feat(server): wire /pty-session route + spawn terminal-agent

Server-side glue connecting the Terminal sidebar tab to the new
terminal-agent process.

server.ts:
- New POST /pty-session route. Validates AUTH_TOKEN, mints a gstack_pty
  HttpOnly cookie via pty-session-cookie.ts, posts the cookie value to
  the agent's loopback /internal/grant. Returns the terminalPort + Set-Cookie
  to the extension.
- /health response gains `terminalPort` (just the port number — never a
  shell token). Tokens flow via the cookie path, never /health, because
  /health already surfaces AUTH_TOKEN to localhost callers in headed mode
  (that's a separate v1.1+ TODO).
- /pty-session and /terminal/* are deliberately NOT added to TUNNEL_PATHS,
  so the dual-listener tunnel surface 404s by default-deny.
- Shutdown path now also pkills terminal-agent and unlinks its state files
  (terminal-port + terminal-internal-token) so a reconnect doesn't try to
  hit a dead port.

cli.ts:
- After spawning sidebar-agent.ts, also spawn terminal-agent.ts. Same
  pattern: pkill old instances, Bun.spawn(['bun', 'run', script]) with
  BROWSE_STATE_FILE + BROWSE_SERVER_PORT env. Non-fatal if the spawn
  fails — chat still works without the terminal agent.
This commit is contained in:
Garry Tan
2026-04-25 12:33:56 -07:00
parent c5407b101b
commit 07d4d36edf
2 changed files with 141 additions and 0 deletions
+34
View File
@@ -933,6 +933,40 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
console.error(`[browse] Sidebar agent failed to start: ${err.message}`);
console.error(`[browse] Run manually: bun run ${agentScript}`);
}
// Auto-start terminal agent (non-compiled, parallel to sidebar-agent).
// Owns the PTY WebSocket for the Terminal sidebar tab. Crash-isolated
// from the chat agent per codex outside-voice review.
let termAgentScript = path.resolve(__dirname, 'terminal-agent.ts');
if (!fs.existsSync(termAgentScript)) {
termAgentScript = path.resolve(path.dirname(process.execPath), '..', 'src', 'terminal-agent.ts');
}
try {
if (fs.existsSync(termAgentScript)) {
// Kill old terminal-agents so a stale port file can't trick the
// server into routing /pty-session at a dead listener.
try {
const { spawnSync } = require('child_process');
spawnSync('pkill', ['-f', 'terminal-agent\\.ts'], { stdio: 'ignore', timeout: 3000 });
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
}
const termProc = Bun.spawn(['bun', 'run', termAgentScript], {
cwd: config.projectDir,
env: {
...process.env,
BROWSE_STATE_FILE: config.stateFile,
BROWSE_SERVER_PORT: String(newState.port),
},
stdio: ['ignore', 'ignore', 'ignore'],
});
termProc.unref();
console.log(`[browse] Terminal agent started (PID: ${termProc.pid})`);
}
} catch (err: any) {
// Non-fatal: chat still works without the terminal agent.
console.error(`[browse] Terminal agent failed to start: ${err.message}`);
}
} catch (err: any) {
console.error(`[browse] Connect failed: ${err.message}`);
process.exit(1);