From 3d0ee71d09e5db864f3e074b6faf0af14544e98d Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 01:32:39 +0300 Subject: [PATCH 1/4] fix(team): stabilize provider live smoke --- scripts/lib/opencode-live-preflight.mjs | 28 ++++++++++++++- scripts/prove-provider-launch-stress.mjs | 10 ++++-- .../runtime/teamRuntimeSettingsBundle.ts | 3 +- .../services/team/TeamProvisioningService.ts | 22 +++++++++--- .../team/MixedProviderTeamLaunch.live.test.ts | 2 +- .../ProviderLaunchStress.live-e2e.test.ts | 2 +- .../TeamProvisioningServicePrepare.test.ts | 36 +++++++++++++++++++ test/scripts/opencodeLivePreflight.test.ts | 18 +++++++++- 8 files changed, 110 insertions(+), 11 deletions(-) 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/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..b6779c3a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1588,6 +1588,18 @@ 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 buildProviderFastModeArgs( providerId: TeamProviderId, launchIdentity?: ProviderModelLaunchIdentity | null @@ -7124,7 +7136,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 +7149,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 +7173,7 @@ export class TeamProvisioningService { ...splitExtraArgs.settingsFragments, ], anthropicHelper: helper, + settingsDirectory: helper ? null : buildRuntimeSettingsTempDirectory(input.teamName), }); return { 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..6c8763f4 100644 --- a/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts +++ b/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts @@ -42,7 +42,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', diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index deee5f14..b7e2a31c 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -2864,6 +2864,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/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 }); From 3908b5eb25acbed59c26cf8fab57cbeb5107aa61 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 02:41:20 +0300 Subject: [PATCH 2/4] fix(workspace-trust): harden Claude preflight for live teams --- .../ClaudePtyWorkspaceTrustStrategy.ts | 42 +++++++++++++++-- .../adapters/output/NodePtyProcessAdapter.ts | 6 ++- .../ClaudePtyWorkspaceTrustStrategy.test.ts | 45 ++++++++++++++++--- .../ProviderLaunchStress.live-e2e.test.ts | 19 ++++++++ 4 files changed, 101 insertions(+), 11 deletions(-) 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/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/ProviderLaunchStress.live-e2e.test.ts b/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts index 6c8763f4..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, @@ -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[], From 860074da15c4cbd188072dde527cef59b81c3e95 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 02:43:28 +0300 Subject: [PATCH 3/4] fix(team): stabilize Claude live runtime launches --- .../services/team/TeamProvisioningService.ts | 12 ++ .../MemberWorkSyncClaudeStopHook.live.test.ts | 145 ++++++++++++++++-- .../TeamProvisioningServicePrepare.test.ts | 28 ++++ .../team/memberWorkSyncLiveHarness.test.ts | 119 ++++++++++++++ .../team/memberWorkSyncLiveHarness.ts | 51 +++++- 5 files changed, 336 insertions(+), 19 deletions(-) create mode 100644 test/main/services/team/memberWorkSyncLiveHarness.test.ts diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b6779c3a..33daae73 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1600,6 +1600,15 @@ function buildRuntimeSettingsTempDirectory(teamName: string): string { ); } +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 @@ -22033,6 +22042,7 @@ export class TeamProvisioningService { contextLabel: 'Team create launch', }); const spawnArgs = mergeJsonSettingsArgs([ + '--print', '--input-format', 'stream-json', '--output-format', @@ -23302,6 +23312,7 @@ export class TeamProvisioningService { throw error; } const launchArgs = [ + '--print', '--input-format', 'stream-json', '--output-format', @@ -36072,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/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/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index b7e2a31c..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 }) => 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; From 4539a1609c5ec0b0988f9b31ce6a8119353bc810 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 03:05:23 +0300 Subject: [PATCH 4/4] docs(readme): refresh install guidance and roadmap --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f42ab4b1..8d2c7821 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,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. -
@@ -343,16 +341,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