fix(browse): suppress remaining Windows console-window flashes

On Windows the browse tool pops console windows during normal use. #1979 fixes
one source (the watchdog's tasklist → conhost flash every 60s). This addresses
the other three:

- chrome-headless-shell.exe (every browse action): the default headless path
  launches the console-subsystem headless shell, which pops a window. Force
  Chrome's "new" headless (full chrome.exe, GUI-subsystem → no console window)
  on Windows via --headless=new. Other platforms keep the lighter shell.
- node daemon (detached spawn on Windows): detached spawns create a console
  window unless windowsHide is set. Added it to the launcher + the detached
  server spawn.
- bun terminal-agent: spawned via Bun.spawn, whose windowsHide does not
  reliably suppress the window on Windows. Switched to Node's child_process
  .spawn with windowsHide (reliable), same unref'd lifecycle.

Verified on Win11: no chrome-headless-shell process (chrome.exe --headless=new
in the cmdline), screenshots render correctly, single daemon, no stray bun
windows.
This commit is contained in:
Derek 2026-06-13 12:08:18 -04:00
parent 14fc0866d9
commit 6e8574bde4
3 changed files with 20 additions and 3 deletions

View File

@ -369,6 +369,16 @@ export class BrowserManager {
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
}
// Windows: the default headless path launches chrome-headless-shell.exe — a
// CONSOLE-subsystem binary that pops a terminal window on every launch. Switch
// to Chrome's "new" headless (full chrome.exe, GUI-subsystem → no console
// window) by launching non-headless with --headless=new. Other platforms keep
// the lighter headless shell (they have no console-window problem).
if (process.platform === 'win32' && useHeadless) {
launchArgs.push('--headless=new');
useHeadless = false;
}
this.browser = await chromium.launch({
headless: useHeadless,
// On Windows, Chromium's sandbox fails when the server is spawned through

View File

@ -323,9 +323,11 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
const launcherCode =
`const{spawn}=require('child_process');` +
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
// windowsHide: a detached spawn on Windows pops a console window unless
// explicitly hidden — this flashed on every daemon (re)start.
`{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
`${extraEnvStr})}).unref()`;
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
} else {
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
// loop — it does NOT call setsid(), so the spawned server stays in the

View File

@ -16,6 +16,7 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import { spawn as nodeSpawn } from 'child_process';
import { safeUnlink, safeKill, isProcessAlive } from './error-handling';
import { writeSecureFile, mkdirSecure } from './file-permissions';
@ -68,7 +69,10 @@ export function spawnTerminalAgent(opts: {
}
const script = opts.scriptPath || resolveTerminalAgentScript();
if (!script || !fs.existsSync(script)) return null;
const proc = (Bun as any).spawn(['bun', 'run', script], {
// Spawn via Node's child_process (not Bun.spawn): Node's `windowsHide` reliably
// suppresses the console window on Windows, whereas Bun's does not. Same lifecycle:
// an unref'd child so it doesn't hold the parent's event loop open.
const proc = nodeSpawn('bun', ['run', script], {
cwd: opts.cwd || process.cwd(),
env: {
...process.env,
@ -77,6 +81,7 @@ export function spawnTerminalAgent(opts: {
...(opts.extraEnv || {}),
},
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true,
});
proc.unref?.();
return proc.pid ?? null;