|
@@ -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
diff --git a/scripts/lib/opencode-live-preflight.mjs b/scripts/lib/opencode-live-preflight.mjs
index 176720fa..379e5463 100644
--- a/scripts/lib/opencode-live-preflight.mjs
+++ b/scripts/lib/opencode-live-preflight.mjs
@@ -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,
};
diff --git a/scripts/prove-provider-launch-stress.mjs b/scripts/prove-provider-launch-stress.mjs
index b757e964..eaea932b 100644
--- a/scripts/prove-provider-launch-stress.mjs
+++ b/scripts/prove-provider-launch-stress.mjs
@@ -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 = [];
diff --git a/src/features/workspace-trust/core/application/ClaudePtyWorkspaceTrustStrategy.ts b/src/features/workspace-trust/core/application/ClaudePtyWorkspaceTrustStrategy.ts
index d069b403..8809aab9 100644
--- a/src/features/workspace-trust/core/application/ClaudePtyWorkspaceTrustStrategy.ts
+++ b/src/features/workspace-trust/core/application/ClaudePtyWorkspaceTrustStrategy.ts
@@ -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 {
+ 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>> {
+ 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,
diff --git a/src/features/workspace-trust/main/adapters/output/NodePtyProcessAdapter.ts b/src/features/workspace-trust/main/adapters/output/NodePtyProcessAdapter.ts
index 76345b36..c5eebf08 100644
--- a/src/features/workspace-trust/main/adapters/output/NodePtyProcessAdapter.ts
+++ b/src/features/workspace-trust/main/adapters/output/NodePtyProcessAdapter.ts
@@ -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;
diff --git a/src/main/services/runtime/teamRuntimeSettingsBundle.ts b/src/main/services/runtime/teamRuntimeSettingsBundle.ts
index 99bda9d9..8a01ba3c 100644
--- a/src/main/services/runtime/teamRuntimeSettingsBundle.ts
+++ b/src/main/services/runtime/teamRuntimeSettingsBundle.ts
@@ -123,6 +123,7 @@ export async function materializeTeamRuntimeSettingsBundle(input: {
providerId: TeamProviderId;
baseSettings?: (TeamRuntimeSettingsJson | null | undefined)[];
anthropicHelper?: AnthropicTeamApiKeyHelperMaterial | null;
+ settingsDirectory?: string | null;
}): Promise {
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;
}
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 4add31bf..33daae73 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -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,
diff --git a/test/features/workspace-trust/core/ClaudePtyWorkspaceTrustStrategy.test.ts b/test/features/workspace-trust/core/ClaudePtyWorkspaceTrustStrategy.test.ts
index c4353765..f8989d5e 100644
--- a/test/features/workspace-trust/core/ClaudePtyWorkspaceTrustStrategy.test.ts
+++ b/test/features/workspace-trust/core/ClaudePtyWorkspaceTrustStrategy.test.ts
@@ -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 () => {
diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts
index bb211e99..5c043588 100644
--- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts
+++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts
@@ -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 {
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 {
+ 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) }
+ : {};
+ const currentProject =
+ projects[normalizedProjectPath] &&
+ typeof projects[normalizedProjectPath] === 'object' &&
+ !Array.isArray(projects[normalizedProjectPath])
+ ? (projects[normalizedProjectPath] as Record)
+ : {};
+ 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 {
+ 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 | null {
+ if (!raw) {
+ return null;
+ }
+ const parsed = JSON.parse(raw);
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : 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;
}
diff --git a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts
index db246ebd..76a42071 100644
--- a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts
+++ b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts
@@ -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;
diff --git a/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts b/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts
index c0863de4..6139b6df 100644
--- a/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts
+++ b/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts
@@ -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[],
diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts
index deee5f14..45404b49 100644
--- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts
+++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts
@@ -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 }) =>
diff --git a/test/main/services/team/memberWorkSyncLiveHarness.test.ts b/test/main/services/team/memberWorkSyncLiveHarness.test.ts
new file mode 100644
index 00000000..3bd481d8
--- /dev/null
+++ b/test/main/services/team/memberWorkSyncLiveHarness.test.ts
@@ -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 {
+ return {
+ isApiErrorMessage: true,
+ error: 'unknown',
+ timestamp: new Date(timestampMs).toISOString(),
+ message: {
+ content: [{ type: 'text', text }],
+ },
+ };
+}
diff --git a/test/main/services/team/memberWorkSyncLiveHarness.ts b/test/main/services/team/memberWorkSyncLiveHarness.ts
index 74716a5a..df295ff3 100644
--- a/test/main/services/team/memberWorkSyncLiveHarness.ts
+++ b/test/main/services/team/memberWorkSyncLiveHarness.ts
@@ -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 {
- 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 {
+ const projectsRoot = path.join(claudeRoot, 'projects');
+ if (!projectPath) {
+ return [projectsRoot];
+ }
+
+ const candidateRoots = new Set();
+ 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, 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;
diff --git a/test/scripts/opencodeLivePreflight.test.ts b/test/scripts/opencodeLivePreflight.test.ts
index 32f768fc..12621d2a 100644
--- a/test/scripts/opencodeLivePreflight.test.ts
+++ b/test/scripts/opencodeLivePreflight.test.ts
@@ -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;
taskkillProcessTree(pid: number): Promise;
};
@@ -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 });
|