Merge pull request #118 from 777genius/fix/hide-cli-child-windows-dev

fix(runtime): hide cli child windows by default
This commit is contained in:
Илия 2026-05-16 09:41:52 +03:00 committed by GitHub
commit 69572150c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1386 additions and 95 deletions

View file

@ -1,10 +1,28 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { pathToFileURL } from 'url';
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 ?? '<empty>'}`);
}
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());
}

View file

@ -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',
},
});
});
});

View file

@ -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 {
@ -352,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<void> => {
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) {
@ -381,29 +409,69 @@ 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) {
await applyMcpLaunchSpecEnv(bridgeEnv, { emitProgress: true });
}
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
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;
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)
}`
);
}
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({
@ -2081,6 +2149,9 @@ async function shutdownServices(): Promise<void> {
() => cleanupOpenCodeHostsForLifecycle('shutdown'),
10_000
);
await runShutdownStep('Agent Teams MCP HTTP server cleanup', () =>
agentTeamsMcpHttpServer.stop()
);
await runShutdownStep('tracked CLI subprocess cleanup', () =>
killTrackedCliProcesses('SIGKILL')
);

View file

@ -0,0 +1,232 @@
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';
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<McpLaunchSpec>;
allocatePort?: () => Promise<number>;
spawnProcess?: (command: string, args: string[], env: NodeJS.ProcessEnv) => ChildProcess;
waitForPort?: (host: string, port: number, timeoutMs: number) => Promise<void>;
}
async function allocateLoopbackPort(): Promise<number> {
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<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function isHealthReady(host: string, port: number): Promise<boolean> {
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<void> {
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<AgentTeamsMcpHttpServerHandle> | null = null;
private child: ChildProcess | null = null;
private handle: AgentTeamsMcpHttpServerHandle | null = null;
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> {
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<void> {
const child = this.child;
this.child = null;
this.handle = null;
if (child) {
killProcessTree(child, 'SIGKILL');
}
}
private async startOnce(): Promise<AgentTeamsMcpHttpServerHandle> {
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.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<never>((_, 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 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;
}
startupSettled = true;
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();

View file

@ -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;

View file

@ -1,10 +1,10 @@
import { execCli } from '@main/utils/childProcess';
import {
getClaudeBasePath,
getMcpConfigsBasePath,
getMcpServerBasePath,
} from '@main/utils/pathDecoder';
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<s
try {
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
const resolved = await new Promise<string>((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...');

View file

@ -28,7 +28,7 @@ function execGit(args: string[], cwd: string): Promise<string> {
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();

View file

@ -5996,6 +5996,10 @@ export class TeamProvisioningService {
private toolApprovalSettingsByTeam = new Map<string, ToolApprovalSettings>();
private pendingTimeouts = new Map<string, NodeJS.Timeout>();
private inFlightResponses = new Set<string>();
private readonly prepareForProvisioningInFlight = new Map<
string,
Promise<TeamProvisioningPrepareResult>
>();
private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null;
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
private workspaceTrustCoordinator: WorkspaceTrustCoordinator | null = null;
@ -6426,6 +6430,7 @@ export class TeamProvisioningService {
encoding: 'utf8',
maxBuffer: 16 * 1024,
timeout: 1000,
windowsHide: true,
},
(error, stdout) => {
if (error) {
@ -18063,6 +18068,74 @@ export class TeamProvisioningService {
limitContext?: boolean;
modelVerificationMode?: TeamProvisioningModelVerificationMode;
}
): Promise<TeamProvisioningPrepareResult> {
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<TeamProvisioningPrepareResult> {
const targetCwdForValidation = cwd?.trim() || process.cwd();
await this.validatePrepareCwd(targetCwdForValidation);

View file

@ -11,7 +11,7 @@ function execGit(args: string[], cwd: string): Promise<string> {
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();

View file

@ -51,6 +51,7 @@ export interface OpenCodeBridgeCommandClientOptions {
diagnosticIdFactory?: () => string;
clock?: () => Date;
env?: NodeJS.ProcessEnv;
envProvider?: () => NodeJS.ProcessEnv | Promise<NodeJS.ProcessEnv>;
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<NodeJS.ProcessEnv>) | 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<NodeJS.ProcessEnv> {
if (!this.envProvider) {
return this.env;
}
return applyOpenCodeAutoUpdatePolicy(await this.envProvider());
}
private async writeInputFile<TBody>(
envelope: OpenCodeBridgeCommandEnvelope<TBody>
): Promise<string> {

View file

@ -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<void>;
}
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<number>();
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,22 @@ export async function cleanupManagedOpenCodeServeProcesses(
}
const details = await readDetails(row.pid);
if (
!details ||
!isManagedOpenCodeServeProcessDetails(details) ||
!processDetailsIncludeMarkers(details, requiredDetailsMarkers)
) {
const isManagedByWindowsCommand =
platform === 'win32' && isAppManagedWindowsOpenCodeServeCommand(row.command);
const isManaged =
isManagedByWindowsCommand || 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 +133,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 +190,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 +272,30 @@ async function readNativeProcessStartTimeMs(pid: number): Promise<number | null>
return Number.isFinite(parsed) ? parsed : null;
}
async function readWindowsProcessStartTimeMs(pid: number): Promise<number | null> {
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);
@ -278,6 +323,7 @@ function execFileText(
encoding: 'utf8',
timeout,
maxBuffer,
windowsHide: true,
},
(error: ExecFileException | null, stdout: string | Buffer) => {
if (error) {

View file

@ -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<T extends { env?: NodeJS.ProcessEnv | Record<string, string | undefined> }>(
options: T
): T {
/** Apply shared CLI process defaults without overriding explicit caller choices. */
function withCliProcessDefaults<
T extends {
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
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<typeof spawn> {
const opts = withCliEnv(options);
const opts = withCliProcessDefaults(options);
const directLauncher = resolveDirectWindowsLauncher(binaryPath);
if (directLauncher) {
const directOpts = { ...opts };
@ -372,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 {

View file

@ -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()

View file

@ -87,6 +87,7 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
const child = spawn(shellPath, args, {
env: process.env,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
const chunks: Buffer[] = [];
let settled = false;

View file

@ -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<ProvisioningProviderCheck[]>([]);
const prepareRequestSeqRef = useRef(0);
const prepareIdleHandleRef = useRef<ScheduledIdleHandle | null>(null);
const prepareUnmountGenerationRef = useRef(0);
const appliedDefaultProjectPathRef = useRef<string | null>(null);
const lastAutoDescriptionRef = useRef<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<{
@ -691,6 +696,23 @@ export const CreateTeamDialog = ({
}
}, [open]);
useEffect(() => {
const generation = ++prepareUnmountGenerationRef.current;
return () => {
// 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;
});
};
}, []);
const prepareRuntimeStatusSignature = useMemo(
() =>
buildProviderPrepareRuntimeStatusSignature(
@ -800,12 +822,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 +844,8 @@ export const CreateTeamDialog = ({
}
if (!effectiveCwd) {
cancelScheduledIdle(prepareIdleHandleRef.current);
prepareIdleHandleRef.current = null;
prepareRequestSeqRef.current += 1;
lastPrepareRequestSignatureRef.current = null;
setPrepareState('idle');
@ -1023,16 +1051,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,
@ -1041,6 +1059,7 @@ export const CreateTeamDialog = ({
effectiveMemberDrafts,
effectiveAnthropicRuntimeLimitContext,
prepareRequestSignature,
prepareRuntimeStatusSignature,
runtimeProviderStatusById,
selectedModel,
selectedProviderId,

View file

@ -0,0 +1,237 @@
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<typeof import('@main/utils/childProcess')>();
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<number> {
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('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<void>(() => {
// 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();
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<void>((resolve) => healthServer.close(() => resolve()));
}
});
});

View file

@ -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<ConstructorParameters<typeof OpenCodeBridgeCommandClient>[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,
});
}

View file

@ -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,89 @@ 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('honors required markers when Windows details are unavailable', 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).not.toHaveBeenCalled();
expect(result.candidates[0]).toMatchObject({ pid: 71629, action: 'kept_unmanaged' });
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 +466,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' });
});
});

View file

@ -9,19 +9,7 @@ const hoisted = vi.hoisted(() => ({
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<typeof import('child_process')>();
vi.mock('@main/utils/childProcess', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/childProcess')>();
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-'));

View file

@ -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') {

View file

@ -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;

View file

@ -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 })
);
});

View file

@ -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<string, unknown>) => 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<void> => {
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();
});
});
});