From 799113ca589cc691ce41813e947f3ad7862d27ee Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Fri, 23 Oct 2026 07:15:20 -0700 Subject: [PATCH] fix(browse): spawn bun.exe directly for the terminal-agent on Windows Under the Node polyfill a bare 'bun' resolved (via cross-spawn) to npm's bun.cmd shim and ran as `cmd.exe /c bun run terminal-agent.ts`. windowsHide only hid cmd.exe, and the detached cmd.exe had no console, so bun.exe allocated a visible one that stole focus on every spawn. proc.pid was cmd.exe's, so the agent's startup check (record.pid === process.pid) never matched; it exited after 2s and the 60s watchdog respawned it (#2295). resolveBunExecutable() finds the real bun.exe (own execPath under bun, BUN_INSTALL, PATH, the npm-global node_modules layout, ~/.bun) and falls back to 'bun'. POSIX is unchanged. Co-Authored-By: Claude Opus 5.5 --- browse/src/terminal-agent-control.ts | 43 ++++++++++++++- browse/test/terminal-agent-bun-exe.test.ts | 64 ++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 browse/test/terminal-agent-bun-exe.test.ts diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts index 1b7247838..6c4bfc7c5 100644 --- a/browse/src/terminal-agent-control.ts +++ b/browse/src/terminal-agent-control.ts @@ -149,6 +149,47 @@ export function bunChildFlags(): string[] { return ['--no-env-file', `--config=${emptyBunConfig()}`]; } +/** + * The bun executable to spawn the agent with. On Windows a bare 'bun' under + * the Node polyfill resolves (via cross-spawn) to npm's bun.cmd shim, run as + * `cmd.exe /c bun ...`. That breaks two ways: windowsHide only hides cmd.exe, + * and the detached cmd.exe has no console, so bun.exe allocates a fresh + * visible one (a window that steals focus on every spawn); and proc.pid is + * cmd.exe's, so the agent's startup check (record.pid === process.pid) never + * matches and it exits after 2s, which the 60s watchdog then respawns. + * Spawning bun.exe directly fixes both. Falls back to 'bun' when no .exe is + * found, which is the old behavior. + */ +export function resolveBunExecutable(opts: { + platform?: string; + env?: NodeJS.ProcessEnv; + execPath?: string; + isBun?: boolean; + isFile?: (p: string) => boolean; +} = {}): string { + const platform = opts.platform ?? process.platform; + if (platform !== 'win32') return 'bun'; + const win = path.win32; + const env = opts.env ?? process.env; + const execPath = opts.execPath ?? process.execPath; + const isBun = opts.isBun ?? !!process.versions.bun; + const isFile = opts.isFile ?? ((p: string) => { try { return fs.statSync(p).isFile(); } catch { return false; } }); + // A compiled browse.exe also reports versions.bun, so check the name too. + if (isBun && /^bun\.exe$/i.test(win.basename(execPath))) return execPath; + const dirs = (env.PATH || env.Path || '').split(';').filter(d => d && win.isAbsolute(d)); + const candidates = [ + ...(env.BUN_INSTALL ? [win.join(env.BUN_INSTALL, 'bin', 'bun.exe')] : []), + ...dirs.flatMap(d => [ + win.join(d, 'bun.exe'), + // npm global install: PATH holds only the bun.cmd shim; the real + // binary it forwards to lives under node_modules. + ...(isFile(win.join(d, 'bun.cmd')) ? [win.join(d, 'node_modules', 'bun', 'bin', 'bun.exe')] : []), + ]), + ...(env.USERPROFILE ? [win.join(env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []), + ]; + return candidates.find(isFile) ?? 'bun'; +} + export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null { const meta = searchHints.metaDir || __dirname; const exec = searchHints.execPath || process.execPath; @@ -208,7 +249,7 @@ export function spawnTerminalAgent(opts: { writeAgentRecord(stateDir, reservation); let proc: any; try { - proc = (Bun as any).spawn(['bun', 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], { + proc = (Bun as any).spawn([resolveBunExecutable(), 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], { cwd: opts.cwd || process.cwd(), env: { ...process.env, diff --git a/browse/test/terminal-agent-bun-exe.test.ts b/browse/test/terminal-agent-bun-exe.test.ts new file mode 100644 index 000000000..05717dc04 --- /dev/null +++ b/browse/test/terminal-agent-bun-exe.test.ts @@ -0,0 +1,64 @@ +/** + * Windows terminal-agent console flash: under the Node polyfill a bare 'bun' + * resolved to npm's bun.cmd shim and ran as `cmd.exe /c bun ...`. The detached + * cmd.exe had no console, so bun.exe opened a visible one on every spawn, and + * proc.pid was cmd.exe's, so the agent failed its startup record check and + * exited, and the 60s watchdog respawned it. spawnTerminalAgent now spawns + * bun.exe directly. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { resolveBunExecutable } from '../src/terminal-agent-control'; + +const files = (...paths: string[]) => (p: string) => paths.some(f => f.toLowerCase() === p.toLowerCase()); + +describe('resolveBunExecutable', () => { + test('POSIX keeps bare bun', () => { + expect(resolveBunExecutable({ platform: 'linux', isFile: () => true })).toBe('bun'); + expect(resolveBunExecutable({ platform: 'darwin', isFile: () => true })).toBe('bun'); + }); + + test('Windows under bun.exe reuses its own executable', () => { + expect(resolveBunExecutable({ + platform: 'win32', isBun: true, execPath: 'C:\\Tools\\bun.exe', env: {}, isFile: () => false, + })).toBe('C:\\Tools\\bun.exe'); + }); + + test('a compiled browse.exe is not mistaken for bun', () => { + expect(resolveBunExecutable({ + platform: 'win32', isBun: true, execPath: 'C:\\gstack\\browse\\dist\\browse.exe', + env: { PATH: 'C:\\bun\\bin' }, isFile: files('C:\\bun\\bin\\bun.exe'), + })).toBe('C:\\bun\\bin\\bun.exe'); + }); + + test('npm global install resolves through the bun.cmd shim to the real bun.exe', () => { + const npm = 'C:\\Users\\u\\AppData\\Roaming\\npm'; + expect(resolveBunExecutable({ + platform: 'win32', isBun: false, execPath: 'C:\\Program Files\\nodejs\\node.exe', + env: { PATH: `C:\\Windows\\System32;${npm}` }, + isFile: files(`${npm}\\bun.cmd`, `${npm}\\node_modules\\bun\\bin\\bun.exe`), + })).toBe(`${npm}\\node_modules\\bun\\bin\\bun.exe`); + }); + + test('BUN_INSTALL wins over PATH, and USERPROFILE\\.bun is the last resort', () => { + const isFile = files('C:\\bi\\bin\\bun.exe', 'C:\\p\\bun.exe', 'C:\\Users\\u\\.bun\\bin\\bun.exe'); + const base = { platform: 'win32', isBun: false, execPath: 'node.exe', isFile }; + expect(resolveBunExecutable({ ...base, env: { BUN_INSTALL: 'C:\\bi', PATH: 'C:\\p' } })).toBe('C:\\bi\\bin\\bun.exe'); + expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\p' } })).toBe('C:\\p\\bun.exe'); + expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\none', USERPROFILE: 'C:\\Users\\u' } })).toBe('C:\\Users\\u\\.bun\\bin\\bun.exe'); + }); + + test('falls back to bare bun when no bun.exe exists', () => { + expect(resolveBunExecutable({ + platform: 'win32', isBun: false, execPath: 'node.exe', env: { PATH: 'C:\\x;relative' }, isFile: () => false, + })).toBe('bun'); + }); + + test('spawnTerminalAgent spawns through the resolver, not a bare bun', () => { + const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/terminal-agent-control.ts'), 'utf8'); + const spawnSite = source.slice(source.indexOf('export function spawnTerminalAgent')); + expect(spawnSite).toContain('spawn([resolveBunExecutable(), \'run\''); + expect(spawnSite).not.toMatch(/spawn\(\[\s*'bun'/); + }); +});