fix(opencode): hide app mcp child processes on windows

This commit is contained in:
iliya 2026-05-16 01:19:13 +03:00
parent 58ff926f4b
commit 3ceef1fb82
9 changed files with 854 additions and 37 deletions

View file

@ -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 ?? '<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 {
@ -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<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,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<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.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();

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

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

View file

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

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

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