fix: make Windows agent launches independent of tmux

This commit is contained in:
iliya 2026-04-29 12:29:49 +03:00
parent 8c84720246
commit 5a8a934d8d
15 changed files with 375 additions and 46 deletions

View file

@ -67,7 +67,7 @@ function buildManualHints(platform: TmuxPlatform): TmuxInstallHint[] {
},
{
title: 'Install Ubuntu',
description: 'Recommended WSL distro for the tmux runtime path.',
description: 'Recommended WSL distro for optional tmux pane transport.',
command: 'wsl --install -d Ubuntu --no-launch',
},
{

View file

@ -24,7 +24,7 @@ export function buildTmuxEffectiveAvailability(
binaryPath: input.wsl.tmuxBinaryPath,
runtimeReady: false,
detail:
'tmux is available inside WSL, but the persistent teammate runtime still needs native Windows pane support.',
'tmux is available inside WSL. On Windows it is optional and is not required for teammate runtime startup.',
};
}
@ -36,7 +36,7 @@ export function buildTmuxEffectiveAvailability(
binaryPath: input.host.binaryPath,
runtimeReady: false,
detail:
'tmux was found on Windows, but the app currently relies on a WSL-backed tmux runtime for the most reliable teammate path.',
'tmux was found on Windows. Native process teammates do not require it; tmux remains optional for pane-based terminal transport.',
};
}
@ -49,7 +49,7 @@ export function buildTmuxEffectiveAvailability(
runtimeReady: false,
detail:
input.wsl?.statusDetail ??
'You can keep using the app, but Windows needs WSL before tmux can improve teammate reliability.',
'You can keep using the app without tmux. Install WSL only if you want optional tmux pane transport.',
};
}
@ -61,7 +61,7 @@ export function buildTmuxEffectiveAvailability(
runtimeReady: false,
detail:
input.wsl?.statusDetail ??
'WSL is available, but tmux is not ready there yet. Finish the Linux setup, install tmux, then re-check.',
'WSL is available, but tmux is not ready there yet. Install tmux only if you want optional pane transport.',
};
}
@ -72,7 +72,7 @@ export function buildTmuxEffectiveAvailability(
version: input.host.version,
binaryPath: input.host.binaryPath,
runtimeReady: input.nativeSupported,
detail: 'tmux is available for the persistent teammate runtime.',
detail: 'tmux is available as an optional pane transport for teammate sessions.',
};
}
@ -84,9 +84,9 @@ export function buildTmuxEffectiveAvailability(
runtimeReady: false,
detail:
input.platform === 'darwin'
? 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.'
? 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.'
: input.platform === 'linux'
? 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.'
: 'You can keep using the app, but tmux improves persistent teammate reliability.',
? 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.'
: 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.',
};
}

View file

@ -177,7 +177,7 @@ export class TmuxInstallerRunnerAdapter
strategy: 'wsl',
message: 'Preparing the Windows WSL tmux setup...',
detail:
'The app can keep working without tmux, but WSL-backed tmux gives the most reliable persistent teammate path on Windows.',
'The app can keep working without tmux. WSL-backed tmux is optional and only adds pane-based terminal transport on Windows.',
error: null,
canCancel: true,
acceptsInput: false,

View file

@ -171,22 +171,22 @@ export class TmuxInstallStrategyResolver {
if (input.effective.available) {
return input.effective.location === 'wsl'
? 'tmux is available inside WSL on Windows.'
: 'tmux is available for persistent teammate runtime.';
: 'tmux is available as an optional pane transport for teammate sessions.';
}
if (input.platform === 'darwin') {
return 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.';
return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.';
}
if (input.platform === 'linux') {
return 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.';
return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.';
}
if (input.platform === 'win32') {
return (
input.wsl?.statusDetail ??
'You can keep using the app, but tmux on Windows goes through WSL for the best teammate experience.'
'You can keep using the app without tmux. On Windows, tmux setup uses WSL and is only needed for optional pane transport.'
);
}
return 'You can keep using the app, but tmux improves persistent teammate reliability.';
return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.';
}
#buildCommand(
@ -329,7 +329,7 @@ export class TmuxInstallStrategyResolver {
if (status.wslInstalled && !status.distroName) {
this.#prependUniqueHint(manualHints, {
title: 'Install Ubuntu',
description: 'Recommended WSL distro for the tmux runtime path.',
description: 'Recommended WSL distro for optional tmux pane transport.',
command: 'wsl --install -d Ubuntu --no-launch',
});
}

View file

@ -83,14 +83,14 @@ export class TmuxInstallerBannerAdapter {
snapshot.message ??
status?.effective.detail ??
status?.wsl?.statusDetail ??
'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.';
'tmux is optional. Install it only if you want pane-based terminal transport for long-running teammate sessions.';
const benefitsBody =
status && !status.effective.available ? formatTmuxOptionalBenefits(status.platform) : null;
const runtimeReadyLabel = status
? status.effective.runtimeReady
? 'Ready for persistent teammates'
? 'Pane transport ready'
: status.effective.available
? 'Installed, but not active yet'
? 'Installed, optional transport inactive'
: null
: null;
const versionLabel =

View file

@ -20,7 +20,7 @@ const baseStatus: TmuxStatus = {
version: null,
binaryPath: null,
runtimeReady: false,
detail: 'tmux improves persistent teammate reliability.',
detail: 'tmux is optional. Install it only if you want pane transport.',
},
error: null,
autoInstall: {
@ -72,9 +72,9 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.progressPercent).toBeNull();
expect(result.manualHints).toHaveLength(1);
expect(result.manualHintsCollapsible).toBe(false);
expect(result.body).toContain('persistent teammate reliability');
expect(result.benefitsBody).toContain('Optional, but recommended');
expect(result.benefitsBody).toContain('multi-agent teams that mix providers');
expect(result.body).toContain('tmux is optional');
expect(result.benefitsBody).toContain('Optional');
expect(result.benefitsBody).toContain('pane-based terminal transport');
expect(result.installButtonPrimary).toBe(true);
expect(result.showRefreshButton).toBe(true);
});
@ -102,7 +102,7 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.title).toBe('Installing tmux');
expect(result.body).toBe('Renderer bridge failed');
expect(result.benefitsBody).toContain('Optional, but recommended');
expect(result.benefitsBody).toContain('Optional');
expect(result.error).toBe('Renderer bridge failed');
expect(result.installDisabled).toBe(true);
expect(result.canCancel).toBe(true);
@ -171,7 +171,7 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.primaryGuideUrl).toBe('https://learn.microsoft.com/en-us/windows/wsl/install');
expect(result.progressPercent).toBe(82);
expect(result.manualHintsCollapsible).toBe(true);
expect(result.benefitsBody).toContain('With tmux in WSL');
expect(result.benefitsBody).toContain('WSL-backed tmux');
expect(result.showRefreshButton).toBe(true);
});
@ -188,7 +188,7 @@ describe('TmuxInstallerBannerAdapter', () => {
version: 'tmux 3.4',
binaryPath: 'C:\\tmux.exe',
runtimeReady: false,
detail: 'tmux was found on Windows, but WSL-backed tmux is still preferred.',
detail: 'tmux was found on Windows. Native process teammates do not require it.',
},
},
snapshot: idleSnapshot,
@ -199,7 +199,7 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.visible).toBe(false);
expect(result.locationLabel).toBe('Host runtime');
expect(result.runtimeReadyLabel).toBe('Installed, but not active yet');
expect(result.runtimeReadyLabel).toBe('Installed, optional transport inactive');
expect(result.versionLabel).toBe('tmux 3.4');
expect(result.benefitsBody).toBeNull();
});
@ -216,7 +216,7 @@ describe('TmuxInstallerBannerAdapter', () => {
version: 'tmux 3.6a',
binaryPath: '/opt/homebrew/bin/tmux',
runtimeReady: true,
detail: 'tmux is available for persistent teammates.',
detail: 'tmux is available as an optional pane transport.',
},
},
snapshot: {

View file

@ -25,7 +25,7 @@ const baseStatus: TmuxStatus = {
version: null,
binaryPath: null,
runtimeReady: false,
detail: 'tmux improves persistent teammate reliability.',
detail: 'tmux is optional. Install it only if you want pane transport.',
},
error: null,
autoInstall: {

View file

@ -21,7 +21,7 @@ const baseViewModel: TmuxInstallerBannerViewModel = {
title: 'tmux is not installed',
body: 'WSL is available, but no Linux distribution is installed yet.',
benefitsBody:
'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable. Without tmux, creating multi-agent teams that mix providers may be blocked.',
'Optional. The app works without tmux. Install WSL-backed tmux only if you want pane-based terminal transport for long-running teammate sessions.',
error: null,
platformLabel: 'Windows',
locationLabel: null,
@ -94,8 +94,8 @@ describe('TmuxInstallerBannerView', () => {
const { host, root } = renderBanner(baseViewModel);
expect(host.textContent).toContain('tmux is not installed');
expect(host.textContent).toContain('Optional, but recommended');
expect(host.textContent).toContain('multi-agent teams that mix providers');
expect(host.textContent).toContain('Optional');
expect(host.textContent).toContain('pane-based terminal transport');
expect(host.textContent).not.toContain(
'WSL is available, but no Linux distribution is installed yet.'
);

View file

@ -67,12 +67,9 @@ export function formatTmuxOptionalBenefits(platform: TmuxPlatform | null): strin
return null;
}
const mixedProviderLimit =
'Without tmux, creating multi-agent teams that mix providers may be blocked.';
if (platform === 'win32') {
return `Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`;
return 'Optional. The app works without tmux. Install WSL-backed tmux only if you want pane-based terminal transport for long-running teammate sessions.';
}
return `Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`;
return 'Optional. The app works without tmux. Install tmux only if you want pane-based terminal transport for long-running teammate sessions.';
}

View file

@ -1,4 +1,8 @@
import { getMcpConfigsBasePath, getMcpServerBasePath } from '@main/utils/pathDecoder';
import {
getClaudeBasePath,
getMcpConfigsBasePath,
getMcpServerBasePath,
} from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { execFile } from 'child_process';
import { randomUUID } from 'crypto';
@ -13,6 +17,7 @@ export interface McpLaunchSpec {
}
const MCP_SERVER_NAME = 'agent-teams';
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
const logger = createLogger('Service:TeamMcpConfigBuilder');
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
@ -273,6 +278,9 @@ export class TeamMcpConfigBuilder {
[MCP_SERVER_NAME]: {
command: launchSpec.command,
args: launchSpec.args,
env: {
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
},
},
};

View file

@ -137,7 +137,7 @@ function resolveGeneratedBunLauncher(
}
function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
const scriptMatch = /"%_prog%"\s+"([^"]+\.(?:cjs|mjs|js))"\s+%\*/i.exec(content);
const scriptMatch = /"%_prog%"\s+"([^"]+(?:\.(?:cjs|mjs|js))?)"\s+%\*/i.exec(content);
const scriptTemplate = scriptMatch?.[1];
if (!scriptTemplate) {
return null;

View file

@ -29,6 +29,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
let previousDisableRuntimeBootstrap: string | undefined;
let previousHome: string | undefined;
let previousUserProfile: string | undefined;
let previousNodeEnv: string | undefined;
let svc: TeamProvisioningService | null;
let teamName: string | null;
@ -45,8 +46,10 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
previousHome = process.env.HOME;
previousUserProfile = process.env.USERPROFILE;
previousNodeEnv = process.env.NODE_ENV;
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
process.env.NODE_ENV = 'production';
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
@ -67,7 +70,13 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
restoreEnv('HOME', previousHome);
restoreEnv('USERPROFILE', previousUserProfile);
await fs.rm(tempDir, { recursive: true, force: true });
restoreEnv('NODE_ENV', previousNodeEnv);
if (process.env.ANTHROPIC_RUNTIME_MEMORY_LIVE_KEEP_TEMP === '1') {
// Live-debug only: preserve process/runtime logs for failed Windows liveness triage.
process.stderr.write(`Preserving Anthropic runtime memory live temp dir: ${tempDir}\n`);
return;
}
await removeTempDirWithRetries(tempDir);
});
it('creates a real Anthropic team and reports teammate RSS in the runtime snapshot', async () => {
@ -79,6 +88,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
teamName = `anthropic-memory-live-${Date.now()}`;
const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true });
await writeTrustedClaudeConfig(tempClaudeRoot, projectPath);
await fs.writeFile(
path.join(projectPath, 'README.md'),
'# Anthropic runtime memory live e2e\n',
@ -133,7 +143,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
typeof alice.rssBytes === 'number' &&
alice.rssBytes > 0
);
}, 60_000);
}, 180_000, 1_000, () => JSON.stringify(snapshot, null, 2));
expect(snapshot!.members.alice).toMatchObject({
alive: true,
@ -158,10 +168,53 @@ async function assertExecutable(filePath: string): Promise<void> {
await fs.access(filePath, fsConstants.X_OK);
}
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
const normalizedProjectPath = path.normalize(projectPath).replace(/\\/g, '/');
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
const config: {
projects: Record<string, { hasTrustDialogAccepted: true }>;
customApiKeyResponses?: { approved: string[]; rejected: string[] };
} = {
projects: {
[normalizedProjectPath]: {
hasTrustDialogAccepted: true,
},
},
};
if (approvedApiKeySuffix) {
config.customApiKeyResponses = {
approved: [approvedApiKeySuffix],
rejected: [],
};
}
await fs.writeFile(
path.join(configDir, '.claude.json'),
`${JSON.stringify(config, null, 2)}\n`,
'utf8'
);
}
async function removeTempDirWithRetries(dirPath: string): Promise<void> {
const attempts = process.platform === 'win32' ? 20 : 1;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await fs.rm(dirPath, { recursive: true, force: true });
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if ((code !== 'EBUSY' && code !== 'EPERM') || attempt === attempts) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}
async function waitUntil(
predicate: () => Promise<boolean>,
timeoutMs: number,
pollMs = 1_000
pollMs = 1_000,
describeState?: () => string
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
@ -178,7 +231,8 @@ async function waitUntil(
}
const suffix =
lastError instanceof Error && lastError.message ? ` Last error: ${lastError.message}` : '';
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}`);
const state = describeState ? ` Last state: ${describeState()}` : '';
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}${state}`);
}
function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string {

View file

@ -45,7 +45,7 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
};
});
import { setAppDataBasePath } from '@main/utils/pathDecoder';
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder';
describe('TeamMcpConfigBuilder', () => {
@ -77,10 +77,10 @@ describe('TeamMcpConfigBuilder', () => {
function readGeneratedServer(
configPath: string
): { command?: string; args?: string[] } | undefined {
): { command?: string; args?: string[]; env?: Record<string, string> } | undefined {
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as {
mcpServers?: Record<string, { command?: string; args?: string[] }>;
mcpServers?: Record<string, { command?: string; args?: string[]; env?: Record<string, string> }>;
};
return parsed.mcpServers?.['agent-teams'];
}
@ -180,6 +180,7 @@ describe('TeamMcpConfigBuilder', () => {
afterEach(() => {
setAppDataBasePath(null);
setClaudeBasePathOverride(null);
setPackagedMode(false);
setResourcesPath(originalResourcesPath);
moduleInternal._load = originalModuleLoad;
@ -370,6 +371,20 @@ describe('TeamMcpConfigBuilder', () => {
expectTsxEntry(parsed.mcpServers['agent-teams'], sourceEntry);
});
it('passes the configured Claude root to the MCP server', async () => {
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-claude-root-'));
createdDirs.push(claudeRoot);
setClaudeBasePathOverride(claudeRoot);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expect(readGeneratedServer(configPath)?.env).toMatchObject({
AGENT_TEAMS_MCP_CLAUDE_DIR: claudeRoot,
});
});
it('ignores malformed user MCP file', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));

View file

@ -0,0 +1,179 @@
// @vitest-environment node
import { execFile } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
import { describe, expect, it } from 'vitest';
import { CodexBinaryResolver } from '@main/services/infrastructure/codexAppServer/CodexBinaryResolver';
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
const execFileAsync = promisify(execFile);
const liveDescribe = process.env.AGENT_CLI_LAUNCH_LIVE_E2E === '1' ? describe : describe.skip;
const CLI_LAUNCH_TIMEOUT_MS = 15_000;
type AgentCliProvider = 'opencode' | 'codex' | 'claude';
type AgentCliSpec = {
providerId: AgentCliProvider;
command: string;
overrideEnv: string;
versionPattern: RegExp;
resolver?: () => Promise<string | null>;
};
const AGENT_CLI_SPECS: AgentCliSpec[] = [
{
providerId: 'opencode',
command: 'opencode',
overrideEnv: 'OPENCODE_CLI_PATH',
versionPattern: /\b\d+\.\d+\.\d+\b/,
},
{
providerId: 'codex',
command: 'codex',
overrideEnv: 'CODEX_CLI_PATH',
versionPattern: /\b(?:codex-cli\s+)?\d+\.\d+\.\d+\b/i,
resolver: () => CodexBinaryResolver.resolve(),
},
{
providerId: 'claude',
command: 'claude',
overrideEnv: 'CLAUDE_CLI_PATH',
versionPattern: /\b\d+\.\d+\.\d+\b.*Claude Code/i,
},
];
liveDescribe('agent CLI launch live e2e', () => {
it.each(AGENT_CLI_SPECS)(
'resolves and executes $providerId through execCli without tmux',
async (spec) => {
const binaryPath = await resolveCliBinary(spec);
expect(binaryPath, `${spec.providerId} binary must be installed`).toBeTruthy();
const result = await execCli(binaryPath, ['--version'], {
timeout: CLI_LAUNCH_TIMEOUT_MS,
windowsHide: true,
});
const output = `${result.stdout}\n${result.stderr}`.trim();
expect(output).toMatch(spec.versionPattern);
expect(output).not.toMatch(/tmux/i);
expect(output).not.toMatch(/running scripts is disabled/i);
expect(output).not.toMatch(/not digitally signed/i);
},
CLI_LAUNCH_TIMEOUT_MS + 5_000
);
it.each(AGENT_CLI_SPECS)(
'spawns $providerId through spawnCli and exits cleanly without tmux',
async (spec) => {
const binaryPath = await resolveCliBinary(spec);
expect(binaryPath, `${spec.providerId} binary must be installed`).toBeTruthy();
const result = await spawnAndCollect(binaryPath, ['--version']);
const output = `${result.stdout}\n${result.stderr}`.trim();
expect(result.exitCode).toBe(0);
expect(output).toMatch(spec.versionPattern);
expect(output).not.toMatch(/tmux/i);
expect(output).not.toMatch(/running scripts is disabled/i);
expect(output).not.toMatch(/not digitally signed/i);
},
CLI_LAUNCH_TIMEOUT_MS + 5_000
);
});
async function resolveCliBinary(spec: AgentCliSpec): Promise<string> {
const override = process.env[spec.overrideEnv]?.trim();
if (override) {
return override;
}
if (spec.resolver) {
const resolved = await spec.resolver();
if (resolved) {
return preferWindowsCmdShim(resolved);
}
}
return preferWindowsCmdShim(await resolveCommandFromPath(spec.command));
}
async function resolveCommandFromPath(command: string): Promise<string> {
if (process.platform === 'win32') {
const { stdout } = await execFileAsync('where.exe', [command], {
timeout: CLI_LAUNCH_TIMEOUT_MS,
windowsHide: true,
});
const candidates = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const cmdCandidate = candidates.find((candidate) => /\.cmd$/i.test(candidate));
return cmdCandidate ?? candidates[0] ?? command;
}
const { stdout } = await execFileAsync('which', [command], {
timeout: CLI_LAUNCH_TIMEOUT_MS,
});
return stdout.trim().split(/\r?\n/)[0] ?? command;
}
function preferWindowsCmdShim(binaryPath: string): string {
if (process.platform !== 'win32') {
return binaryPath;
}
const extension = path.extname(binaryPath).toLowerCase();
if (extension === '.cmd') {
return binaryPath;
}
const cmdPeer = extension ? `${binaryPath.slice(0, -extension.length)}.cmd` : `${binaryPath}.cmd`;
return fs.existsSync(cmdPeer) ? cmdPeer : binaryPath;
}
function spawnAndCollect(
binaryPath: string,
args: string[]
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawnCli(binaryPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let settled = false;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
killProcessTree(child, 'SIGKILL');
reject(new Error(`Timed out launching ${binaryPath}`));
}
}, CLI_LAUNCH_TIMEOUT_MS);
child.stdout?.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr?.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.once('error', (error) => {
if (!settled) {
settled = true;
clearTimeout(timeout);
reject(error);
}
});
child.once('close', (exitCode) => {
if (!settled) {
settled = true;
clearTimeout(timeout);
resolve({
exitCode,
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
});
}
});
});
}

View file

@ -64,6 +64,41 @@ function createGeneratedBunLauncher(): { dir: string; launcher: string; target:
return { dir, launcher, target };
}
function createExtensionlessNpmNodeLauncher(): {
dir: string;
launcher: string;
target: string;
} {
const dir = mkdtempSync(path.join(tmpdir(), 'cat-cli-npm-launcher-'));
const targetDir = path.join(dir, 'node_modules', 'opencode-ai', 'bin');
mkdirSync(targetDir, { recursive: true });
const target = path.join(targetDir, 'opencode');
writeFileSync(target, 'console.log("ok")', 'utf8');
const launcher = path.join(dir, 'opencode.cmd');
writeFileSync(
launcher,
[
'@ECHO off',
'GOTO start',
':find_dp0',
'SET dp0=%~dp0',
'EXIT /b',
':start',
'SETLOCAL',
'CALL :find_dp0',
'IF EXIST "%dp0%\\node.exe" (',
' SET "_prog=%dp0%\\node.exe"',
') ELSE (',
' SET "_prog=node"',
')',
'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\opencode-ai\\bin\\opencode" %*',
'',
].join('\r\n'),
'utf8'
);
return { dir, launcher, target };
}
describe('cli child process helpers', () => {
beforeEach(() => {
vi.resetAllMocks();
@ -152,6 +187,24 @@ describe('cli child process helpers', () => {
}
});
it('runs extensionless npm node cmd launchers directly', () => {
setPlatform('win32');
const fake = {} as any;
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockReturnValue(fake);
const { dir, launcher, target } = createExtensionlessNpmNodeLauncher();
try {
const result = spawnCli(launcher, ['--model', 'test%PATH%"arg']);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toBe('node');
expect(spawnMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell');
expect(result).toBe(fake);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('uses shell directly when path contains non-ASCII on windows', () => {
setPlatform('win32');
const fake = {} as any;
@ -281,6 +334,29 @@ describe('cli child process helpers', () => {
}
});
it('executes extensionless npm node cmd launchers directly', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;
const execMock = child.exec as unknown as Mock;
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
cb(null, 'ok', '');
return {} as any;
}
);
const { dir, launcher, target } = createExtensionlessNpmNodeLauncher();
try {
const result = await execCli(launcher, ['--model', 'test%PATH%"arg']);
expect(execFileMock).toHaveBeenCalledTimes(1);
expect(execFileMock.mock.calls[0][0]).toBe('node');
expect(execFileMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
expect(execMock).not.toHaveBeenCalled();
expect(result.stdout).toBe('ok');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('skips straight to shell when path contains non-ASCII on windows', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;