Skip to content

Commit 61a6336

Browse files
StreamDemonclaude
andcommitted
fix(browse): make sidebar Terminal pane work on Windows
Two Windows-only blockers in the side-panel PTY: 1. terminal-agent.ts spawnClaude() used Bun.spawn(..., {terminal:}), which is POSIX-only by design (oven-sh/bun#25565). Platform-gated to bun-pty (Rust portable-pty via Bun FFI) on Windows; POSIX path unchanged. Wrapped behind a Bun-Subprocess-shaped adapter so disposeSession, the message handler, and source-grep tests see one uniform shape across platforms. 2. cli.ts spawned the agent via Bun.spawn(['bun','run',...]) from inside compiled browse.exe; the child died before writing its state files. pkill-based cleanup also doesn't exist on Windows. Mirrors the existing Windows-detach pattern at cli.ts:221-232: Bun.spawnSync( ['node','-e',launcherCode]) wrapping child_process.spawn().unref(). Cleanup uses taskkill /PID with /FI IMAGENAME filter so recycled PIDs can't be killed by accident. Spawned-child stdio is redirected to <projectDir>/.gstack/terminal-agent.log so startup failures (port collision, missing claude, bun-pty load fail) are diagnosable instead of presenting as silent 503s in the sidebar. Closes #1221. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dde5510 commit 61a6336

5 files changed

Lines changed: 207 additions & 24 deletions

File tree

browse/src/cli.ts

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -896,23 +896,99 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
896896
if (fs.existsSync(termAgentScript)) {
897897
// Kill old terminal-agents so a stale port file can't trick the
898898
// server into routing /pty-session at a dead listener.
899-
try {
900-
const { spawnSync } = require('child_process');
901-
spawnSync('pkill', ['-f', 'terminal-agent\\.ts'], { stdio: 'ignore', timeout: 3000 });
902-
} catch (err: any) {
903-
if (err?.code !== 'ENOENT') throw err;
899+
if (IS_WINDOWS) {
900+
// Windows: read the PID file the previous agent wrote, target it
901+
// explicitly with taskkill /PID. Don't use /IM bun.exe — that
902+
// would kill every Bun process on the machine. Add /FI IMAGENAME
903+
// filter so a PID that was recycled to a non-bun process gets
904+
// skipped (the previous agent may have been hard-killed and left
905+
// a stale PID file). Number.isFinite check distinguishes "first
906+
// run" from "corrupted PID file" — log a warning for the latter.
907+
const pidFile = path.join(path.dirname(config.stateFile), 'terminal-agent.pid');
908+
try {
909+
const raw = fs.readFileSync(pidFile, 'utf-8');
910+
const oldPid = parseInt(raw, 10);
911+
if (Number.isFinite(oldPid) && oldPid > 0) {
912+
Bun.spawnSync(['taskkill', '/PID', String(oldPid), '/T', '/F', '/FI', 'IMAGENAME eq bun.exe'], {
913+
stdout: 'pipe', stderr: 'pipe', timeout: 5000,
914+
});
915+
} else {
916+
console.warn(`[browse] terminal-agent.pid contained non-numeric content (${JSON.stringify(raw.slice(0, 32))}); skipping kill`);
917+
}
918+
} catch (err: any) {
919+
if (err?.code !== 'ENOENT') throw err;
920+
}
921+
} else {
922+
try {
923+
const { spawnSync } = require('child_process');
924+
spawnSync('pkill', ['-f', 'terminal-agent\\.ts'], { stdio: 'ignore', timeout: 3000 });
925+
} catch (err: any) {
926+
if (err?.code !== 'ENOENT') throw err;
927+
}
928+
}
929+
930+
if (IS_WINDOWS) {
931+
// Windows: Bun.spawn(['bun','run',...]).unref() doesn't truly
932+
// detach when invoked from inside the compiled browse.exe — the
933+
// child dies before it can write its state files (terminal-port,
934+
// terminal-agent.pid). Mirror the proven pattern at cli.ts:221-232:
935+
// stringified child_process.spawn launched via `node -e`, with
936+
// detached: true and .unref() so the agent outlives this process.
937+
//
938+
// Resolve the real bun.exe — npm-installed `bun` is a POSIX shell
939+
// script Windows can't execute (the bug #1 npm-shim issue). Don't
940+
// fall back to bare 'bun.exe' on PATH — it'll resolve to that same
941+
// shim. process.execPath returns the compiled browse.exe (which
942+
// can't be re-invoked as `bun run`), so it's not a valid fallback
943+
// either. If neither candidate exists, fail loud with install
944+
// instructions and skip the spawn (chat degrades gracefully).
945+
const bunCandidates = [
946+
process.env.BUN_INSTALL && `${process.env.BUN_INSTALL}\\bin\\bun.exe`,
947+
process.env.USERPROFILE && `${process.env.USERPROFILE}\\.bun\\bin\\bun.exe`,
948+
].filter(Boolean) as string[];
949+
const bunExe = bunCandidates.find(c => fs.existsSync(c));
950+
if (!bunExe) {
951+
console.error('[browse] Could not find bun.exe (checked BUN_INSTALL\\bin and %USERPROFILE%\\.bun\\bin).');
952+
console.error('[browse] Terminal agent will not start. Install official Bun: irm bun.com/install.ps1 | iex');
953+
} else {
954+
// Redirect the detached child's stdout+stderr to a log file so
955+
// startup failures (port collision, claude-not-found, bun-pty
956+
// load failure per terminal-agent.ts:33, etc.) are diagnosable.
957+
// Without this, the user sees only "Terminal agent started" +
958+
// 503s in the sidebar with no breadcrumb. Parent's stderr is
959+
// 'inherit' so synchronous launcher errors (e.g. node missing)
960+
// surface immediately to the user's console.
961+
const logPath = path.join(path.dirname(config.stateFile), 'terminal-agent.log');
962+
const extraEnvStr = JSON.stringify({
963+
BROWSE_STATE_FILE: config.stateFile,
964+
BROWSE_SERVER_PORT: String(newState.port),
965+
});
966+
const launcherCode =
967+
`const{spawn}=require('child_process');` +
968+
`const fs=require('fs');` +
969+
`const out=fs.openSync(${JSON.stringify(logPath)},'a');` +
970+
`spawn(${JSON.stringify(bunExe)},['run',${JSON.stringify(termAgentScript)}],` +
971+
`{detached:true,stdio:['ignore',out,out],windowsHide:true,` +
972+
`cwd:${JSON.stringify(config.projectDir)},` +
973+
`env:Object.assign({},process.env,${extraEnvStr})}).unref()`;
974+
Bun.spawnSync(['node', '-e', launcherCode], {
975+
stdio: ['ignore', 'ignore', 'inherit'],
976+
});
977+
console.log(`[browse] Terminal agent started (detached); logs: ${logPath}`);
978+
}
979+
} else {
980+
const termProc = Bun.spawn(['bun', 'run', termAgentScript], {
981+
cwd: config.projectDir,
982+
env: {
983+
...process.env,
984+
BROWSE_STATE_FILE: config.stateFile,
985+
BROWSE_SERVER_PORT: String(newState.port),
986+
},
987+
stdio: ['ignore', 'ignore', 'ignore'],
988+
});
989+
termProc.unref();
990+
console.log(`[browse] Terminal agent started (PID: ${termProc.pid})`);
904991
}
905-
const termProc = Bun.spawn(['bun', 'run', termAgentScript], {
906-
cwd: config.projectDir,
907-
env: {
908-
...process.env,
909-
BROWSE_STATE_FILE: config.stateFile,
910-
BROWSE_SERVER_PORT: String(newState.port),
911-
},
912-
stdio: ['ignore', 'ignore', 'ignore'],
913-
});
914-
termProc.unref();
915-
console.log(`[browse] Terminal agent started (PID: ${termProc.pid})`);
916992
}
917993
} catch (err: any) {
918994
// Non-fatal: chat still works without the terminal agent.

browse/src/terminal-agent.ts

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,31 @@
2323
import * as fs from 'fs';
2424
import * as path from 'path';
2525
import * as crypto from 'crypto';
26-
import { safeUnlink } from './error-handling';
26+
import { safeUnlink, safeUnlinkQuiet } from './error-handling';
27+
import { IS_WINDOWS } from './platform';
28+
29+
// bun-pty is loaded only on Windows because Bun's `terminal:` spawn option
30+
// is POSIX-only (upstream oven-sh/bun#25565). The agent runs as
31+
// `bun run terminal-agent.ts` (not compiled), so node_modules resolution
32+
// works at runtime; macOS/Linux skip the dependency entirely. If load
33+
// fails (missing dep, native binding can't dlopen — e.g. AV quarantine,
34+
// MSVC runtime missing), exit loudly with actionable instructions so the
35+
// failure isn't invisible behind cli.ts's "Terminal agent started" message.
36+
let ptySpawn: any = null;
37+
if (IS_WINDOWS) {
38+
try {
39+
ptySpawn = (await import('bun-pty')).spawn;
40+
} catch (err: any) {
41+
console.error(`[terminal-agent] FATAL: bun-pty failed to load on Windows: ${err?.message || err}`);
42+
console.error(`[terminal-agent] Run \`bun install\` in the gstack directory.`);
43+
console.error(`[terminal-agent] If the problem persists, see https://github.com/oven-sh/bun/issues/25565`);
44+
process.exit(2);
45+
}
46+
}
2747

2848
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
2949
const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port');
50+
const PID_FILE = path.join(path.dirname(STATE_FILE), 'terminal-agent.pid');
3051
const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10);
3152
const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check
3253
const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn
@@ -74,6 +95,17 @@ function findClaude(): string | null {
7495
`${process.env.HOME}/.bun/bin/claude`,
7596
`${process.env.HOME}/.npm-global/bin/claude`,
7697
];
98+
if (IS_WINDOWS) {
99+
// HOME is often unset on Windows; use APPDATA / LOCALAPPDATA / USERPROFILE
100+
// explicitly. Cover the npm shim (claude.cmd), the official Anthropic
101+
// installer, and `bun install -g`.
102+
if (process.env.APPDATA) candidates.push(`${process.env.APPDATA}\\npm\\claude.cmd`);
103+
if (process.env.LOCALAPPDATA) {
104+
candidates.push(`${process.env.LOCALAPPDATA}\\AnthropicClaude\\claude.exe`);
105+
candidates.push(`${process.env.LOCALAPPDATA}\\Programs\\Anthropic\\claude\\claude.exe`);
106+
}
107+
if (process.env.USERPROFILE) candidates.push(`${process.env.USERPROFILE}\\.bun\\bin\\claude.exe`);
108+
}
77109
for (const c of candidates) {
78110
try { fs.accessSync(c, fs.constants.X_OK); return c; } catch {}
79111
}
@@ -167,6 +199,54 @@ function spawnClaude(cols: number, rows: number, onData: (chunk: Buffer) => void
167199
const stateDir = path.dirname(STATE_FILE);
168200
const tabHint = buildTabAwarenessHint(stateDir);
169201

202+
if (IS_WINDOWS) {
203+
// ConPTY-backed PTY via bun-pty (Rust portable-pty + Bun FFI). Returns a
204+
// wrapper that mimics Bun's Subprocess shape — { pid, terminal: { write,
205+
// resize, close }, kill, killed, exited } — so disposeSession and the
206+
// message handler use one set of optional-chain accessors across platforms.
207+
const pty = ptySpawn(claudePath, ['--append-system-prompt', tabHint], {
208+
name: 'xterm-256color',
209+
cols,
210+
rows,
211+
env,
212+
});
213+
pty.onData((s: string) => onData(Buffer.from(s, 'utf-8')));
214+
let killed = false;
215+
const exited = new Promise<number>((resolve) => {
216+
pty.onExit(({ exitCode }: any) => {
217+
killed = true;
218+
resolve(exitCode ?? 0);
219+
});
220+
});
221+
return {
222+
pid: pty.pid,
223+
get killed() { return killed; },
224+
terminal: {
225+
// Don't swallow write errors here — the message handler at the call
226+
// site already wraps in try/catch and logs via console.error. Letting
227+
// throws propagate keeps Win/POSIX error visibility symmetric.
228+
write: (buf: Buffer | string) => {
229+
pty.write(typeof buf === 'string' ? buf : buf.toString('utf-8'));
230+
},
231+
resize: (c: number, r: number) => {
232+
try { pty.resize(c, r); } catch (err) {
233+
console.error('[terminal-agent] pty.resize failed:', err);
234+
}
235+
},
236+
close: () => { /* bun-pty has no terminal.close; kill() handles teardown */ },
237+
},
238+
kill: (signal?: string) => {
239+
try {
240+
pty.kill(signal);
241+
killed = true;
242+
} catch (err) {
243+
console.error('[terminal-agent] pty.kill failed:', err);
244+
}
245+
},
246+
exited,
247+
};
248+
}
249+
170250
const proc = (Bun as any).spawn([claudePath, '--append-system-prompt', tabHint], {
171251
terminal: {
172252
rows,
@@ -529,16 +609,33 @@ function main() {
529609
fs.writeFileSync(tmp, String(port), { mode: 0o600 });
530610
fs.renameSync(tmp, PORT_FILE);
531611

612+
// Write PID file atomically. cli.ts reads this on disconnect/cleanup so
613+
// it can target this exact agent with taskkill /PID on Windows (where
614+
// pkill -f doesn't exist and /IM bun.exe would kill every Bun process).
615+
const pidTmp = `${PID_FILE}.tmp-${process.pid}`;
616+
fs.writeFileSync(pidTmp, String(process.pid), { mode: 0o600 });
617+
fs.renameSync(pidTmp, PID_FILE);
618+
532619
// Hand the parent the internal token so it can call /internal/grant.
533620
// Parent learns INTERNAL_TOKEN via env (TERMINAL_AGENT_INTERNAL_TOKEN below).
534621
// We just print it on stdout for the supervising process to pick up if it's
535622
// not already in env. Defense against env races at spawn time.
536623
console.log(`[terminal-agent] listening on 127.0.0.1:${port} pid=${process.pid}`);
537624

538-
// Cleanup port file on exit.
539-
const cleanup = () => { safeUnlink(PORT_FILE); process.exit(0); };
540-
process.on('SIGTERM', cleanup);
541-
process.on('SIGINT', cleanup);
625+
// Cleanup state files on exit. `safeUnlinkQuiet` because per CLAUDE.md
626+
// shutdown paths must swallow all errors so a single throw doesn't skip
627+
// subsequent unlinks. Register on `exit` (sync handler — no process.exit
628+
// inside!) so hard-killed agents (Windows taskkill /T /F skips signal
629+
// handlers) still clean up state files. SIGTERM/SIGINT call cleanup
630+
// explicitly because process.exit(0) won't fire 'exit' synchronously
631+
// before the handler returns; the explicit calls cover the graceful path.
632+
const cleanup = () => {
633+
safeUnlinkQuiet(PORT_FILE);
634+
safeUnlinkQuiet(PID_FILE);
635+
};
636+
process.on('exit', cleanup);
637+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
638+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
542639
}
543640

544641
// Export the internal token so cli.ts can pass the SAME value to the parent

browse/test/terminal-agent-integration.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
1818
import * as fs from 'fs';
1919
import * as path from 'path';
2020
import * as os from 'os';
21+
import { IS_WINDOWS } from '../src/platform';
2122

2223
const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts');
2324
const BASH = '/bin/bash';
2425

26+
// /bin/bash isn't available on Windows; the agent's PTY layer is also a
27+
// different path (bun-pty vs Bun.spawn({terminal:})). Skip this whole suite
28+
// on Windows — CI is Ubuntu-only so it doesn't change coverage.
29+
2530
let stateDir: string;
2631
let agentProc: any;
2732
let agentPort: number;
@@ -50,6 +55,7 @@ function readTokenFile(): string {
5055
}
5156

5257
beforeAll(() => {
58+
if (IS_WINDOWS) return;
5359
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-term-'));
5460
const stateFile = path.join(stateDir, 'browse.json');
5561
// browse.json must exist so the agent's readBrowseToken doesn't throw.
@@ -83,7 +89,7 @@ async function grantToken(token: string): Promise<Response> {
8389
});
8490
}
8591

86-
describe('terminal-agent: /internal/grant', () => {
92+
describe.skipIf(IS_WINDOWS)('terminal-agent: /internal/grant', () => {
8793
test('accepts grants signed with the internal token', async () => {
8894
const resp = await grantToken('test-cookie-token-very-long-yes');
8995
expect(resp.status).toBe(200);
@@ -102,7 +108,7 @@ describe('terminal-agent: /internal/grant', () => {
102108
});
103109
});
104110

105-
describe('terminal-agent: /ws gates', () => {
111+
describe.skipIf(IS_WINDOWS)('terminal-agent: /ws gates', () => {
106112
test('rejects upgrade attempts without an extension Origin', async () => {
107113
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`);
108114
expect(resp.status).toBe(403);
@@ -127,7 +133,7 @@ describe('terminal-agent: /ws gates', () => {
127133
});
128134
});
129135

130-
describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () => {
136+
describe.skipIf(IS_WINDOWS)('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () => {
131137
test('binary writes go to PTY stdin, output streams back', async () => {
132138
const cookie = 'rt-token-must-be-at-least-seventeen-chars-long';
133139
const granted = await grantToken(cookie);

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"dependencies": {
4444
"@huggingface/transformers": "^4.1.0",
4545
"@ngrok/ngrok": "^1.7.0",
46+
"bun-pty": "^0.4.8",
4647
"diff": "^7.0.0",
4748
"marked": "^18.0.2",
4849
"playwright": "^1.58.2",

0 commit comments

Comments
 (0)