From 5610e13b9817d6978e4bd89a658431607f9c3310 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 00:03:26 +0300 Subject: [PATCH 01/10] fix(runtime): hide cli child windows by default --- src/main/utils/childProcess.ts | 16 +++++++++------ test/main/utils/childProcess.test.ts | 29 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index 6bc72d94..7305f824 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -242,12 +242,16 @@ export function killTrackedCliProcesses(signal: NodeJS.Signals = 'SIGKILL'): voi } } -/** Merge CLI_ENV_DEFAULTS into spawn/exec options.env (or process.env if absent). */ -function withCliEnv }>( - options: T -): T { +/** Apply shared CLI process defaults without overriding explicit caller choices. */ +function withCliProcessDefaults< + T extends { + env?: NodeJS.ProcessEnv | Record; + windowsHide?: boolean; + }, +>(options: T): T & { windowsHide: boolean } { return { ...options, + windowsHide: options.windowsHide ?? true, env: { ...(options.env ?? process.env), ...CLI_ENV_DEFAULTS }, }; } @@ -270,7 +274,7 @@ export async function execCli( ); } const target = binaryPath; - const opts = withCliEnv(options); + const opts = withCliProcessDefaults(options); const directLauncher = resolveDirectWindowsLauncher(target); if (directLauncher) { const result = await execFileAsync( @@ -316,7 +320,7 @@ export function spawnCli( args: string[], options: SpawnOptions = {} ): ReturnType { - const opts = withCliEnv(options); + const opts = withCliProcessDefaults(options); const directLauncher = resolveDirectWindowsLauncher(binaryPath); if (directLauncher) { const directOpts = { ...opts }; diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts index 14f5982c..e444d7fa 100644 --- a/test/main/utils/childProcess.test.ts +++ b/test/main/utils/childProcess.test.ts @@ -134,6 +134,18 @@ describe('cli child process helpers', () => { expect(result).toEqual({} as any); }); + it('hides spawned CLI windows by default but preserves explicit opt-out', () => { + setPlatform('win32'); + const spawnMock = child.spawn as unknown as Mock; + spawnMock.mockReturnValue({} as any); + + spawnCli('C:\\bin\\claude.exe', ['--version']); + expect(spawnMock.mock.calls[0][2]).toMatchObject({ windowsHide: true }); + + spawnCli('C:\\bin\\claude.exe', ['--version'], { windowsHide: false }); + expect(spawnMock.mock.calls[1][2]).toMatchObject({ windowsHide: false }); + }); + it('falls back to shell when spawn throws EINVAL', () => { setPlatform('win32'); const error: any = new Error('spawn EINVAL'); @@ -296,6 +308,23 @@ describe('cli child process helpers', () => { expect(result.stdout).toBe('ok'); }); + it('hides exec CLI windows by default but preserves explicit opt-out', async () => { + setPlatform('win32'); + const execFileMock = child.execFile as unknown as Mock; + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => { + cb(null, 'ok', ''); + return {} as any; + } + ); + + await execCli('C:\\bin\\claude.exe', ['--version']); + expect(execFileMock.mock.calls[0][2]).toMatchObject({ windowsHide: true }); + + await execCli('C:\\bin\\claude.exe', ['--version'], { windowsHide: false }); + expect(execFileMock.mock.calls[1][2]).toMatchObject({ windowsHide: false }); + }); + it('skips straight to shell for Windows cmd launchers', async () => { setPlatform('win32'); const execFileMock = child.execFile as unknown as Mock; From 1b086f41b714d72e9c9ba039962d95e96713641c Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 00:32:54 +0300 Subject: [PATCH 02/10] fix(team): keep create preflight alive across rerenders --- .../team/dialogs/CreateTeamDialog.tsx | 25 +- .../team/dialogs/LaunchTeamDialog.test.ts | 219 ++++++++++++++++++ 2 files changed, 234 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index b72b6ac3..4f353e34 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -691,6 +691,15 @@ export const CreateTeamDialog = ({ } }, [open]); + useEffect(() => { + return () => { + cancelScheduledIdle(prepareIdleHandleRef.current); + prepareIdleHandleRef.current = null; + prepareRequestSeqRef.current += 1; + lastPrepareRequestSignatureRef.current = null; + }; + }, []); + const prepareRuntimeStatusSignature = useMemo( () => buildProviderPrepareRuntimeStatusSignature( @@ -800,12 +809,16 @@ export const CreateTeamDialog = ({ useEffect(() => { if (!open || !canCreate || !launchTeam) { + cancelScheduledIdle(prepareIdleHandleRef.current); + prepareIdleHandleRef.current = null; prepareRequestSeqRef.current += 1; lastPrepareRequestSignatureRef.current = null; return; } if (typeof api.teams.prepareProvisioning !== 'function') { + cancelScheduledIdle(prepareIdleHandleRef.current); + prepareIdleHandleRef.current = null; prepareRequestSeqRef.current += 1; lastPrepareRequestSignatureRef.current = null; setPrepareState('failed'); @@ -818,6 +831,8 @@ export const CreateTeamDialog = ({ } if (!effectiveCwd) { + cancelScheduledIdle(prepareIdleHandleRef.current); + prepareIdleHandleRef.current = null; prepareRequestSeqRef.current += 1; lastPrepareRequestSignatureRef.current = null; setPrepareState('idle'); @@ -1023,16 +1038,6 @@ export const CreateTeamDialog = ({ } })(); }); - - return () => { - cancelScheduledIdle(prepareIdleHandleRef.current); - prepareIdleHandleRef.current = null; - // Bump the request sequence so any callback that already woke up but - // hasn't checked yet treats itself as superseded. - if (prepareRequestSeqRef.current === requestSeq) { - prepareRequestSeqRef.current += 1; - } - }; }, [ open, canCreate, diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index 788e5343..4f794a3a 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -8,6 +8,51 @@ const fetchCliStatus = vi.fn(); const createSchedule = vi.fn(); const updateSchedule = vi.fn(); const teamRosterEditorSectionMock = vi.hoisted(() => ({ lastProps: null as any })); +const createTeamDraftMock = vi.hoisted(() => ({ + state: { + teamName: 'team-alpha', + setTeamName: vi.fn(), + members: [ + { + id: 'member-opencode', + name: 'tom', + roleSelection: '', + customRole: 'Developer', + workflow: '', + providerId: 'opencode', + model: 'opencode/big-pickle', + }, + { + id: 'member-codex', + name: 'bob', + roleSelection: '', + customRole: 'Developer', + workflow: '', + providerId: 'codex', + model: 'gpt-5.5', + }, + ], + setMembers: vi.fn(), + syncModelsWithLead: false, + setSyncModelsWithLead: vi.fn(), + teammateWorktreeDefault: false, + setTeammateWorktreeDefault: vi.fn(), + cwdMode: 'project' as const, + setCwdMode: vi.fn(), + selectedProjectPath: '/tmp/project', + setSelectedProjectPath: vi.fn(), + customCwd: '', + setCustomCwd: vi.fn(), + soloTeam: false, + setSoloTeam: vi.fn(), + launchTeam: true, + setLaunchTeam: vi.fn(), + teamColor: 'slate', + setTeamColor: vi.fn(), + isLoaded: true, + clearDraft: vi.fn(), + }, +})); const storeState = { appConfig: { general: { multimodelEnabled: true } }, @@ -141,6 +186,20 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({ effort: draft.effort as 'low' | 'medium' | 'high' | undefined, fastMode: draft.fastMode as 'inherit' | 'on' | 'off' | undefined, })), + createMemberDraft: (member: any = {}) => ({ + id: member.id ?? 'draft-member', + name: member.name ?? '', + originalName: member.originalName ?? member.name ?? '', + roleSelection: member.roleSelection ?? '', + customRole: member.customRole ?? '', + workflow: member.workflow ?? '', + isolation: member.isolation, + providerId: member.providerId, + providerBackendId: member.providerBackendId, + model: member.model ?? '', + effort: member.effort, + fastMode: member.fastMode, + }), clearMemberModelOverrides: (member: unknown) => member, createMemberDraftsFromInputs: ( members: Array<{ @@ -228,6 +287,10 @@ vi.mock('@renderer/components/ui/button', () => ({ ), })); +vi.mock('@renderer/components/ui/auto-resize-textarea', () => ({ + AutoResizeTextarea: (props: Record) => React.createElement('textarea', props), +})); + vi.mock('@renderer/components/ui/checkbox', () => ({ Checkbox: ({ checked, @@ -307,6 +370,10 @@ vi.mock('@renderer/hooks/useChipDraftPersistence', () => ({ }), })); +vi.mock('@renderer/hooks/useCreateTeamDraft', () => ({ + useCreateTeamDraft: () => createTeamDraftMock.state, +})); + vi.mock('@renderer/hooks/useDraftPersistence', () => ({ useDraftPersistence: () => { const [value, setValue] = React.useState(''); @@ -447,6 +514,7 @@ vi.mock('@renderer/components/team/dialogs/CodexFastModeSelector', () => ({ })); import { api } from '@renderer/api'; +import { CreateTeamDialog } from '@renderer/components/team/dialogs/CreateTeamDialog'; import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog'; import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics'; import { isTeamModelAvailableForUi } from '@renderer/utils/teamModelAvailability'; @@ -461,6 +529,7 @@ describe('LaunchTeamDialog', () => { afterEach(() => { document.body.innerHTML = ''; localStorage.clear(); + vi.useRealTimers(); vi.clearAllMocks(); storeState.cliStatus = { providers: [] }; storeState.launchParamsByTeam = {}; @@ -1801,4 +1870,154 @@ describe('LaunchTeamDialog', () => { await flush(); }); }); + + it('keeps create-team preflight alive across same-signature rerenders', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'anthropic', + supported: true, + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: null, + detailMessage: null, + models: ['haiku'], + modelCatalog: { + source: 'live', + status: 'ready', + models: [{ id: 'haiku' }], + }, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + }, + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: null, + detailMessage: null, + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + models: ['gpt-5.5'], + modelCatalog: { + source: 'app-server', + status: 'ready', + models: [{ id: 'gpt-5.5' }], + }, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + }, + { + providerId: 'opencode', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: 'warming up', + detailMessage: 'first render', + models: ['opencode/big-pickle'], + modelCatalog: { + source: 'app-server', + status: 'ready', + models: [{ id: 'opencode/big-pickle' }], + }, + capabilities: { + teamLaunch: true, + oneShot: false, + }, + }, + ], + } as any; + + let resolvePrepare!: (value: { + status: 'ready'; + warnings: []; + details: []; + modelResultsById: {}; + }) => void; + const preparePromise = new Promise<{ + status: 'ready'; + warnings: []; + details: []; + modelResultsById: {}; + }>((resolve) => { + resolvePrepare = resolve; + }); + vi.mocked(runProviderPrepareDiagnostics).mockReturnValue(preparePromise as any); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const renderDialog = async (): Promise => { + root.render( + React.createElement(CreateTeamDialog, { + open: true, + canCreate: true, + provisioningErrorsByTeam: {}, + clearProvisioningError: vi.fn(), + existingTeamNames: [], + provisioningTeamNames: [], + activeTeams: [], + defaultProjectPath: '/tmp/project', + onClose: vi.fn(), + onCreate: vi.fn(async () => {}), + onOpenTeam: vi.fn(), + }) + ); + await flush(); + }; + + await act(async () => { + await renderDialog(); + await flush(); + }); + await act(async () => { + vi.runOnlyPendingTimers(); + await flush(); + }); + + expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalled(); + + await act(async () => { + await renderDialog(); + await flush(); + }); + + const callsAfterSameSignatureRerender = vi.mocked(runProviderPrepareDiagnostics).mock.calls.length; + + await act(async () => { + resolvePrepare({ + status: 'ready', + warnings: [], + details: [], + modelResultsById: {}, + }); + await flush(); + await flush(); + }); + + expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes( + callsAfterSameSignatureRerender + ); + expect(host.textContent).toContain('Selected providers are ready.'); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); }); From 876527a51d21aa64dcc1d0331bd3101c02faee06 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 00:45:58 +0300 Subject: [PATCH 03/10] fix(runtime): hide direct child process windows --- src/main/services/team/GitDiffFallback.ts | 8 ++-- .../services/team/TeamMcpConfigBuilder.ts | 16 +++----- .../team/TeamMemberWorktreeManager.ts | 2 +- .../services/team/TeamProvisioningService.ts | 1 + .../services/team/TeamWorktreeGitService.ts | 2 +- .../OpenCodeManagedHostProcessCleanup.ts | 1 + src/main/utils/childProcess.ts | 4 +- src/main/utils/processKill.ts | 4 +- src/main/utils/shellEnv.ts | 1 + .../team/TeamMcpConfigBuilder.test.ts | 37 ++++++++++--------- test/main/utils/shellEnv.test.ts | 4 +- 11 files changed, 41 insertions(+), 39 deletions(-) diff --git a/src/main/services/team/GitDiffFallback.ts b/src/main/services/team/GitDiffFallback.ts index 1843da0c..1597725b 100644 --- a/src/main/services/team/GitDiffFallback.ts +++ b/src/main/services/team/GitDiffFallback.ts @@ -54,6 +54,7 @@ export class GitDiffFallback { cwd: projectPath, maxBuffer: GIT_MAX_BUFFER, timeout: GIT_TIMEOUT, + windowsHide: true, }); return stdout; } catch { @@ -75,7 +76,7 @@ export class GitDiffFallback { const { stdout } = await execFileAsync( 'git', ['log', '--format=%H', '--before', timestamp, '-1', '--', relativePath], - { cwd: projectPath, timeout: GIT_TIMEOUT } + { cwd: projectPath, timeout: GIT_TIMEOUT, windowsHide: true } ); return stdout.trim() || null; } catch { @@ -98,7 +99,7 @@ export class GitDiffFallback { const { stdout } = await execFileAsync( 'git', ['diff', fromCommit, toCommit, '--', relativePath], - { cwd: projectPath, timeout: GIT_TIMEOUT } + { cwd: projectPath, timeout: GIT_TIMEOUT, windowsHide: true } ); return stdout || null; } catch { @@ -120,7 +121,7 @@ export class GitDiffFallback { const { stdout } = await execFileAsync( 'git', ['log', `--max-count=${maxCount}`, '--format=%H|%aI|%s', '--', relativePath], - { cwd: projectPath, timeout: GIT_TIMEOUT } + { cwd: projectPath, timeout: GIT_TIMEOUT, windowsHide: true } ); return stdout @@ -148,6 +149,7 @@ export class GitDiffFallback { await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath, timeout: GIT_TIMEOUT, + windowsHide: true, }); this.gitRepoCache.set(projectPath, true); return true; diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index d34e856a..2411b3ea 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -3,8 +3,8 @@ import { getMcpConfigsBasePath, getMcpServerBasePath, } from '@main/utils/pathDecoder'; +import { execCli } from '@main/utils/childProcess'; import { createLogger } from '@shared/utils/logger'; -import { execFile } from 'child_process'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; @@ -185,17 +185,11 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise((resolve, reject) => { - execFile( - 'node', - ['-e', 'process.stdout.write(process.execPath)'], - { - encoding: 'utf-8', - timeout: 5000, - }, - (err, stdout) => (err ? reject(err) : resolve(stdout.trim())) - ); + const { stdout } = await execCli('node', ['-e', 'process.stdout.write(process.execPath)'], { + encoding: 'utf-8', + timeout: 5000, }); + const resolved = stdout.trim(); if (resolved) { _resolvedNodePath = resolved; emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); diff --git a/src/main/services/team/TeamMemberWorktreeManager.ts b/src/main/services/team/TeamMemberWorktreeManager.ts index 94b613e0..c4e941c0 100644 --- a/src/main/services/team/TeamMemberWorktreeManager.ts +++ b/src/main/services/team/TeamMemberWorktreeManager.ts @@ -28,7 +28,7 @@ function execGit(args: string[], cwd: string): Promise { execFile( 'git', args, - { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, + { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => { if (error) { const message = String(stderr || error.message || 'git command failed').trim(); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index dac4d51a..51c5dd4e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6426,6 +6426,7 @@ export class TeamProvisioningService { encoding: 'utf8', maxBuffer: 16 * 1024, timeout: 1000, + windowsHide: true, }, (error, stdout) => { if (error) { diff --git a/src/main/services/team/TeamWorktreeGitService.ts b/src/main/services/team/TeamWorktreeGitService.ts index f03e2c85..24dd6207 100644 --- a/src/main/services/team/TeamWorktreeGitService.ts +++ b/src/main/services/team/TeamWorktreeGitService.ts @@ -11,7 +11,7 @@ function execGit(args: string[], cwd: string): Promise { execFile( 'git', args, - { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, + { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => { if (error) { const message = String(stderr || error.message || 'git command failed').trim(); diff --git a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts index e63a2122..cf06b3f0 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts @@ -278,6 +278,7 @@ function execFileText( encoding: 'utf8', timeout, maxBuffer, + windowsHide: true, }, (error: ExecFileException | null, stdout: string | Buffer) => { if (error) { diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index 7305f824..619d384d 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -376,8 +376,8 @@ export function killProcessTree( 'System32', 'taskkill.exe' ); - execFile(taskkillPath, ['/T', '/F', '/PID', String(child.pid)], () => { - // Best-effort — ignore errors (process may have already exited) + execFile(taskkillPath, ['/T', '/F', '/PID', String(child.pid)], { windowsHide: true }, () => { + // Best-effort - ignore errors (process may have already exited) }); return; } catch { diff --git a/src/main/utils/processKill.ts b/src/main/utils/processKill.ts index fdcf4fcd..c02401ac 100644 --- a/src/main/utils/processKill.ts +++ b/src/main/utils/processKill.ts @@ -21,8 +21,8 @@ export function killProcessByPid(pid: number): void { 'System32', 'taskkill.exe' ); - execFile(taskkillPath, ['/T', '/F', '/PID', String(pid)], () => { - // Best-effort — ignore errors (process may have already exited) + execFile(taskkillPath, ['/T', '/F', '/PID', String(pid)], { windowsHide: true }, () => { + // Best-effort - ignore errors (process may have already exited) }); } catch { // taskkill failed to spawn, fall through to process.kill() diff --git a/src/main/utils/shellEnv.ts b/src/main/utils/shellEnv.ts index 5a621e72..f3cd0646 100644 --- a/src/main/utils/shellEnv.ts +++ b/src/main/utils/shellEnv.ts @@ -87,6 +87,7 @@ async function readShellEnv(shellPath: string, args: string[]): Promise ({ isPackaged: false, version: '9.9.9-test', }, - execFileMock: vi.fn( - ( - _file: string, - _args: readonly string[], - _options: - | { encoding?: string; timeout?: number } - | ((error: Error | null, stdout: string, stderr: string) => void), - callback?: (error: Error | null, stdout: string, stderr: string) => void - ) => { - const cb = typeof _options === 'function' ? _options : callback; - cb?.(null, '/mock/node', ''); - } - ), + execCliMock: vi.fn(async () => ({ stdout: '/mock/node', stderr: '' })), })); let mockHomeDir = ''; @@ -29,11 +17,11 @@ type ModuleLoad = (request: string, parent: NodeModule | undefined, isMain: bool const moduleInternal = Module as unknown as { _load: ModuleLoad }; const originalModuleLoad = moduleInternal._load; -vi.mock('child_process', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('@main/utils/childProcess', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - execFile: hoisted.execFileMock, + execCli: hoisted.execCliMock, }; }); @@ -189,7 +177,7 @@ describe('TeamMcpConfigBuilder', () => { setAppDataBasePath(tempAppData); setPackagedMode(false); setResourcesPath(undefined); - hoisted.execFileMock.mockClear(); + hoisted.execCliMock.mockClear(); }); afterEach(() => { @@ -283,6 +271,21 @@ describe('TeamMcpConfigBuilder', () => { expectNodeEntry(server, builtEntry); }); + it('uses the shared CLI helper for the Node.js runtime resolver', async () => { + mockBuiltWorkspaceEntryAvailable(); + const builder = new TeamMcpConfigBuilder(); + + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + + expect(readGeneratedServer(configPath)?.command).toBe('/mock/node'); + expect(hoisted.execCliMock).toHaveBeenCalledWith( + 'node', + ['-e', 'process.stdout.write(process.execPath)'], + expect.objectContaining({ encoding: 'utf-8', timeout: 5000 }) + ); + }); + it('keeps generated team MCP config minimal and does not inline top-level user MCP', async () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-')); diff --git a/test/main/utils/shellEnv.test.ts b/test/main/utils/shellEnv.test.ts index 35aa053c..aa26b08f 100644 --- a/test/main/utils/shellEnv.test.ts +++ b/test/main/utils/shellEnv.test.ts @@ -117,13 +117,13 @@ describe('shellEnv', () => { 1, '/bin/zsh', ['-lic', 'env -0'], - expect.any(Object) + expect.objectContaining({ windowsHide: true }) ); expect(hoisted.spawn).toHaveBeenNthCalledWith( 2, '/bin/zsh', ['-ic', 'env -0'], - expect.any(Object) + expect.objectContaining({ windowsHide: true }) ); }); From 58ff926f4bb146ab15b7144d75eeca8c901dd6b2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 00:57:03 +0300 Subject: [PATCH 04/10] fix(team): harden create preflight cleanup --- .../services/team/TeamMcpConfigBuilder.ts | 2 +- .../team/dialogs/CreateTeamDialog.tsx | 22 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 2411b3ea..949bed25 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -1,9 +1,9 @@ +import { execCli } from '@main/utils/childProcess'; import { getClaudeBasePath, getMcpConfigsBasePath, getMcpServerBasePath, } from '@main/utils/pathDecoder'; -import { execCli } from '@main/utils/childProcess'; import { createLogger } from '@shared/utils/logger'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 4f353e34..3a22a60d 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -361,6 +361,10 @@ function cancelScheduledIdle(handle: ScheduledIdleHandle | null): void { window.clearTimeout(handle.id); } +function isCurrentPrepareGeneration(ref: { current: number }, generation: number): boolean { + return ref.current === generation; +} + export const CreateTeamDialog = ({ open, canCreate, @@ -445,6 +449,7 @@ export const CreateTeamDialog = ({ const [prepareChecks, setPrepareChecks] = useState([]); const prepareRequestSeqRef = useRef(0); const prepareIdleHandleRef = useRef(null); + const prepareUnmountGenerationRef = useRef(0); const appliedDefaultProjectPathRef = useRef(null); const lastAutoDescriptionRef = useRef(null); const [fieldErrors, setFieldErrors] = useState<{ @@ -692,11 +697,19 @@ export const CreateTeamDialog = ({ }, [open]); useEffect(() => { + const generation = ++prepareUnmountGenerationRef.current; return () => { - cancelScheduledIdle(prepareIdleHandleRef.current); - prepareIdleHandleRef.current = null; - prepareRequestSeqRef.current += 1; - lastPrepareRequestSignatureRef.current = null; + // React StrictMode replays effect cleanup/setup in development; defer + // invalidation so the replay does not cancel the live prepare request. + queueMicrotask(() => { + if (!isCurrentPrepareGeneration(prepareUnmountGenerationRef, generation)) { + return; + } + cancelScheduledIdle(prepareIdleHandleRef.current); + prepareIdleHandleRef.current = null; + prepareRequestSeqRef.current += 1; + lastPrepareRequestSignatureRef.current = null; + }); }; }, []); @@ -1046,6 +1059,7 @@ export const CreateTeamDialog = ({ effectiveMemberDrafts, effectiveAnthropicRuntimeLimitContext, prepareRequestSignature, + prepareRuntimeStatusSignature, runtimeProviderStatusById, selectedModel, selectedProviderId, From 3ceef1fb82cb5b353d925c2585a1b5c16a4e04b2 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 01:19:13 +0300 Subject: [PATCH 05/10] fix(opencode): hide app mcp child processes on windows --- mcp-server/src/index.ts | 79 ++++++- mcp-server/test/startOptions.test.ts | 54 +++++ src/main/index.ts | 40 +++- .../services/team/AgentTeamsMcpHttpServer.ts | 212 ++++++++++++++++++ .../services/team/TeamProvisioningService.ts | 72 ++++++ .../OpenCodeManagedHostProcessCleanup.ts | 82 +++++-- .../team/AgentTeamsMcpHttpServer.test.ts | 185 +++++++++++++++ .../OpenCodeManagedHostProcessCleanup.test.ts | 91 +++++++- .../TeamProvisioningServicePrepare.test.ts | 76 +++++++ 9 files changed, 854 insertions(+), 37 deletions(-) create mode 100644 mcp-server/test/startOptions.test.ts create mode 100644 src/main/services/team/AgentTeamsMcpHttpServer.ts create mode 100644 test/main/services/team/AgentTeamsMcpHttpServer.test.ts diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 0ca07344..fff620d3 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -5,6 +5,24 @@ import { FastMCP } from 'fastmcp'; import { registerTools } from './tools'; +const HTTP_TRANSPORT = 'httpStream'; +const STDIO_TRANSPORT = 'stdio'; +const DEFAULT_HTTP_HOST = '127.0.0.1'; +const DEFAULT_HTTP_ENDPOINT = '/mcp'; + +export type AgentTeamsMcpStartOptions = + | { + transportType: typeof STDIO_TRANSPORT; + } + | { + transportType: typeof HTTP_TRANSPORT; + httpStream: { + host: string; + port: number; + endpoint: `/${string}`; + }; + }; + export function createServer() { const server = new FastMCP({ name: 'agent-teams-mcp', @@ -16,9 +34,64 @@ export function createServer() { return server; } +function getArgValue(argv: string[], name: string): string | null { + const directPrefix = `${name}=`; + for (let index = 2; index < argv.length; index += 1) { + const value = argv[index]; + if (value === name) { + return argv[index + 1] ?? null; + } + if (value.startsWith(directPrefix)) { + return value.slice(directPrefix.length); + } + } + return null; +} + +function normalizeEndpoint(value: string | null | undefined): `/${string}` { + const trimmed = value?.trim(); + if (!trimmed) { + return DEFAULT_HTTP_ENDPOINT; + } + return (trimmed.startsWith('/') ? trimmed : `/${trimmed}`) as `/${string}`; +} + +function parsePort(value: string | null | undefined): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { + throw new Error(`Invalid agent-teams MCP HTTP port: ${value ?? ''}`); + } + return parsed; +} + +export function resolveStartOptions( + argv: string[] = process.argv, + env: NodeJS.ProcessEnv = process.env +): AgentTeamsMcpStartOptions { + const transport = + getArgValue(argv, '--transport') ?? + getArgValue(argv, '--transportType') ?? + env.AGENT_TEAMS_MCP_TRANSPORT ?? + STDIO_TRANSPORT; + + if (transport !== HTTP_TRANSPORT) { + return { transportType: STDIO_TRANSPORT }; + } + + return { + transportType: HTTP_TRANSPORT, + httpStream: { + host: + getArgValue(argv, '--host')?.trim() || + env.AGENT_TEAMS_MCP_HTTP_HOST?.trim() || + DEFAULT_HTTP_HOST, + port: parsePort(getArgValue(argv, '--port') ?? env.AGENT_TEAMS_MCP_HTTP_PORT), + endpoint: normalizeEndpoint(getArgValue(argv, '--endpoint') ?? env.AGENT_TEAMS_MCP_HTTP_ENDPOINT), + }, + }; +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const server = createServer(); - void server.start({ - transportType: 'stdio', - }); + void server.start(resolveStartOptions()); } diff --git a/mcp-server/test/startOptions.test.ts b/mcp-server/test/startOptions.test.ts new file mode 100644 index 00000000..867a1918 --- /dev/null +++ b/mcp-server/test/startOptions.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveStartOptions } from '../src/index'; + +describe('agent-teams MCP start options', () => { + it('defaults to stdio transport', () => { + expect(resolveStartOptions(['node', 'index.js'], {})).toEqual({ + transportType: 'stdio', + }); + }); + + it('resolves HTTP stream transport from CLI args', () => { + expect( + resolveStartOptions( + [ + 'node', + 'index.js', + '--transport', + 'httpStream', + '--host', + '127.0.0.1', + '--port', + '43123', + '--endpoint', + 'mcp', + ], + {} + ) + ).toEqual({ + transportType: 'httpStream', + httpStream: { + host: '127.0.0.1', + port: 43123, + endpoint: '/mcp', + }, + }); + }); + + it('resolves HTTP stream transport from environment', () => { + expect( + resolveStartOptions(['node', 'index.js'], { + AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', + AGENT_TEAMS_MCP_HTTP_PORT: '43124', + }) + ).toEqual({ + transportType: 'httpStream', + httpStream: { + host: '127.0.0.1', + port: 43124, + endpoint: '/mcp', + }, + }); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index e569293a..2fe0b20c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -133,6 +133,7 @@ import { import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor'; import { HttpServer } from './services/infrastructure/HttpServer'; import { clearAutoResumeService } from './services/team/AutoResumeService'; +import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer'; import { LaunchIoGovernor } from './services/team/LaunchIoGovernor'; import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient'; import { @@ -381,23 +382,37 @@ async function createOpenCodeRuntimeAdapterRegistry( ); } try { - reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...'); - const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({ - onProgress: ({ phase, message }) => reportProgress(`mcp-${phase}`, message), - }); - const mcpEntry = mcpLaunchSpec.args[0]; - if (mcpEntry) { - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args); - } + reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...'); + const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted(); + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; + reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...'); } catch (error) { logger.warn( - `[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${ + `[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${ error instanceof Error ? error.message : String(error) }` ); } + if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) { + try { + reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...'); + const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({ + onProgress: ({ phase, message }) => reportProgress(`mcp-${phase}`, message), + }); + const mcpEntry = mcpLaunchSpec.args[0]; + if (mcpEntry) { + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args); + } + } catch (error) { + logger.warn( + `[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } reportProgress('runtime-bridge', 'Preparing OpenCode bridge...'); const bridgeClient = new OpenCodeBridgeCommandClient({ @@ -2081,6 +2096,9 @@ async function shutdownServices(): Promise { () => cleanupOpenCodeHostsForLifecycle('shutdown'), 10_000 ); + await runShutdownStep('Agent Teams MCP HTTP server cleanup', () => + agentTeamsMcpHttpServer.stop() + ); await runShutdownStep('tracked CLI subprocess cleanup', () => killTrackedCliProcesses('SIGKILL') ); diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts new file mode 100644 index 00000000..e6149912 --- /dev/null +++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts @@ -0,0 +1,212 @@ +import { spawnCli, killProcessTree } from '@main/utils/childProcess'; +import { getClaudeBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import { type ChildProcess } from 'child_process'; +import http from 'http'; +import net from 'net'; + +import { type McpLaunchSpec, resolveAgentTeamsMcpLaunchSpec } from './TeamMcpConfigBuilder'; + +const logger = createLogger('Service:AgentTeamsMcpHttpServer'); +const MCP_HTTP_HOST = '127.0.0.1'; +const MCP_HTTP_ENDPOINT = '/mcp'; +const MCP_HTTP_READY_TIMEOUT_MS = 5_000; +const MCP_HTTP_READY_POLL_MS = 100; + +export interface AgentTeamsMcpHttpServerHandle { + url: string; + port: number; + pid: number | null; +} + +export interface AgentTeamsMcpHttpServerDeps { + resolveLaunchSpec?: () => Promise; + allocatePort?: () => Promise; + spawnProcess?: (command: string, args: string[], env: NodeJS.ProcessEnv) => ChildProcess; + waitForPort?: (host: string, port: number, timeoutMs: number) => Promise; +} + +async function allocateLoopbackPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, MCP_HTTP_HOST, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate Agent Teams MCP HTTP port'))); + return; + } + + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function isHealthReady(host: string, port: number): Promise { + return new Promise((resolve) => { + const request = http.get( + { + host, + port, + path: '/health', + timeout: MCP_HTTP_READY_POLL_MS, + }, + (response) => { + response.resume(); + resolve((response.statusCode ?? 500) >= 200 && (response.statusCode ?? 500) < 300); + } + ); + request.once('timeout', () => { + request.destroy(); + resolve(false); + }); + request.once('error', () => { + resolve(false); + }); + }); +} + +async function waitForLoopbackPort(host: string, port: number, timeoutMs: number): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await isHealthReady(host, port)) { + return; + } + await sleep(MCP_HTTP_READY_POLL_MS); + } + throw new Error( + `Agent Teams MCP HTTP server did not become healthy at ${host}:${port} in ${timeoutMs}ms` + ); +} + +function defaultSpawnProcess( + command: string, + args: string[], + env: NodeJS.ProcessEnv +): ChildProcess { + return spawnCli(command, args, { + env, + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }); +} + +function buildHttpServerArgs(launchSpec: McpLaunchSpec, port: number): string[] { + return [ + ...launchSpec.args, + '--transport', + 'httpStream', + '--host', + MCP_HTTP_HOST, + '--port', + String(port), + '--endpoint', + MCP_HTTP_ENDPOINT, + ]; +} + +export class AgentTeamsMcpHttpServer { + private startPromise: Promise | null = null; + private child: ChildProcess | null = null; + private handle: AgentTeamsMcpHttpServerHandle | null = null; + + constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {} + + async ensureStarted(): Promise { + if (this.handle) { + return this.handle; + } + if (this.startPromise) { + return this.startPromise; + } + + this.startPromise = this.startOnce().finally(() => { + this.startPromise = null; + }); + return this.startPromise; + } + + async stop(): Promise { + const child = this.child; + this.child = null; + this.handle = null; + if (child) { + killProcessTree(child, 'SIGKILL'); + } + } + + private async startOnce(): Promise { + const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec; + const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort; + const spawnProcess = this.deps.spawnProcess ?? defaultSpawnProcess; + const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort; + const launchSpec = await resolveLaunchSpec(); + const port = await allocatePort(); + const args = buildHttpServerArgs(launchSpec, port); + const child = spawnProcess(launchSpec.command, args, { + ...process.env, + AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(), + AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', + AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST, + AGENT_TEAMS_MCP_HTTP_PORT: String(port), + AGENT_TEAMS_MCP_HTTP_ENDPOINT: MCP_HTTP_ENDPOINT, + }); + + const clearIfCurrent = (): void => { + if (this.child === child) { + this.child = null; + this.handle = null; + } + }; + child.once('exit', (code, signal) => { + clearIfCurrent(); + logger.warn( + `Agent Teams MCP HTTP server exited${typeof code === 'number' ? ` with code ${code}` : ''}${ + signal ? ` (${signal})` : '' + }` + ); + }); + child.once('error', (error) => { + clearIfCurrent(); + logger.warn( + `Agent Teams MCP HTTP server process error: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); + child.stderr?.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf8').trim(); + if (text) { + logger.debug(`Agent Teams MCP HTTP stderr: ${text.slice(0, 1000)}`); + } + }); + + try { + await waitForPort(MCP_HTTP_HOST, port, MCP_HTTP_READY_TIMEOUT_MS); + } catch (error) { + killProcessTree(child, 'SIGKILL'); + throw error; + } + + this.child = child; + this.handle = { + url: `http://${MCP_HTTP_HOST}:${port}${MCP_HTTP_ENDPOINT}`, + port, + pid: child.pid ?? null, + }; + logger.info(`Agent Teams MCP HTTP server running at ${this.handle.url}`); + return this.handle; + } +} + +export const agentTeamsMcpHttpServer = new AgentTeamsMcpHttpServer(); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 51c5dd4e..833cb9a5 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -5996,6 +5996,10 @@ export class TeamProvisioningService { private toolApprovalSettingsByTeam = new Map(); private pendingTimeouts = new Map(); private inFlightResponses = new Set(); + private readonly prepareForProvisioningInFlight = new Map< + string, + Promise + >(); private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null; private controlApiBaseUrlResolver: (() => Promise) | null = null; private workspaceTrustCoordinator: WorkspaceTrustCoordinator | null = null; @@ -18064,6 +18068,74 @@ export class TeamProvisioningService { limitContext?: boolean; modelVerificationMode?: TeamProvisioningModelVerificationMode; } + ): Promise { + const inFlightKey = this.createPrepareForProvisioningInFlightKey(cwd, opts); + const inFlight = this.prepareForProvisioningInFlight.get(inFlightKey); + if (inFlight) { + return this.clonePrepareForProvisioningResult(await inFlight); + } + + const request = this.prepareForProvisioningOnce(cwd, opts).finally(() => { + if (this.prepareForProvisioningInFlight.get(inFlightKey) === request) { + this.prepareForProvisioningInFlight.delete(inFlightKey); + } + }); + this.prepareForProvisioningInFlight.set(inFlightKey, request); + return this.clonePrepareForProvisioningResult(await request); + } + + private createPrepareForProvisioningInFlightKey( + cwd?: string, + opts?: { + forceFresh?: boolean; + providerId?: TeamProviderId; + providerIds?: TeamProviderId[]; + modelIds?: string[]; + limitContext?: boolean; + modelVerificationMode?: TeamProvisioningModelVerificationMode; + } + ): string { + const providerIds = Array.from( + new Set( + [opts?.providerId, ...(opts?.providerIds ?? [])] + .map((providerId) => resolveTeamProviderId(providerId)) + .filter((providerId): providerId is TeamProviderId => Boolean(providerId)) + ) + ); + const modelIds = Array.from( + new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean)) + ); + return JSON.stringify({ + cwd: cwd?.trim() || process.cwd(), + forceFresh: opts?.forceFresh === true, + providerIds, + modelIds, + limitContext: opts?.limitContext === true, + modelVerificationMode: opts?.modelVerificationMode ?? null, + }); + } + + private clonePrepareForProvisioningResult( + result: TeamProvisioningPrepareResult + ): TeamProvisioningPrepareResult { + return { + ...result, + details: result.details ? [...result.details] : undefined, + warnings: result.warnings ? [...result.warnings] : undefined, + issues: result.issues?.map((issue) => ({ ...issue })), + }; + } + + private async prepareForProvisioningOnce( + cwd?: string, + opts?: { + forceFresh?: boolean; + providerId?: TeamProviderId; + providerIds?: TeamProviderId[]; + modelIds?: string[]; + limitContext?: boolean; + modelVerificationMode?: TeamProvisioningModelVerificationMode; + } ): Promise { const targetCwdForValidation = cwd?.trim() || process.cwd(); await this.validatePrepareCwd(targetCwdForValidation); diff --git a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts index cf06b3f0..3abb90ad 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts @@ -3,6 +3,7 @@ import { type RuntimeProcessTableRow, } from '@features/tmux-installer/main'; import { killProcessByPid } from '@main/utils/processKill'; +import { listWindowsProcessTable } from '@main/utils/windowsProcessTable'; import { execFile, type ExecFileException } from 'child_process'; export type OpenCodeManagedHostCleanupMode = 'orphaned' | 'force'; @@ -37,11 +38,15 @@ export interface OpenCodeManagedHostProcessCleanupOptions { sleepMs?: (ms: number) => Promise; } -const OPENCODE_SERVE_COMMAND_RE = /(^|[/\\\s])opencode(?:\.exe)?(?=\s|$).*?(?:^|\s)serve(?=\s|$)/i; +const OPENCODE_SERVE_COMMAND_RE = + /(^|[/\\\s"])opencode(?:\.exe)?(?:"?)(?=\s|$).*?(?:^|\s)serve(?=\s|$)/i; +const WINDOWS_APP_MANAGED_OPENCODE_SERVE_RE = + /[\\/]runtimes[\\/]opencode[\\/]versions[\\/][^"'\s]+[\\/]opencode-windows-[^"'\s]+[\\/]opencode\.exe(?:"|\s|$)/i; const MANAGED_ENV_MARKERS = ['CLAUDE_MULTIMODEL_DATA_HOME=', 'OPENCODE_CONFIG_CONTENT='] as const; const MANAGED_ENV_IDENTITY_MARKERS = [ 'AGENT_TEAMS_MCP_CLAUDE_DIR=', 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=', + 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=', ] as const; export async function cleanupManagedOpenCodeServeProcesses( @@ -55,18 +60,18 @@ export async function cleanupManagedOpenCodeServeProcesses( diagnostics: [], }; - if (platform === 'win32') { - result.diagnostics.push( - 'Managed OpenCode serve process fallback cleanup is skipped on Windows.' - ); - return result; - } - - const rows = await (options.listProcessRows ?? listRuntimeProcessesForCurrentTmuxPlatform)(); + const rows = await ( + options.listProcessRows ?? + (platform === 'win32' ? listWindowsProcessTable : listRuntimeProcessesForCurrentTmuxPlatform) + )(); const excludePids = options.excludePids ?? new Set(); const requiredDetailsMarkers = options.requiredDetailsMarkers ?? []; - const readDetails = options.readProcessDetails ?? readNativeProcessCommandWithEnv; - const readStartTimeMs = options.readProcessStartTimeMs ?? readNativeProcessStartTimeMs; + const readDetails = + options.readProcessDetails ?? + (platform === 'win32' ? async () => null : readNativeProcessCommandWithEnv); + const readStartTimeMs = + options.readProcessStartTimeMs ?? + (platform === 'win32' ? readWindowsProcessStartTimeMs : readNativeProcessStartTimeMs); const disposeServeHost = options.disposeServeHost ?? disposeOpenCodeServeHost; const killProcess = options.killProcess ?? killProcessByPid; const forceKillProcess = @@ -91,16 +96,23 @@ export async function cleanupManagedOpenCodeServeProcesses( } const details = await readDetails(row.pid); - if ( - !details || - !isManagedOpenCodeServeProcessDetails(details) || - !processDetailsIncludeMarkers(details, requiredDetailsMarkers) - ) { + const isManaged = + platform === 'win32' + ? isAppManagedWindowsOpenCodeServeCommand(row.command) || + Boolean(details && isManagedOpenCodeServeProcessDetails(details)) + : Boolean(details && isManagedOpenCodeServeProcessDetails(details)); + const hasRequiredDetailsMarkers = + requiredDetailsMarkers.length === 0 || + Boolean(details && processDetailsIncludeMarkers(details, requiredDetailsMarkers)); + if (!isManaged || !hasRequiredDetailsMarkers) { result.candidates.push({ pid: row.pid, ppid: row.ppid, action: 'kept_unmanaged', - reason: 'process does not carry Agent Teams managed OpenCode environment markers', + reason: + platform === 'win32' + ? 'process is not an app-managed Windows OpenCode serve command' + : 'process does not carry Agent Teams managed OpenCode environment markers', }); continue; } @@ -122,7 +134,9 @@ export async function cleanupManagedOpenCodeServeProcesses( }); continue; } - if (row.ppid !== 1) { + const parentMayStillOwnProcess = + platform === 'win32' ? row.ppid > 0 && isProcessAlive(row.ppid) : row.ppid !== 1; + if (parentMayStillOwnProcess) { result.candidates.push({ pid: row.pid, ppid: row.ppid, @@ -177,6 +191,14 @@ export function isOpenCodeServeCommand(command: string): boolean { return OPENCODE_SERVE_COMMAND_RE.test(command.trim()); } +export function isAppManagedWindowsOpenCodeServeCommand(command: string): boolean { + const normalizedCommand = command.trim().replace(/\//g, '\\'); + return ( + isOpenCodeServeCommand(normalizedCommand) && + WINDOWS_APP_MANAGED_OPENCODE_SERVE_RE.test(normalizedCommand) + ); +} + export function isManagedOpenCodeServeProcessDetails(details: string): boolean { return ( processDetailsIncludeMarkers(details, MANAGED_ENV_MARKERS) && @@ -251,6 +273,30 @@ async function readNativeProcessStartTimeMs(pid: number): Promise return Number.isFinite(parsed) ? parsed : null; } +async function readWindowsProcessStartTimeMs(pid: number): Promise { + const normalizedPid = Math.trunc(pid); + if (!Number.isFinite(normalizedPid) || normalizedPid <= 0) { + return null; + } + + const script = [ + '$ErrorActionPreference = "Stop"', + `$process = Get-Process -Id ${normalizedPid} -ErrorAction Stop`, + '$process.StartTime.ToUniversalTime().ToString("o")', + ].join('; '); + const output = await execFileText( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], + 2_000, + 64 * 1024 + ); + if (!output) { + return null; + } + const parsed = Date.parse(output.trim()); + return Number.isFinite(parsed) ? parsed : null; +} + function isNativeProcessAlive(pid: number): boolean { try { process.kill(pid, 0); diff --git a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts new file mode 100644 index 00000000..0801ef07 --- /dev/null +++ b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts @@ -0,0 +1,185 @@ +import { EventEmitter } from 'events'; +import http from 'http'; +import net from 'net'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + killProcessTreeMock: vi.fn(), + spawnCliMock: vi.fn(), +})); + +vi.mock('@main/utils/childProcess', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + killProcessTree: (...args: unknown[]) => hoisted.killProcessTreeMock(...args), + spawnCli: (...args: unknown[]) => hoisted.spawnCliMock(...args), + }; +}); + +import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer'; + +class FakeChildProcess extends EventEmitter { + pid = 43123; + stderr = new EventEmitter(); +} + +async function allocateLoopbackPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate port'))); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +describe('AgentTeamsMcpHttpServer', () => { + beforeEach(() => { + hoisted.killProcessTreeMock.mockReset(); + hoisted.spawnCliMock.mockReset(); + }); + + it('starts the MCP server over HTTP with hidden app-owned process env', async () => { + const child = new FakeChildProcess(); + const spawnProcess = vi.fn(() => child as any); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => 41001, + spawnProcess, + waitForPort: vi.fn(async () => undefined), + }); + + const handle = await server.ensureStarted(); + + expect(handle).toEqual({ + url: 'http://127.0.0.1:41001/mcp', + port: 41001, + pid: 43123, + }); + expect(spawnProcess).toHaveBeenCalledWith( + 'node', + [ + 'mcp-server/dist/index.js', + '--transport', + 'httpStream', + '--host', + '127.0.0.1', + '--port', + '41001', + '--endpoint', + '/mcp', + ], + expect.objectContaining({ + AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', + AGENT_TEAMS_MCP_HTTP_PORT: '41001', + AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp', + }) + ); + }); + + it('uses a hidden default spawn without holding stdout open', async () => { + const child = new FakeChildProcess(); + hoisted.spawnCliMock.mockReturnValue(child as any); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => 41005, + waitForPort: vi.fn(async () => undefined), + }); + + const handle = await server.ensureStarted(); + + expect(handle.pid).toBe(43123); + expect(hoisted.spawnCliMock).toHaveBeenCalledWith( + 'node', + [ + 'mcp-server/dist/index.js', + '--transport', + 'httpStream', + '--host', + '127.0.0.1', + '--port', + '41005', + '--endpoint', + '/mcp', + ], + expect.objectContaining({ + env: expect.objectContaining({ + AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', + AGENT_TEAMS_MCP_HTTP_PORT: '41005', + AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp', + }), + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }) + ); + }); + + it('coalesces concurrent starts', async () => { + const child = new FakeChildProcess(); + const spawnProcess = vi.fn(() => child as any); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => 41002, + spawnProcess, + waitForPort: async () => undefined, + }); + + const [first, second] = await Promise.all([server.ensureStarted(), server.ensureStarted()]); + + expect(first).toBe(second); + expect(spawnProcess).toHaveBeenCalledTimes(1); + }); + + it('waits for the HTTP health endpoint before marking the server ready', async () => { + const child = new FakeChildProcess(); + const port = await allocateLoopbackPort(); + let healthRequests = 0; + const healthServer = http.createServer((request, response) => { + if (request.url === '/health') { + healthRequests += 1; + response.writeHead(200, { 'content-type': 'text/plain' }); + response.end('ok'); + return; + } + response.writeHead(404); + response.end(); + }); + const spawnProcess = vi.fn((_command: string, args: string[]) => { + expect(args).toContain(String(port)); + healthServer.listen(port, '127.0.0.1'); + return child as any; + }); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => port, + spawnProcess, + }); + + try { + const handle = await server.ensureStarted(); + + expect(handle.url).toBe(`http://127.0.0.1:${port}/mcp`); + expect(healthRequests).toBeGreaterThan(0); + } finally { + await new Promise((resolve) => healthServer.close(() => resolve())); + } + }); +}); diff --git a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts index dd8d17a6..9561c40c 100644 --- a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts +++ b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { cleanupManagedOpenCodeServeProcesses, getOpenCodeServeLoopbackBaseUrl, + isAppManagedWindowsOpenCodeServeCommand, isManagedOpenCodeServeProcessDetails, isOpenCodeServeCommand, } from '@main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup'; @@ -14,6 +15,12 @@ const MANAGED_DETAILS = [ 'AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude', 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=/tmp/mcp-entry.js', ].join(' '); +const MANAGED_DETAILS_WITH_REMOTE_MCP = [ + '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171', + 'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime', + 'OPENCODE_CONFIG_CONTENT={}', + 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=http://127.0.0.1:58461/mcp', +].join(' '); const MANAGED_DETAILS_WITH_WORKSPACE_MCP = [ '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171', 'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime', @@ -34,8 +41,27 @@ describe('OpenCodeManagedHostProcessCleanup', () => { expect(isOpenCodeServeCommand('node mcp-server/src/index.ts')).toBe(false); }); + it('identifies app-managed Windows OpenCode serve commands', () => { + expect( + isAppManagedWindowsOpenCodeServeCommand( + '"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913' + ) + ).toBe(true); + expect( + isAppManagedWindowsOpenCodeServeCommand( + 'C:\\tools\\opencode.exe serve --hostname 127.0.0.1 --port 49913' + ) + ).toBe(false); + expect( + isAppManagedWindowsOpenCodeServeCommand( + 'C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe auth login' + ) + ).toBe(false); + }); + it('requires Agent Teams managed environment markers', () => { expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS)).toBe(true); + expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_REMOTE_MCP)).toBe(true); expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_WORKSPACE_MCP)).toBe(true); expect( isManagedOpenCodeServeProcessDetails( @@ -347,7 +373,62 @@ describe('OpenCodeManagedHostProcessCleanup', () => { ]); }); - it('skips fallback cleanup on Windows because environment markers are unavailable', async () => { + it('kills old orphaned app-managed Windows OpenCode serve processes', async () => { + const killProcess = vi.fn(); + const disposeServeHost = vi.fn(() => resolved(undefined)); + + const result = await cleanupManagedOpenCodeServeProcesses({ + mode: 'orphaned', + platform: 'win32', + startedBeforeMs: Date.parse('2026-05-16T00:47:55.000Z'), + listProcessRows: () => + resolved([ + { + pid: 71628, + ppid: 86256, + command: + '"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913', + }, + ]), + readProcessStartTimeMs: () => resolved(Date.parse('2026-05-16T00:35:31.000Z')), + disposeServeHost, + isProcessAlive: () => false, + killProcess, + }); + + expect(disposeServeHost).toHaveBeenCalledWith('http://127.0.0.1:49913'); + expect(killProcess).toHaveBeenCalledWith(71628); + expect(result.killed).toBe(1); + expect(result.scanned).toBe(1); + expect(result.diagnostics).toEqual([]); + }); + + it('keeps app-managed Windows OpenCode serve processes while their parent is still alive', async () => { + const killProcess = vi.fn(); + + const result = await cleanupManagedOpenCodeServeProcesses({ + mode: 'orphaned', + platform: 'win32', + startedBeforeMs: Date.parse('2026-05-16T00:47:55.000Z'), + listProcessRows: () => + resolved([ + { + pid: 71628, + ppid: 86256, + command: + '"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913', + }, + ]), + readProcessStartTimeMs: () => resolved(Date.parse('2026-05-16T00:35:31.000Z')), + isProcessAlive: (pid) => pid === 86256, + killProcess, + }); + + expect(killProcess).not.toHaveBeenCalled(); + expect(result.candidates[0]).toMatchObject({ pid: 71628, action: 'kept_recent' }); + }); + + it('does not kill unmanaged Windows OpenCode serve commands', async () => { const killProcess = vi.fn(); const result = await cleanupManagedOpenCodeServeProcesses({ @@ -358,15 +439,15 @@ describe('OpenCodeManagedHostProcessCleanup', () => { { pid: 500, ppid: 1, - command: 'opencode.exe serve --hostname 127.0.0.1', + command: 'C:\\tools\\opencode.exe serve --hostname 127.0.0.1', }, ]), - readProcessDetails: () => resolved(MANAGED_DETAILS), killProcess, }); expect(killProcess).not.toHaveBeenCalled(); - expect(result.scanned).toBe(0); - expect(result.diagnostics[0]).toContain('skipped on Windows'); + expect(result.scanned).toBe(1); + expect(result.diagnostics).toEqual([]); + expect(result.candidates[0]).toMatchObject({ pid: 500, action: 'kept_unmanaged' }); }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 52f10712..83647c8a 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -661,6 +661,82 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ); }); + it('coalesces duplicate OpenCode compatibility preflight requests while prepare is in flight', async () => { + const prepareGate: { release?: () => void } = {}; + const prepare = vi.fn( + async () => + new Promise<{ + ok: true; + providerId: 'opencode'; + modelId: null; + diagnostics: string[]; + warnings: string[]; + }>((resolve) => { + prepareGate.release = () => + resolve({ + ok: true, + providerId: 'opencode', + modelId: null, + diagnostics: [], + warnings: [], + }); + }) + ); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare, + getLastOpenCodeTeamLaunchReadiness: vi.fn(() => ({ + state: 'ready', + launchAllowed: true, + modelId: 'opencode/big-pickle', + availableModels: ['opencode/big-pickle'], + opencodeVersion: '1.0.0', + installMethod: 'unknown', + binaryPath: 'opencode', + hostHealthy: true, + appMcpConnected: true, + requiredToolsPresent: true, + permissionBridgeReady: true, + issues: [], + warnings: [], + diagnostics: [], + })), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + } as any, + ]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(registry); + const opts = { + providerId: 'opencode' as const, + forceFresh: true, + modelIds: ['opencode/big-pickle'], + modelVerificationMode: 'compatibility' as const, + }; + + const first = svc.prepareForProvisioning(tempRoot, opts); + const second = svc.prepareForProvisioning(tempRoot, opts); + + for (let attempt = 0; attempt < 20 && prepare.mock.calls.length === 0; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + expect(prepare).toHaveBeenCalledTimes(1); + expect(prepareGate.release).toBeTypeOf('function'); + prepareGate.release?.(); + + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(prepare).toHaveBeenCalledTimes(1); + expect(firstResult).not.toBe(secondResult); + expect(firstResult.ready).toBe(true); + expect(secondResult.ready).toBe(true); + expect(firstResult.details).toContain( + 'Selected model opencode/big-pickle is compatible. Deep verification pending.' + ); + }); + it('checks every selected OpenCode model instead of only the first one', async () => { const prepare = vi.fn(async (input: { model?: string }) => { if (input.model === 'opencode/nemotron-3-super-free') { From 2cae0a080a568de048d61b2df659c014b23af0ee Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 01:56:35 +0300 Subject: [PATCH 06/10] fix(team): satisfy MCP HTTP lint gates --- mcp-server/src/index.ts | 4 ++-- src/main/services/team/AgentTeamsMcpHttpServer.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index fff620d3..59abcf0b 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -82,8 +82,8 @@ export function resolveStartOptions( transportType: HTTP_TRANSPORT, httpStream: { host: - getArgValue(argv, '--host')?.trim() || - env.AGENT_TEAMS_MCP_HTTP_HOST?.trim() || + getArgValue(argv, '--host')?.trim() ?? + env.AGENT_TEAMS_MCP_HTTP_HOST?.trim() ?? DEFAULT_HTTP_HOST, port: parsePort(getArgValue(argv, '--port') ?? env.AGENT_TEAMS_MCP_HTTP_PORT), endpoint: normalizeEndpoint(getArgValue(argv, '--endpoint') ?? env.AGENT_TEAMS_MCP_HTTP_ENDPOINT), diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts index e6149912..443885a6 100644 --- a/src/main/services/team/AgentTeamsMcpHttpServer.ts +++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts @@ -1,4 +1,4 @@ -import { spawnCli, killProcessTree } from '@main/utils/childProcess'; +import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { getClaudeBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { type ChildProcess } from 'child_process'; From 48826af00b6fa9631391715dcd30c29669bcb3a1 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 02:14:34 +0300 Subject: [PATCH 07/10] fix(team): harden MCP HTTP startup cleanup --- .../services/team/AgentTeamsMcpHttpServer.ts | 56 +++++++++++++------ .../OpenCodeManagedHostProcessCleanup.ts | 8 +-- .../team/AgentTeamsMcpHttpServer.test.ts | 52 +++++++++++++++++ .../OpenCodeManagedHostProcessCleanup.test.ts | 27 +++++++++ 4 files changed, 121 insertions(+), 22 deletions(-) diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts index 443885a6..60c4ebc3 100644 --- a/src/main/services/team/AgentTeamsMcpHttpServer.ts +++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts @@ -168,37 +168,57 @@ export class AgentTeamsMcpHttpServer { this.handle = null; } }; - child.once('exit', (code, signal) => { - clearIfCurrent(); - logger.warn( - `Agent Teams MCP HTTP server exited${typeof code === 'number' ? ` with code ${code}` : ''}${ - signal ? ` (${signal})` : '' - }` - ); - }); - child.once('error', (error) => { - clearIfCurrent(); - logger.warn( - `Agent Teams MCP HTTP server process error: ${ - error instanceof Error ? error.message : String(error) - }` - ); - }); child.stderr?.on('data', (chunk: Buffer) => { const text = chunk.toString('utf8').trim(); if (text) { logger.debug(`Agent Teams MCP HTTP stderr: ${text.slice(0, 1000)}`); } }); + this.child = child; + + let startupSettled = false; + const startupFailure = new Promise((_, reject) => { + child.once('exit', (code, signal) => { + clearIfCurrent(); + const codeSuffix = typeof code === 'number' ? ` with code ${code}` : ''; + const signalSuffix = signal ? ` (${signal})` : ''; + const message = `Agent Teams MCP HTTP server exited before startup completed${codeSuffix}${signalSuffix}`; + if (!startupSettled) { + reject(new Error(message)); + } + logger.warn(message); + }); + child.once('error', (error) => { + clearIfCurrent(); + const message = `Agent Teams MCP HTTP server process error: ${ + error instanceof Error ? error.message : String(error) + }`; + if (!startupSettled) { + reject(error instanceof Error ? error : new Error(message)); + } + logger.warn(message); + }); + }); try { - await waitForPort(MCP_HTTP_HOST, port, MCP_HTTP_READY_TIMEOUT_MS); + await Promise.race([ + waitForPort(MCP_HTTP_HOST, port, MCP_HTTP_READY_TIMEOUT_MS), + startupFailure, + ]); + if (this.child !== child) { + throw new Error('Agent Teams MCP HTTP server exited before startup completed'); + } } catch (error) { + startupSettled = true; + if (this.child === child) { + this.child = null; + this.handle = null; + } killProcessTree(child, 'SIGKILL'); throw error; } - this.child = child; + startupSettled = true; this.handle = { url: `http://${MCP_HTTP_HOST}:${port}${MCP_HTTP_ENDPOINT}`, port, diff --git a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts index 3abb90ad..d684e77b 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts @@ -96,13 +96,13 @@ export async function cleanupManagedOpenCodeServeProcesses( } const details = await readDetails(row.pid); + const isManagedByWindowsCommand = + platform === 'win32' && isAppManagedWindowsOpenCodeServeCommand(row.command); const isManaged = - platform === 'win32' - ? isAppManagedWindowsOpenCodeServeCommand(row.command) || - Boolean(details && isManagedOpenCodeServeProcessDetails(details)) - : Boolean(details && isManagedOpenCodeServeProcessDetails(details)); + isManagedByWindowsCommand || Boolean(details && isManagedOpenCodeServeProcessDetails(details)); const hasRequiredDetailsMarkers = requiredDetailsMarkers.length === 0 || + (isManagedByWindowsCommand && details === null) || Boolean(details && processDetailsIncludeMarkers(details, requiredDetailsMarkers)); if (!isManaged || !hasRequiredDetailsMarkers) { result.candidates.push({ diff --git a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts index 0801ef07..a964b49e 100644 --- a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts +++ b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts @@ -145,6 +145,58 @@ describe('AgentTeamsMcpHttpServer', () => { expect(spawnProcess).toHaveBeenCalledTimes(1); }); + it('fails startup promptly when the child exits before readiness', async () => { + const child = new FakeChildProcess(); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => 41003, + spawnProcess: vi.fn(() => child as any), + waitForPort: vi.fn(() => { + child.emit('exit', 1, null); + return new Promise(() => { + // Keep readiness pending so startup resolves only through the child exit. + }); + }), + }); + + await expect(server.ensureStarted()).rejects.toThrow( + 'Agent Teams MCP HTTP server exited before startup completed with code 1' + ); + expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(child, 'SIGKILL'); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( + 'Agent Teams MCP HTTP server exited before startup completed with code 1' + ); + vi.mocked(console.warn).mockClear(); + }); + + it('does not return a handle if the child exits during readiness polling', async () => { + const child = new FakeChildProcess(); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => 41004, + spawnProcess: vi.fn(() => child as any), + waitForPort: vi.fn(async () => { + await Promise.resolve(); + child.emit('exit', 0, null); + }), + }); + + await expect(server.ensureStarted()).rejects.toThrow( + 'Agent Teams MCP HTTP server exited before startup completed' + ); + expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(child, 'SIGKILL'); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( + 'Agent Teams MCP HTTP server exited before startup completed with code 0' + ); + vi.mocked(console.warn).mockClear(); + }); + it('waits for the HTTP health endpoint before marking the server ready', async () => { const child = new FakeChildProcess(); const port = await allocateLoopbackPort(); diff --git a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts index 9561c40c..2b9ad444 100644 --- a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts +++ b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts @@ -403,6 +403,33 @@ describe('OpenCodeManagedHostProcessCleanup', () => { expect(result.diagnostics).toEqual([]); }); + it('does not require unreadable Windows details for app-managed command fallback cleanup', async () => { + const killProcess = vi.fn(); + + const result = await cleanupManagedOpenCodeServeProcesses({ + mode: 'force', + platform: 'win32', + requiredDetailsMarkers: ['CLAUDE_TEAM_APP_INSTANCE_ID=app-1'], + listProcessRows: () => + resolved([ + { + pid: 71629, + ppid: 86256, + command: + '"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49914', + }, + ]), + readProcessDetails: () => resolved(null), + disposeServeHost: () => resolved(undefined), + isProcessAlive: () => false, + killProcess, + }); + + expect(killProcess).toHaveBeenCalledWith(71629); + expect(result.candidates[0]).toMatchObject({ pid: 71629, action: 'killed' }); + expect(result.diagnostics).toEqual([]); + }); + it('keeps app-managed Windows OpenCode serve processes while their parent is still alive', async () => { const killProcess = vi.fn(); From f3f38dd0e9e912f362895b846d04257453745b89 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 02:21:02 +0300 Subject: [PATCH 08/10] fix(opencode): refresh bridge MCP env per command --- mcp-server/src/index.ts | 2 +- src/main/index.ts | 20 +++++++++ .../bridge/OpenCodeBridgeCommandClient.ts | 12 +++++- .../team/OpenCodeBridgeCommandClient.test.ts | 42 ++++++++++++++++++- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 59abcf0b..5445e709 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'url'; import { FastMCP } from 'fastmcp'; diff --git a/src/main/index.ts b/src/main/index.ts index 2fe0b20c..dc822884 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -415,10 +415,30 @@ async function createOpenCodeRuntimeAdapterRegistry( } reportProgress('runtime-bridge', 'Preparing OpenCode bridge...'); + const resolveBridgeCommandEnv = async (): Promise => { + const nextEnv = { ...bridgeEnv }; + if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) { + return nextEnv; + } + try { + const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted(); + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; + nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; + } catch (error) { + delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; + logger.warn( + `[OpenCode] Runtime adapter bridge MCP HTTP server refresh failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + return nextEnv; + }; const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath, tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'), env: bridgeEnv, + envProvider: resolveBridgeCommandEnv, }); const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge'); const clientIdentity = createOpenCodeBridgeClientIdentity({ diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts index 4ec07423..f5602d64 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts @@ -51,6 +51,7 @@ export interface OpenCodeBridgeCommandClientOptions { diagnosticIdFactory?: () => string; clock?: () => Date; env?: NodeJS.ProcessEnv; + envProvider?: () => NodeJS.ProcessEnv | Promise; keepInputFile?: boolean; } @@ -102,6 +103,7 @@ export class OpenCodeBridgeCommandClient { private readonly diagnosticIdFactory: () => string; private readonly clock: () => Date; private readonly env: NodeJS.ProcessEnv; + private readonly envProvider: (() => NodeJS.ProcessEnv | Promise) | null; private readonly keepInputFile: boolean; constructor(options: OpenCodeBridgeCommandClientOptions) { @@ -114,6 +116,7 @@ export class OpenCodeBridgeCommandClient { options.diagnosticIdFactory ?? (() => `opencode-bridge-diagnostic-${randomUUID()}`); this.clock = options.clock ?? (() => new Date()); this.env = applyOpenCodeAutoUpdatePolicy(options.env ?? process.env); + this.envProvider = options.envProvider ?? null; this.keepInputFile = options.keepInputFile ?? false; } @@ -147,7 +150,7 @@ export class OpenCodeBridgeCommandClient { timeoutMs: options.timeoutMs, stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES, stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES, - env: this.env, + env: await this.resolveEnv(), }); if (processResult.timedOut) { @@ -195,6 +198,13 @@ export class OpenCodeBridgeCommandClient { } } + private async resolveEnv(): Promise { + if (!this.envProvider) { + return this.env; + } + return applyOpenCodeAutoUpdatePolicy(await this.envProvider()); + } + private async writeInputFile( envelope: OpenCodeBridgeCommandEnvelope ): Promise { diff --git a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts index f5daf35d..ef0c7479 100644 --- a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts @@ -189,6 +189,43 @@ describe('OpenCodeBridgeCommandClient', () => { }, }); }); + + it('resolves command env lazily for each bridge command', async () => { + runner.nextResult = { + stdout: `${JSON.stringify(bridgeSuccess({ data: { runId: 'run-1' } }))}\n`, + stderr: '', + exitCode: 0, + timedOut: false, + }; + let envVersion = 0; + const client = createClient({ + envProvider: () => { + envVersion += 1; + return { + PATH: '/usr/bin', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: `http://127.0.0.1:${5000 + envVersion}/mcp`, + }; + }, + }); + + await client.execute('opencode.launchTeam', { runId: 'run-1' }, { + cwd: '/tmp/project', + timeoutMs: 10_000, + }); + await client.execute('opencode.launchTeam', { runId: 'run-2' }, { + cwd: '/tmp/project', + timeoutMs: 10_000, + }); + + expect(runner.calls[0].env).toMatchObject({ + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: 'http://127.0.0.1:5001/mcp', + OPENCODE_DISABLE_AUTOUPDATE: '1', + }); + expect(runner.calls[1].env).toMatchObject({ + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: 'http://127.0.0.1:5002/mcp', + OPENCODE_DISABLE_AUTOUPDATE: '1', + }); + }); }); describe('redactBridgeDiagnosticText', () => { @@ -205,7 +242,9 @@ describe('redactBridgeDiagnosticText', () => { }); }); -function createClient(): OpenCodeBridgeCommandClient { +function createClient( + overrides: Partial[0]> = {} +): OpenCodeBridgeCommandClient { return new OpenCodeBridgeCommandClient({ binaryPath: '/usr/local/bin/agent-teams-controller', tempDirectory: tempDir, @@ -215,6 +254,7 @@ function createClient(): OpenCodeBridgeCommandClient { diagnosticIdFactory: () => 'diag-1', clock: () => new Date('2026-04-21T12:00:00.000Z'), env: { PATH: '/usr/bin' }, + ...overrides, }); } From f3a01d173f504603f4fbfbda0a05ede26c7a6ac7 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 09:01:40 +0300 Subject: [PATCH 09/10] fix(opencode): preserve MCP fallback on refresh failure --- src/main/index.ts | 69 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index dc822884..a8474c66 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -353,6 +353,33 @@ async function createOpenCodeRuntimeAdapterRegistry( const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env }); bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId; bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath(); + const applyMcpLaunchSpecEnv = async ( + targetEnv: NodeJS.ProcessEnv, + options: { emitProgress?: boolean } = {} + ): Promise => { + try { + if (options.emitProgress) { + reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...'); + } + const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({ + onProgress: options.emitProgress + ? ({ phase, message }) => reportProgress(`mcp-${phase}`, message) + : undefined, + }); + const mcpEntry = mcpLaunchSpec.args[0]; + if (mcpEntry) { + targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; + targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; + targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args); + } + } catch (error) { + logger.warn( + `[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + }; try { const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); if (appManagedOpenCodeBinary && !bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH) { @@ -394,24 +421,7 @@ async function createOpenCodeRuntimeAdapterRegistry( ); } if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) { - try { - reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...'); - const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({ - onProgress: ({ phase, message }) => reportProgress(`mcp-${phase}`, message), - }); - const mcpEntry = mcpLaunchSpec.args[0]; - if (mcpEntry) { - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; - bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args); - } - } catch (error) { - logger.warn( - `[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } + await applyMcpLaunchSpecEnv(bridgeEnv, { emitProgress: true }); } reportProgress('runtime-bridge', 'Preparing OpenCode bridge...'); @@ -424,8 +434,31 @@ async function createOpenCodeRuntimeAdapterRegistry( const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted(); bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; + delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND; + delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY; + delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON; } catch (error) { delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; + if ( + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND && + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY && + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON + ) { + nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND; + nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY; + nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON; + } else { + await applyMcpLaunchSpecEnv(nextEnv); + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = + nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND; + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = + nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY; + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = + nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON; + } logger.warn( `[OpenCode] Runtime adapter bridge MCP HTTP server refresh failed: ${ error instanceof Error ? error.message : String(error) From 31bfd5ebb3ff924bd1b528bd4c1cf144b42468a4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 09:15:35 +0300 Subject: [PATCH 10/10] fix(opencode): keep Windows marker cleanup conservative --- .../opencode/bridge/OpenCodeManagedHostProcessCleanup.ts | 1 - .../services/team/OpenCodeManagedHostProcessCleanup.test.ts | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts index d684e77b..9e3e8e58 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts @@ -102,7 +102,6 @@ export async function cleanupManagedOpenCodeServeProcesses( isManagedByWindowsCommand || Boolean(details && isManagedOpenCodeServeProcessDetails(details)); const hasRequiredDetailsMarkers = requiredDetailsMarkers.length === 0 || - (isManagedByWindowsCommand && details === null) || Boolean(details && processDetailsIncludeMarkers(details, requiredDetailsMarkers)); if (!isManaged || !hasRequiredDetailsMarkers) { result.candidates.push({ diff --git a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts index 2b9ad444..803d33cf 100644 --- a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts +++ b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts @@ -403,7 +403,7 @@ describe('OpenCodeManagedHostProcessCleanup', () => { expect(result.diagnostics).toEqual([]); }); - it('does not require unreadable Windows details for app-managed command fallback cleanup', async () => { + it('honors required markers when Windows details are unavailable', async () => { const killProcess = vi.fn(); const result = await cleanupManagedOpenCodeServeProcesses({ @@ -425,8 +425,8 @@ describe('OpenCodeManagedHostProcessCleanup', () => { killProcess, }); - expect(killProcess).toHaveBeenCalledWith(71629); - expect(result.candidates[0]).toMatchObject({ pid: 71629, action: 'killed' }); + expect(killProcess).not.toHaveBeenCalled(); + expect(result.candidates[0]).toMatchObject({ pid: 71629, action: 'kept_unmanaged' }); expect(result.diagnostics).toEqual([]); });