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:
commit
69572150c9
23 changed files with 1386 additions and 95 deletions
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
54
mcp-server/test/startOptions.test.ts
Normal file
54
mcp-server/test/startOptions.test.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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')
|
||||
);
|
||||
|
|
|
|||
232
src/main/services/team/AgentTeamsMcpHttpServer.ts
Normal file
232
src/main/services/team/AgentTeamsMcpHttpServer.ts
Normal 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();
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
237
test/main/services/team/AgentTeamsMcpHttpServer.test.ts
Normal file
237
test/main/services/team/AgentTeamsMcpHttpServer.test.ts
Normal 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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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-'));
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue