merge: dev into main

This commit is contained in:
777genius 2026-05-20 03:44:08 +03:00
commit be95b564cf
15 changed files with 550 additions and 46 deletions

View file

@ -53,8 +53,6 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42
No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI.
If you want the FRESHEST version, clone the repo and run it from the `dev` branch.
<table align="center">
<tr>
<td align="center">
@ -347,16 +345,16 @@ local packaging.
- [ ] Planning mode to organize agent plans before execution
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
- [ ] Remote agent execution via SSH: launch and manage agent teams on remote machines over SSH (stream-json protocol over SSH channel, SFTP-based file monitoring for tasks/inboxes/config)
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
- [x] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
- [ ] Slash commands
- [x] Slash commands
- [ ] Outgoing message queue — queue user messages while the lead (or agent) is busy; clear agent-busy status in the UI; flush to stdin or relay from inbox when idle (durable queue on disk for the lead inbox path)
- [ ] `createTasksBatch` — IPC/service API to create many team tasks in one call (playbooks, markdown checklist import, scripts); complements single `createTask`
- [ ] Command palette — extend Cmd/Ctrl+K beyond project/session search to runnable actions (quick commands, navigation shortcuts, team/task operations) in a keyboard-first flow
- [ ] Custom kanban columns
- [ ] Run terminal commands
- [ ] Monitor agents processes/stats
- [x] Monitor agents processes/stats
- [ ] Reusable agents with SOUL.md
- [ ] Сommunicate via messenger
- [ ] SDK to programmatically launch agents

View file

@ -11,6 +11,9 @@ const OPENCODE_HEALTH_FETCH_TIMEOUT_MS = 1_000;
export async function preflightOpenCodeLiveEnvironment(input) {
const repoRoot = input.repoRoot;
const requiredModels = Array.isArray(input.requiredModels)
? input.requiredModels.map((model) => String(model).trim()).filter(Boolean)
: [];
const opencodeBin = process.env.OPENCODE_BIN?.trim() || '/opt/homebrew/bin/opencode';
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-live-preflight-'));
const xdgDataHome = path.join(tempRoot, 'xdg-data');
@ -29,6 +32,14 @@ export async function preflightOpenCodeLiveEnvironment(input) {
if (!models.ok) {
return skip(`opencode models failed: ${models.output}`);
}
const missingModels = findMissingOpenCodeModels(models.output, requiredModels);
if (missingModels.length > 0) {
return skip(
`opencode models missing selected model(s): ${missingModels.join(', ')}. Available: ${compactOutput(
parseOpenCodeModels(models.output).join(', ') || 'none'
)}`
);
}
const agents = runOpenCodeCommand(opencodeBin, ['agent', 'list'], repoRoot, env);
if (!agents.ok) {
@ -68,7 +79,7 @@ function runOpenCodeCommand(opencodeBin, args, cwd, env) {
maxBuffer: 256_000,
});
if (result.status === 0) {
return { ok: true, output: '' };
return { ok: true, output: result.stdout || '' };
}
return {
ok: false,
@ -76,6 +87,19 @@ function runOpenCodeCommand(opencodeBin, args, cwd, env) {
};
}
function parseOpenCodeModels(output) {
return output
.split(/\s+/)
.map((model) => model.trim())
.filter(Boolean);
}
function findMissingOpenCodeModels(output, requiredModels) {
if (requiredModels.length === 0) return [];
const available = new Set(parseOpenCodeModels(output));
return requiredModels.filter((model) => !available.has(model));
}
function canBindLoopback() {
return new Promise((resolve) => {
const server = net.createServer();
@ -287,7 +311,9 @@ function compactOutput(value) {
}
export const __opencodeLivePreflightTestHooks = {
findMissingOpenCodeModels,
isHealthyOpenCodeHostResponse,
parseOpenCodeModels,
stopChild,
taskkillProcessTree,
};

View file

@ -12,6 +12,7 @@ import { preflightOpenCodeLiveEnvironment } from './lib/opencode-live-preflight.
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle';
const requestedOrder =
process.env.PROVIDER_LAUNCH_STRESS_ORDER?.trim() || 'anthropic,codex,opencode,mixed';
@ -28,6 +29,8 @@ const env = {
process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS?.trim() || '90000',
CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS:
process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS?.trim() || '30000',
PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL:
process.env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL?.trim() || DEFAULT_OPENCODE_MODEL,
OPENCODE_E2E: '1',
OPENCODE_E2E_USE_REAL_APP_CREDENTIALS: '1',
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
@ -47,7 +50,7 @@ console.log(`Anthropic auth: ${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH}`);
console.log(
`Models: anthropic=${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_MODEL || 'haiku'}, codex=${
env.PROVIDER_LAUNCH_STRESS_CODEX_MODEL || 'gpt-5.4-mini'
}, opencode=${env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL || 'openai/gpt-5.4-mini'}`
}, opencode=${env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL}`
);
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
@ -109,7 +112,10 @@ async function preflightProviderLaunchStress(input) {
anthropic: needs.anthropic ? await preflightAnthropic(input.repoRoot) : { ok: true },
codex: needs.codex ? preflightCodex() : { ok: true },
opencode: needs.opencode
? await preflightOpenCodeLiveEnvironment({ repoRoot: input.repoRoot })
? await preflightOpenCodeLiveEnvironment({
repoRoot: input.repoRoot,
requiredModels: [env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL],
})
: { ok: true },
};
const skipped = [];

View file

@ -11,6 +11,12 @@ import type {
} from './ports';
const WORKSPACE_TRUST_RAW_TAIL_LIMIT = 4096;
const CLAUDE_WORKSPACE_TRUST_PREFLIGHT_TIMEOUT_MS = 60_000;
const CLAUDE_WORKSPACE_TRUST_CONFIRM_TIMEOUT_MS = 5_000;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export interface ClaudePtyWorkspaceTrustStrategyInput {
claudePath: string;
@ -45,6 +51,27 @@ function buildRawTail(snapshot: TerminalSnapshot | undefined): string | undefine
return normalized.slice(-WORKSPACE_TRUST_RAW_TAIL_LIMIT);
}
async function waitForTrustedState(input: {
stateProbe: ProviderStateProbe;
workspace: WorkspaceTrustWorkspace;
isCancelled(): boolean;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<Awaited<ReturnType<ProviderStateProbe['readTrustState']>>> {
const pollIntervalMs = Math.max(1, input.pollIntervalMs);
const deadline = Date.now() + input.timeoutMs;
let last = await input.stateProbe.readTrustState(input.workspace);
while (last.status !== 'trusted' && !input.isCancelled()) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
break;
}
await sleep(Math.min(pollIntervalMs, remainingMs));
last = await input.stateProbe.readTrustState(input.workspace);
}
return last;
}
function worseStatus(
current: WorkspaceTrustDiagnosticStrategyResult['status'],
next: WorkspaceTrustDiagnosticStrategyResult['status']
@ -152,13 +179,22 @@ export class ClaudePtyWorkspaceTrustStrategy {
session: spawnResult.session,
detect: detectClaudeStartupState,
isCancelled: input.isCancelled,
timeoutMs: input.timeoutMs,
timeoutMs: input.timeoutMs ?? CLAUDE_WORKSPACE_TRUST_PREFLIGHT_TIMEOUT_MS,
pollIntervalMs: input.pollIntervalMs,
afterDialogAction: async ({ ruleId }) => {
if (ruleId !== 'claude.workspace_trust') {
return { action: 'continue' };
}
const after = await stateProbe.readTrustState(workspace);
const after = await waitForTrustedState({
stateProbe,
workspace,
isCancelled: input.isCancelled,
timeoutMs: Math.min(
CLAUDE_WORKSPACE_TRUST_CONFIRM_TIMEOUT_MS,
input.timeoutMs ?? CLAUDE_WORKSPACE_TRUST_CONFIRM_TIMEOUT_MS
),
pollIntervalMs: input.pollIntervalMs ?? 100,
});
if (after.status === 'trusted') {
evidence.push(...after.evidence);
return { action: 'stop', reason: 'workspace_trust_persisted' };
@ -218,7 +254,7 @@ export class ClaudePtyWorkspaceTrustStrategy {
workspaceIds,
matchedRuleIds: [...new Set(matchedRuleIds)],
actions,
evidence,
evidence: [...new Set(evidence)],
elapsedMs: Date.now() - startedAt,
errorCode,
errorMessage,

View file

@ -1,3 +1,5 @@
import { createRequire } from 'node:module';
import { createLogger } from '@shared/utils/logger';
import type {
@ -13,6 +15,7 @@ import type * as NodePty from 'node-pty';
const logger = createLogger('WorkspaceTrustNodePtyProcessAdapter');
const MAX_TRANSCRIPT_CHARS = 64 * 1024;
const requireNativeAddon = createRequire(import.meta.url);
type NodePtyModule = typeof NodePty;
@ -23,8 +26,7 @@ function loadNodePty(): NodePtyModule | null {
return nodePty;
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon
nodePty = require('node-pty') as NodePtyModule;
nodePty = requireNativeAddon('node-pty') as NodePtyModule;
} catch (error) {
logger.warn(`node-pty unavailable for workspace trust preflight: ${String(error)}`);
nodePty = null;

View file

@ -123,6 +123,7 @@ export async function materializeTeamRuntimeSettingsBundle(input: {
providerId: TeamProviderId;
baseSettings?: (TeamRuntimeSettingsJson | null | undefined)[];
anthropicHelper?: AnthropicTeamApiKeyHelperMaterial | null;
settingsDirectory?: string | null;
}): Promise<TeamRuntimeSettingsBundle | null> {
const fragments = [...(input.baseSettings ?? [])].filter(
(fragment): fragment is TeamRuntimeSettingsJson =>
@ -145,7 +146,7 @@ export async function materializeTeamRuntimeSettingsBundle(input: {
return null;
}
const baseDirectory = input.anthropicHelper?.directory;
const baseDirectory = input.anthropicHelper?.directory ?? input.settingsDirectory;
if (!baseDirectory) {
return null;
}

View file

@ -1588,6 +1588,27 @@ function buildAnthropicSettingsArgs(
return ['--settings', JSON.stringify(settings)];
}
function sanitizeRuntimeSettingsTeamName(teamName: string): string {
return teamName.replace(/[^a-zA-Z0-9._-]+/g, '_') || 'team';
}
function buildRuntimeSettingsTempDirectory(teamName: string): string {
return path.join(
os.tmpdir(),
'agent-teams-runtime-settings',
`${sanitizeRuntimeSettingsTeamName(teamName)}-${randomUUID()}`
);
}
function normalizeTeamRuntimeNodeEnv(env: NodeJS.ProcessEnv): void {
// Vitest sets NODE_ENV=test in the desktop parent process. Real team runtime
// children must run the CLI normally, otherwise source launches can take
// test-only startup paths and exit before deterministic bootstrap starts.
if (env.NODE_ENV === 'test') {
env.NODE_ENV = 'development';
}
}
function buildProviderFastModeArgs(
providerId: TeamProviderId,
launchIdentity?: ProviderModelLaunchIdentity | null
@ -7124,7 +7145,7 @@ export class TeamProvisioningService {
const rawProviderArgs = input.envResolution.providerArgs ?? [];
const rawExtraArgs = input.extraArgs ?? [];
if (!helper) {
if (!helper && resolvedProviderId !== 'anthropic') {
return {
settingsArgs: [],
fastModeArgs: buildProviderFastModeArgs(resolvedProviderId, input.launchIdentity),
@ -7137,13 +7158,14 @@ export class TeamProvisioningService {
const providerArgsWithoutHelper = filterOutSettingsPathArgs(
rawProviderArgs,
helper.settingsPath
helper?.settingsPath
);
const splitProviderArgs = splitSettingsJsonArgs(providerArgsWithoutHelper);
const splitExtraArgs = splitSettingsJsonArgs(rawExtraArgs);
if (
hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) ||
hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs)
helper &&
(hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) ||
hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs))
) {
throw new Error(
`${input.contextLabel}: app-managed Anthropic API-key helper cannot be combined with path-based --settings. Use inline JSON settings or remove the custom --settings path.`
@ -7160,6 +7182,7 @@ export class TeamProvisioningService {
...splitExtraArgs.settingsFragments,
],
anthropicHelper: helper,
settingsDirectory: helper ? null : buildRuntimeSettingsTempDirectory(input.teamName),
});
return {
@ -22019,6 +22042,7 @@ export class TeamProvisioningService {
contextLabel: 'Team create launch',
});
const spawnArgs = mergeJsonSettingsArgs([
'--print',
'--input-format',
'stream-json',
'--output-format',
@ -23288,6 +23312,7 @@ export class TeamProvisioningService {
throw error;
}
const launchArgs = [
'--print',
'--input-format',
'stream-json',
'--output-format',
@ -36058,6 +36083,7 @@ export class TeamProvisioningService {
: {}),
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
};
normalizeTeamRuntimeNodeEnv(env);
const resolvedProviderId = resolveTeamProviderId(providerId);
const providerEnvResult = await buildProviderAwareCliEnv({
providerId,

View file

@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest';
import { ClaudePtyWorkspaceTrustStrategy } from '@features/workspace-trust/core/application';
import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/core/domain';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
ProviderStateProbe,
@ -83,6 +82,10 @@ function workspace(cwd = '/tmp/project') {
}
describe('ClaudePtyWorkspaceTrustStrategy', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('skips PTY when the state probe already reports trusted', async () => {
const pty = new FakePtyProcess();
const result = await new ClaudePtyWorkspaceTrustStrategy().execute({
@ -172,15 +175,17 @@ describe('ClaudePtyWorkspaceTrustStrategy', () => {
it('accepts the trust dialog, verifies persisted trust, kills PTY, and cleans temp MCP config', async () => {
const pty = new FakePtyProcess();
const tempStore = new FakeTempStore();
const stateProbe = new FakeStateProbe([
{ status: 'untrusted' },
{ status: 'untrusted' },
{ status: 'trusted', evidence: ['trusted project key: /tmp/project'] },
]);
const result = await new ClaudePtyWorkspaceTrustStrategy().execute({
claudePath: '/usr/local/bin/claude',
workspaces: [workspace()],
env: { HOME: '/Users/tester', PATH: '/usr/local/bin', OPTIONAL_EMPTY: undefined },
ptyProcess: pty,
stateProbe: new FakeStateProbe([
{ status: 'untrusted' },
{ status: 'trusted', evidence: ['trusted project key: /tmp/project'] },
]),
stateProbe,
tempEmptyMcpConfigStore: tempStore,
isCancelled: () => false,
timeoutMs: 100,
@ -190,6 +195,7 @@ describe('ClaudePtyWorkspaceTrustStrategy', () => {
expect(result.status).toBe('ok');
expect(result.matchedRuleIds).toEqual(['claude.workspace_trust']);
expect(result.actions).toEqual(['claude.workspace_trust:enter']);
expect(result.evidence).toEqual(['trusted project key: /tmp/project']);
expect(pty.spawnInputs[0]).toMatchObject({
command: '/usr/local/bin/claude',
cwd: '/tmp/project',
@ -203,6 +209,33 @@ describe('ClaudePtyWorkspaceTrustStrategy', () => {
expect(pty.session?.actions.map((action) => action.id)).toEqual(['enter']);
expect(pty.session?.killed).toBe(true);
expect(tempStore.cleaned).toBe(true);
expect(stateProbe.calls).toBe(4);
});
it('keeps the default Claude preflight alive long enough for slow startup trust prompts', async () => {
const pty = new FakePtyProcess();
const session = new FakeSession(['Starting Claude...', 'Quick safety check\nYes, I trust this folder']);
pty.spawnResult = { ok: true, session };
const nowValues = [0, 0, 0, 0, 46_000, 46_000, 46_000];
vi.spyOn(Date, 'now').mockImplementation(() => nowValues.shift() ?? 46_000);
const result = await new ClaudePtyWorkspaceTrustStrategy().execute({
claudePath: '/usr/local/bin/claude',
workspaces: [workspace()],
env: { HOME: '/Users/tester' },
ptyProcess: pty,
stateProbe: new FakeStateProbe([
{ status: 'untrusted' },
{ status: 'trusted', evidence: ['trusted project key: /tmp/project'] },
]),
tempEmptyMcpConfigStore: new FakeTempStore(),
isCancelled: () => false,
pollIntervalMs: 1,
});
expect(result.status).toBe('ok');
expect(result.matchedRuleIds).toEqual(['claude.workspace_trust']);
expect(session.actions.map((action) => action.id)).toEqual(['enter']);
});
it('soft-fails when node-pty is unavailable instead of throwing', async () => {

View file

@ -15,6 +15,7 @@ import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMem
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
import {
getTasksBasePath,
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
@ -51,7 +52,6 @@ const liveDescribe =
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'sonnet';
const DEFAULT_EFFORT = 'low' as const;
type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up';
@ -98,17 +98,16 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
let previousHome: string | undefined;
let previousHistFile: string | undefined;
let previousUserProfile: string | undefined;
let claudeJsonConfigRoot: string;
let previousClaudeJsonConfig: string | null | undefined;
let svc: TeamProvisioningService | null;
let feature: MemberWorkSyncFeatureFacade | null;
let controlServer: MemberWorkSyncLiveControlServer | null;
let teamName: string | null;
let usingConnectedClaudeAccount = false;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-claude-stop-live-'));
tempClaudeRoot = path.join(tempDir, '.claude');
await fs.mkdir(tempClaudeRoot, { recursive: true });
setClaudeBasePathOverride(tempClaudeRoot);
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
@ -117,12 +116,24 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
previousHome = process.env.HOME;
previousHistFile = process.env.HISTFILE;
previousUserProfile = process.env.USERPROFILE;
previousClaudeJsonConfig = undefined;
const shouldUseConnectedAccountHome = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
tempHome = shouldUseConnectedAccountHome
? resolveConnectedClaudeHome(previousHome)
usingConnectedClaudeAccount = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
tempHome = usingConnectedClaudeAccount
? resolveConnectedClaudeHome()
: path.join(tempDir, 'home');
tempClaudeRoot = usingConnectedClaudeAccount
? path.join(tempHome, '.claude')
: path.join(tempDir, '.claude');
claudeJsonConfigRoot = usingConnectedClaudeAccount ? tempHome : tempClaudeRoot;
await fs.mkdir(tempHome, { recursive: true });
await fs.mkdir(tempClaudeRoot, { recursive: true });
if (usingConnectedClaudeAccount) {
setClaudeBasePathOverride(null);
} else {
setClaudeBasePathOverride(tempClaudeRoot);
}
process.env.HOME = tempHome;
process.env.HISTFILE = '/dev/null';
@ -140,6 +151,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
});
afterEach(async () => {
const keepTemp = process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1';
if (svc && teamName) {
await svc.stopTeam(teamName).catch(() => undefined);
}
@ -148,6 +160,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
svc?.setRuntimeTurnSettledHookSettingsProvider(null);
await feature?.dispose().catch(() => undefined);
await controlServer?.close().catch(() => undefined);
discardKnownClaudeStopHookWarnings();
if (!keepTemp && usingConnectedClaudeAccount && teamName) {
await fs.rm(path.join(getTeamsBasePath(), teamName), { recursive: true, force: true });
await fs.rm(path.join(getTasksBasePath(), teamName), { recursive: true, force: true });
}
if (usingConnectedClaudeAccount && previousClaudeJsonConfig !== undefined) {
await restoreClaudeJsonConfig(claudeJsonConfigRoot, previousClaudeJsonConfig);
}
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
@ -158,7 +178,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
restoreEnv('HISTFILE', previousHistFile);
restoreEnv('USERPROFILE', previousUserProfile);
setClaudeBasePathOverride(null);
if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') {
if (keepTemp) {
console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`);
} else {
await removeTempDirAfterLateShellWrites(tempDir);
@ -186,7 +206,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`;
const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true });
await writeTrustedClaudeConfig(tempClaudeRoot, projectPath);
if (usingConnectedClaudeAccount) {
previousClaudeJsonConfig = await upsertTrustedClaudeProjectConfig(
claudeJsonConfigRoot,
projectPath
);
} else {
await writeTrustedClaudeConfig(claudeJsonConfigRoot, projectPath);
}
await fs.writeFile(
path.join(projectPath, 'README.md'),
'# Member work sync Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n',
@ -251,7 +278,9 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
cwd: projectPath,
providerId: 'anthropic',
model,
effort: DEFAULT_EFFORT,
...(process.env.MEMBER_WORK_SYNC_CLAUDE_EXTRA_CLI_ARGS?.trim()
? { extraCliArgs: process.env.MEMBER_WORK_SYNC_CLAUDE_EXTRA_CLI_ARGS.trim() }
: {}),
skipPermissions: true,
prompt: [
'Keep launch work minimal and wait for the explicit live-test instruction.',
@ -263,7 +292,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
role: 'Developer',
providerId: 'anthropic',
model,
effort: DEFAULT_EFFORT,
},
],
},
@ -282,6 +310,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
await throwIfClaudeTranscriptApiError({
claudeRoot: tempClaudeRoot,
context: 'Claude team launch',
projectPath,
sinceMs: startedAt,
});
await expect(
@ -339,6 +369,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
await throwIfClaudeTranscriptApiError({
claudeRoot: tempClaudeRoot,
context: 'Claude validation turn',
projectPath,
sinceMs: startedAt,
});
await feature!.replayPendingReports([teamName!]);
const [status, metrics, tasks] = await Promise.all([
@ -377,6 +409,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
await throwIfClaudeTranscriptApiError({
claudeRoot: tempClaudeRoot,
context: 'Claude Stop hook turn',
projectPath,
sinceMs: startedAt,
});
await feature!.drainRuntimeTurnSettledEvents();
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
@ -562,6 +596,26 @@ function hasLiveAnthropicApiKey(): boolean {
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
}
function discardKnownClaudeStopHookWarnings(): void {
const warn = vi.mocked(console.warn);
if (!warn.mock) return;
const calls = warn.mock.calls;
for (let index = calls.length - 1; index >= 0; index -= 1) {
const text = calls[index]?.map((value) => String(value)).join(' ') ?? '';
if (text.includes('Failed to resolve login shell env: shell env resolve timeout')) {
calls.splice(index, 1);
continue;
}
if (text.includes('Failed to resolve interactive shell env: shell env resolve timeout')) {
calls.splice(index, 1);
continue;
}
if (text.includes('Failed to parse runtime model list for launch validation')) {
calls.splice(index, 1);
}
}
}
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
@ -589,14 +643,71 @@ async function writeTrustedClaudeConfig(configDir: string, projectPath: string):
);
}
function resolveConnectedClaudeHome(previousHome: string | undefined): string {
async function upsertTrustedClaudeProjectConfig(
configDir: string,
projectPath: string
): Promise<string | null> {
const configPath = path.join(configDir, '.claude.json');
const previous = await fs.readFile(configPath, 'utf8').catch((error) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
});
const existing = parseJsonObject(previous) ?? {};
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
const projects =
existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects)
? { ...(existing.projects as Record<string, unknown>) }
: {};
const currentProject =
projects[normalizedProjectPath] &&
typeof projects[normalizedProjectPath] === 'object' &&
!Array.isArray(projects[normalizedProjectPath])
? (projects[normalizedProjectPath] as Record<string, unknown>)
: {};
projects[normalizedProjectPath] = {
...currentProject,
hasTrustDialogAccepted: true,
};
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
...existing,
projects,
},
null,
2
)}\n`,
'utf8'
);
return previous;
}
async function restoreClaudeJsonConfig(configDir: string, previous: string | null): Promise<void> {
const configPath = path.join(configDir, '.claude.json');
if (previous === null) {
await fs.rm(configPath, { force: true });
return;
}
await fs.writeFile(configPath, previous, 'utf8');
}
function parseJsonObject(raw: string | null): Record<string, unknown> | null {
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
}
function resolveConnectedClaudeHome(): string {
const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim();
if (explicit) {
return path.resolve(explicit);
}
const previous = previousHome?.trim();
if (previous) {
return path.resolve(previous);
}
return os.userInfo().homedir;
}

View file

@ -40,7 +40,7 @@ const liveDescribe =
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini';
const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle';
liveDescribe('Mixed provider team launch live e2e', () => {
let tempDir: string;

View file

@ -5,10 +5,14 @@ import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createWorkspaceTrustCoordinator } from '../../../../src/features/workspace-trust/main';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
import {
getAutoDetectedClaudeBasePath,
getClaudeBasePath,
getHomeDir,
getTasksBasePath,
getTeamsBasePath,
setClaudeBasePathOverride,
@ -42,7 +46,7 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
const DEFAULT_CODEX_EFFORT = 'low' as const;
const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini';
const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle';
const DEFAULT_ORDER: ProviderLaunchStressScenario[] = ['anthropic', 'codex', 'opencode', 'mixed'];
const MEMBER_NAMES = [
'alice',
@ -236,6 +240,7 @@ async function runProviderStressScenario(
throw error;
}
const svc = harness?.svc ?? new TeamProvisioningService();
configureWorkspaceTrustCoordinator(svc);
const active: ActiveScenario = { scenario, teamName, svc, harness, codexCleanup, failed: false };
activeScenarios.push(active);
@ -296,6 +301,20 @@ async function runProviderStressScenario(
}
}
function configureWorkspaceTrustCoordinator(svc: TeamProvisioningService): void {
svc.setWorkspaceTrustCoordinator(
createWorkspaceTrustCoordinator({
claudeConfigDir: () => getClaudeBasePath(),
globalConfigFilePath: () => {
const claudeBasePath = getClaudeBasePath();
return claudeBasePath !== getAutoDetectedClaudeBasePath()
? path.join(claudeBasePath, '.claude.json')
: path.join(getHomeDir(), '.claude.json');
},
})
);
}
async function runRestartStressChecks(
active: ActiveScenario,
expectedMembers: string[],

View file

@ -2850,6 +2850,34 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(result.env.ANTHROPIC_API_KEY).toBe('real-key');
});
it('does not leak Vitest NODE_ENV into real team runtime children', async () => {
const previousNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'test';
try {
const svc = new TeamProvisioningService();
const buildProvisioningEnv = (
svc as unknown as {
buildProvisioningEnv(): Promise<{ env: NodeJS.ProcessEnv }>;
}
).buildProvisioningEnv.bind(svc);
const result = await buildProvisioningEnv();
expect(result.env.NODE_ENV).toBe('development');
expect(buildProviderAwareCliEnvMock).toHaveBeenLastCalledWith(
expect.objectContaining({
env: expect.objectContaining({ NODE_ENV: 'development' }),
})
);
} finally {
if (previousNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = previousNodeEnv;
}
}
});
it('adds member-work-sync turn-settled spool env for Codex provisioning', async () => {
const svc = new TeamProvisioningService();
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
@ -2864,6 +2892,42 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(result.env.AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT).toBe('/tmp/runtime-hooks');
});
it('materializes Anthropic turn-settled hook settings instead of passing inline JSON', async () => {
const svc = new TeamProvisioningService();
svc.setRuntimeTurnSettledHookSettingsProvider(async ({ provider }) =>
provider === 'claude'
? {
hooks: {
Stop: [
{
matcher: '',
hooks: [{ type: 'command', command: '/bin/true # test-hook' }],
},
],
},
}
: null
);
const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({
teamName: 'anthropic-hook-settings-team',
providerId: 'anthropic',
launchIdentity: null,
envResolution: { providerArgs: [] },
extraArgs: [],
includeAnthropicHelper: false,
contextLabel: 'Team launch',
});
expect(result.fastModeArgs).toEqual([]);
expect(result.runtimeTurnSettledHookArgs).toEqual([]);
expect(result.settingsArgs[0]).toBe('--settings');
const settingsPath = result.settingsArgs[1];
expect(settingsPath).toContain('agent-teams-runtime-settings');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.hooks.Stop[0].hooks[0].command).toBe('/bin/true # test-hook');
});
it('adds Codex turn-settled env when Codex is only a secondary member provider', async () => {
const svc = new TeamProvisioningService();
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>

View file

@ -0,0 +1,119 @@
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { encodePath } from '../../../../src/main/utils/pathDecoder';
import { throwIfClaudeTranscriptApiError } from './memberWorkSyncLiveHarness';
const tempDirs: string[] = [];
describe('memberWorkSyncLiveHarness', () => {
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it('scopes Claude API error checks to the current project transcripts', async () => {
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-harness-'));
tempDirs.push(claudeRoot);
const projectPath = path.join(claudeRoot, 'project');
await fs.mkdir(projectPath, { recursive: true });
const projectTranscriptDir = path.join(
claudeRoot,
'projects',
encodePath(await fs.realpath(projectPath))
);
const unrelatedTranscriptDir = path.join(
claudeRoot,
'projects',
encodePath('/Users/example/other-project')
);
await fs.mkdir(projectTranscriptDir, { recursive: true });
await fs.mkdir(unrelatedTranscriptDir, { recursive: true });
await fs.writeFile(
path.join(unrelatedTranscriptDir, 'unrelated.jsonl'),
`${JSON.stringify(buildApiErrorRecord('wrong project'))}\n`,
'utf8'
);
await expect(
throwIfClaudeTranscriptApiError({
claudeRoot,
context: 'live check',
projectPath,
})
).resolves.toBeUndefined();
await fs.writeFile(
path.join(projectTranscriptDir, 'current.jsonl'),
`${JSON.stringify(buildApiErrorRecord('right project'))}\n`,
'utf8'
);
await expect(
throwIfClaudeTranscriptApiError({
claudeRoot,
context: 'live check',
projectPath,
})
).rejects.toThrow(/right project/);
});
it('ignores stale Claude API errors before the live check start time', async () => {
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-harness-'));
tempDirs.push(claudeRoot);
const projectPath = path.join(claudeRoot, 'project');
await fs.mkdir(projectPath, { recursive: true });
const projectTranscriptDir = path.join(
claudeRoot,
'projects',
encodePath(await fs.realpath(projectPath))
);
await fs.mkdir(projectTranscriptDir, { recursive: true });
const sinceMs = Date.now();
await fs.writeFile(
path.join(projectTranscriptDir, 'current.jsonl'),
`${JSON.stringify(buildApiErrorRecord('old error', sinceMs - 1_000))}\n`,
'utf8'
);
await expect(
throwIfClaudeTranscriptApiError({
claudeRoot,
context: 'live check',
projectPath,
sinceMs,
})
).resolves.toBeUndefined();
await fs.appendFile(
path.join(projectTranscriptDir, 'current.jsonl'),
`${JSON.stringify(buildApiErrorRecord('new error', sinceMs + 1_000))}\n`,
'utf8'
);
await expect(
throwIfClaudeTranscriptApiError({
claudeRoot,
context: 'live check',
projectPath,
sinceMs,
})
).rejects.toThrow(/new error/);
});
});
function buildApiErrorRecord(text: string, timestampMs = Date.now()): Record<string, unknown> {
return {
isApiErrorMessage: true,
error: 'unknown',
timestamp: new Date(timestampMs).toISOString(),
message: {
content: [{ type: 'text', text }],
},
};
}

View file

@ -2,9 +2,10 @@ import { constants as fsConstants, promises as fs } from 'node:fs';
import * as http from 'node:http';
import * as path from 'node:path';
import { encodePath } from '../../../../src/main/utils/pathDecoder';
import type { MemberWorkSyncReportRequest } from '../../../../src/features/member-work-sync/contracts';
import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main';
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
export class FatalWaitError extends Error {
@ -199,8 +200,11 @@ export async function formatMemberWorkSyncDiagnostics(input: {
export async function throwIfClaudeTranscriptApiError(input: {
claudeRoot: string;
context: string;
projectPath?: string;
sinceMs?: number;
}): Promise<void> {
const transcriptFiles = await findJsonlFiles(path.join(input.claudeRoot, 'projects'));
const transcriptRoots = await resolveClaudeTranscriptRoots(input.claudeRoot, input.projectPath);
const transcriptFiles = (await Promise.all(transcriptRoots.map(findJsonlFiles))).flat();
const apiErrors: Array<{ filePath: string; error: string; text: string }> = [];
for (const filePath of transcriptFiles) {
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
@ -218,6 +222,9 @@ export async function throwIfClaudeTranscriptApiError(input: {
} catch {
continue;
}
if (input.sinceMs !== undefined && isTranscriptRecordBefore(parsed, input.sinceMs)) {
continue;
}
if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') {
continue;
}
@ -247,6 +254,46 @@ export async function throwIfClaudeTranscriptApiError(input: {
);
}
async function resolveClaudeTranscriptRoots(
claudeRoot: string,
projectPath: string | undefined
): Promise<string[]> {
const projectsRoot = path.join(claudeRoot, 'projects');
if (!projectPath) {
return [projectsRoot];
}
const candidateRoots = new Set<string>();
const addCandidate = (candidatePath: string) => {
candidateRoots.add(path.join(projectsRoot, encodePath(candidatePath)));
};
addCandidate(path.resolve(projectPath));
const realProjectPath = await fs.realpath(projectPath).catch(() => null);
if (realProjectPath) {
addCandidate(realProjectPath);
}
const existingRoots: string[] = [];
for (const candidateRoot of candidateRoots) {
const stats = await fs.stat(candidateRoot).catch(() => null);
if (stats?.isDirectory()) {
existingRoots.push(candidateRoot);
}
}
return existingRoots;
}
function isTranscriptRecordBefore(record: Record<string, unknown>, sinceMs: number): boolean {
const timestamp = record.timestamp;
const timestampMs =
typeof timestamp === 'string'
? Date.parse(timestamp)
: typeof timestamp === 'number'
? timestamp
: Number.NaN;
return Number.isFinite(timestampMs) && timestampMs < sinceMs;
}
export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise<
Array<{
filePath: string;

View file

@ -5,7 +5,6 @@ import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { afterEach, describe, expect, it, vi } from 'vitest';
interface StopChildOptions {
@ -17,7 +16,9 @@ interface StopChildOptions {
interface OpenCodeLivePreflightTestHooks {
__opencodeLivePreflightTestHooks: {
findMissingOpenCodeModels(output: string, requiredModels: string[]): string[];
isHealthyOpenCodeHostResponse(response: { ok: boolean }): boolean;
parseOpenCodeModels(output: string): string[];
stopChild(child: FakeChild, options?: StopChildOptions): Promise<void>;
taskkillProcessTree(pid: number): Promise<void>;
};
@ -47,6 +48,21 @@ describe('opencode live preflight cleanup', () => {
expect(isHealthyOpenCodeHostResponse({ ok: false })).toBe(false);
});
it('detects selected OpenCode models missing from preflight output', async () => {
const { findMissingOpenCodeModels, parseOpenCodeModels } = (await loadTestHooks())
.__opencodeLivePreflightTestHooks;
const output = 'opencode/big-pickle\nopencode/minimax-m2.5-free\n';
expect(parseOpenCodeModels(output)).toEqual([
'opencode/big-pickle',
'opencode/minimax-m2.5-free',
]);
expect(findMissingOpenCodeModels(output, ['opencode/big-pickle'])).toEqual([]);
expect(findMissingOpenCodeModels(output, ['openai/gpt-5.4-mini'])).toEqual([
'openai/gpt-5.4-mini',
]);
});
it('waits for child close after Windows process-tree cleanup', async () => {
const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks;
const child = new FakeChild({ pid: 1234 });