fix: compiled bun binary cannot posix_spawn — use external agent process

Compiled bun binaries fail posix_spawn on ALL executables (even /bin/bash).
The server now writes to an agent queue file, and a separate non-compiled
bun process (sidebar-agent.ts) reads the queue, spawns claude, and POSTs
events back via /sidebar-agent/event.

Changes:
- server.ts: spawnClaude writes to queue file instead of spawning directly
- server.ts: new /sidebar-agent/event endpoint for agent → server relay
- server.ts: fix result event field name (event.text vs event.result)
- sidebar-agent.ts: rewritten to poll queue file, relay events via HTTP
- cli.ts: $B connect auto-starts sidebar-agent as non-compiled bun process

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-22 19:15:07 -07:00
parent 24e1417aad
commit 9d5409c420
3 changed files with 199 additions and 275 deletions
+25
View File
@@ -414,6 +414,31 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
});
const status = await resp.text();
console.log(`Connected to real Chrome\n${status}`);
// Auto-start sidebar agent (non-compiled bun process)
const agentScript = path.resolve(__dirname, 'sidebar-agent.ts');
const agentLogFile = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent.log');
try {
// Clear old agent queue
const agentQueue = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
try { fs.writeFileSync(agentQueue, ''); } catch {}
const agentProc = Bun.spawn(['bun', 'run', agentScript], {
cwd: config.projectDir,
env: {
...process.env,
BROWSE_BIN: path.resolve(__dirname, '..', 'dist', 'browse'),
BROWSE_STATE_FILE: config.stateFile,
BROWSE_SERVER_PORT: String(newState.port),
},
stdio: ['ignore', 'ignore', 'ignore'],
});
agentProc.unref();
console.log(`[browse] Sidebar agent started (PID: ${agentProc.pid})`);
} catch (err: any) {
console.error(`[browse] Sidebar agent failed to start: ${err.message}`);
console.error(`[browse] Run manually: bun run ${agentScript}`);
}
} catch (err: any) {
console.error(`[browse] Connect failed: ${err.message}`);
process.exit(1);
+57 -59
View File
@@ -22,7 +22,8 @@ import { COMMAND_DESCRIPTIONS } from './commands';
import { SNAPSHOT_FLAGS } from './snapshot';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
import { spawn, type ChildProcess } from 'child_process';
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
// fail posix_spawn on all executables including /bin/bash)
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
@@ -365,7 +366,7 @@ function processAgentEvent(event: any): void {
}
if (event.type === 'result') {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'result', text: event.result || '' });
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'result', text: event.text || event.result || '' });
}
}
@@ -398,68 +399,34 @@ function spawnClaude(userMessage: string): void {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_start' });
// Resolve claude binary — daemon process may not have user's PATH
const claudeBin = findClaudeBin();
if (!claudeBin) {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: 'Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code' });
// Compiled bun binaries CANNOT spawn external processes (posix_spawn
// fails with ENOENT on everything, including /bin/bash). Instead,
// write the command to a queue file that the sidebar-agent process
// (running as non-compiled bun) picks up and spawns claude.
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
const agentQueue = path.join(gstackDir, 'sidebar-agent-queue.jsonl');
const entry = JSON.stringify({
ts: new Date().toISOString(),
message: userMessage,
prompt,
args,
stateFile: config.stateFile,
cwd: (sidebarSession as any)?.worktreePath || process.cwd(),
sessionId: sidebarSession?.claudeSessionId || null,
});
try {
fs.mkdirSync(gstackDir, { recursive: true });
fs.appendFileSync(agentQueue, entry + '\n');
} catch (err: any) {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: `Failed to queue: ${err.message}` });
agentStatus = 'idle';
agentStartTime = null;
currentMessage = null;
return;
}
const proc = spawn(claudeBin, args, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: (sidebarSession as any)?.worktreePath || process.cwd(),
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
} as any);
proc.stdin?.end();
agentProcess = proc;
let buffer = '';
proc.stdout?.on('data', (data: Buffer) => {
buffer += data.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try { processAgentEvent(JSON.parse(line)); } catch {}
}
});
proc.stderr?.on('data', () => {}); // Claude logs to stderr, ignore
proc.on('close', () => {
if (buffer.trim()) {
try { processAgentEvent(JSON.parse(buffer)); } catch {}
}
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_done' });
agentProcess = null;
agentStartTime = null;
currentMessage = null;
// Process next queued message (set status synchronously first — race condition guard)
if (messageQueue.length > 0) {
const next = messageQueue.shift()!;
spawnClaude(next.message);
} else {
agentStatus = 'idle';
}
});
proc.on('error', (err) => {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: err.message });
agentProcess = null;
agentStartTime = null;
currentMessage = null;
agentStatus = 'idle';
// Try next in queue even after error
if (messageQueue.length > 0) {
const next = messageQueue.shift()!;
spawnClaude(next.message);
}
});
// The sidebar-agent.ts process polls this file and spawns claude.
// It POST events back via /sidebar-event which processAgentEvent handles.
// Agent status transitions happen when we receive agent_done/agent_error events.
}
function killAgent(): void {
@@ -1042,6 +1009,37 @@ async function start() {
});
}
// Agent event relay — sidebar-agent.ts POSTs events here
if (url.pathname === '/sidebar-agent/event' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
const body = await req.json();
processAgentEvent(body);
// Handle agent lifecycle events
if (body.type === 'agent_done' || body.type === 'agent_error') {
agentProcess = null;
agentStartTime = null;
currentMessage = null;
if (body.type === 'agent_done') {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_done' });
}
// Process next queued message
if (messageQueue.length > 0) {
const next = messageQueue.shift()!;
spawnClaude(next.message);
} else {
agentStatus = 'idle';
}
}
// Capture claude session ID for --resume
if (body.claudeSessionId && sidebarSession && !sidebarSession.claudeSessionId) {
sidebarSession.claudeSessionId = body.claudeSessionId;
saveSession();
}
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
// ─── Auth-required endpoints ──────────────────────────────────
if (!validateAuth(req)) {
+117 -216
View File
@@ -1,6 +1,10 @@
/**
* Sidebar Agent — watches sidebar-commands.jsonl, spawns claude -p for each
* message, streams live events back to the sidebar.
* Sidebar Agent — polls agent-queue from server, spawns claude -p for each
* message, streams live events back to the server via /sidebar-agent/event.
*
* This runs as a NON-COMPILED bun process because compiled bun binaries
* cannot posix_spawn external executables. The server writes to the queue
* file, this process reads it and spawns claude.
*
* Usage: BROWSE_BIN=/path/to/browse bun run browse/src/sidebar-agent.ts
*/
@@ -9,13 +13,15 @@ import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
const QUEUE = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-commands.jsonl');
const SERVER_URL = 'http://127.0.0.1:34567';
const POLL_MS = 1500;
const QUEUE = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
const SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '34567', 10);
const SERVER_URL = `http://127.0.0.1:${SERVER_PORT}`;
const POLL_MS = 500; // Fast polling — server already did the user-facing response
const B = process.env.BROWSE_BIN || path.resolve(__dirname, '../../.claude/skills/gstack/browse/dist/browse');
let lastLine = 0;
let authToken: string | null = null;
let isProcessing = false;
// ─── Auth ────────────────────────────────────────────────────────
@@ -31,14 +37,14 @@ async function refreshToken(): Promise<string | null> {
}
}
// ─── Event streaming to sidebar ──────────────────────────────────
// ─── Event relay to server ──────────────────────────────────────
async function sendEvent(event: Record<string, any>): Promise<void> {
if (!authToken) await refreshToken();
if (!authToken) return;
try {
await fetch(`${SERVER_URL}/sidebar-event`, {
await fetch(`${SERVER_URL}/sidebar-agent/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -51,159 +57,7 @@ async function sendEvent(event: Record<string, any>): Promise<void> {
}
}
// ─── Claude subprocess with live streaming ───────────────────────
async function askClaude(userMessage: string): Promise<void> {
// Get current page context
let pageContext = '';
try {
const statusResp = await fetch(`${SERVER_URL}/health`, { signal: AbortSignal.timeout(3000) });
if (statusResp.ok) {
const status = await statusResp.json() as any;
pageContext = `Current browser: ${status.currentUrl || 'about:blank'} (${status.tabs || 1} tabs, mode: ${status.mode})`;
}
} catch {}
const systemPrompt = [
'You are a browser assistant running in a Chrome sidebar.',
'You control a browser via the browse CLI.',
'',
`Browse binary: ${B}`,
pageContext,
'',
'Available commands (run via bash):',
` ${B} goto <url> — navigate`,
` ${B} click <@ref> — click element`,
` ${B} fill <@ref> <text> — fill input`,
` ${B} snapshot -i — get element refs`,
` ${B} text — page text`,
` ${B} screenshot — screenshot`,
` ${B} back / forward / reload`,
` ${B} status — current URL`,
'',
'Rules:',
'- Before clicking, run snapshot -i to get fresh refs.',
'- Keep responses SHORT — narrow sidebar.',
'- You can also read/write files, run git, etc.',
].join('\n');
const prompt = `${systemPrompt}\n\nUser: ${userMessage}`;
// Signal that Claude is starting
await sendEvent({ type: 'agent_start' });
return new Promise((resolve) => {
const proc = spawn('claude', [
'-p', prompt,
'--output-format', 'stream-json',
'--verbose',
'--allowedTools', 'Bash,Read,Glob,Grep',
], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
let buffer = '';
// Close stdin immediately so claude doesn't wait for input
proc.stdin.end();
proc.stdout.on('data', (data: Buffer) => {
buffer += data.toString();
const lines = buffer.split('\n');
// Keep last potentially incomplete line in buffer
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
handleStreamEvent(event);
} catch {
// Not JSON
}
}
});
proc.stderr.on('data', (data: Buffer) => {
console.error('[sidebar-agent] stderr:', data.toString().slice(0, 200));
});
proc.on('close', (code) => {
console.log(`[sidebar-agent] claude exited with code ${code}`);
// Process any remaining buffer
if (buffer.trim()) {
try {
handleStreamEvent(JSON.parse(buffer));
} catch {}
}
sendEvent({ type: 'agent_done' }).then(resolve);
});
proc.on('error', (err) => {
sendEvent({ type: 'agent_error', error: err.message }).then(resolve);
});
// Timeout after 90 seconds
setTimeout(() => {
proc.kill();
sendEvent({ type: 'agent_error', error: 'Timed out after 90s' }).then(resolve);
}, 90000);
});
}
async function handleStreamEvent(event: any): Promise<void> {
console.log(`[sidebar-agent] event: ${event.type}`, event.type === 'result' ? event.result?.slice(0, 80) : '');
// claude stream-json event types:
// - { type: "assistant", message: { content: [{ type: "text", text: "..." }, { type: "tool_use", name: "...", input: {...} }] } }
// - { type: "content_block_start", content_block: { type: "tool_use", name: "Bash", ... } }
// - { type: "content_block_delta", delta: { type: "text_delta", text: "..." } }
// - { type: "result", result: "final text", ... }
if (event.type === 'assistant' && event.message?.content) {
for (const block of event.message.content) {
if (block.type === 'tool_use') {
// Tool call starting
await sendEvent({
type: 'tool_use',
tool: block.name,
input: summarizeToolInput(block.name, block.input),
});
} else if (block.type === 'text' && block.text) {
await sendEvent({
type: 'text',
text: block.text,
});
}
}
}
if (event.type === 'content_block_start' && event.content_block) {
if (event.content_block.type === 'tool_use') {
await sendEvent({
type: 'tool_use',
tool: event.content_block.name,
input: summarizeToolInput(event.content_block.name, event.content_block.input),
});
}
}
if (event.type === 'content_block_delta' && event.delta) {
if (event.delta.type === 'text_delta' && event.delta.text) {
await sendEvent({
type: 'text_delta',
text: event.delta.text,
});
}
}
if (event.type === 'result') {
await sendEvent({
type: 'result',
text: event.result || '',
});
}
}
// ─── Claude subprocess ──────────────────────────────────────────
function shorten(str: string): string {
return str
@@ -228,49 +82,129 @@ function summarizeToolInput(tool: string, input: any): string {
try { return shorten(JSON.stringify(input)).slice(0, 60); } catch { return ''; }
}
async function handleStreamEvent(event: any): Promise<void> {
if (event.type === 'system' && event.session_id) {
// Relay claude session ID for --resume support
await sendEvent({ type: 'system', claudeSessionId: event.session_id });
}
if (event.type === 'assistant' && event.message?.content) {
for (const block of event.message.content) {
if (block.type === 'tool_use') {
await sendEvent({ type: 'tool_use', tool: block.name, input: summarizeToolInput(block.name, block.input) });
} else if (block.type === 'text' && block.text) {
await sendEvent({ type: 'text', text: block.text });
}
}
}
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
await sendEvent({ type: 'tool_use', tool: event.content_block.name, input: summarizeToolInput(event.content_block.name, event.content_block.input) });
}
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
await sendEvent({ type: 'text_delta', text: event.delta.text });
}
if (event.type === 'result') {
await sendEvent({ type: 'result', text: event.result || '' });
}
}
async function askClaude(queueEntry: any): Promise<void> {
const { prompt, args, stateFile, cwd } = queueEntry;
isProcessing = true;
await sendEvent({ type: 'agent_start' });
return new Promise((resolve) => {
// Build args fresh — don't trust --resume from queue (session may be stale)
let claudeArgs = ['-p', prompt, '--output-format', 'stream-json', '--verbose',
'--allowedTools', 'Bash,Read,Glob,Grep'];
const proc = spawn('claude', claudeArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: cwd || process.cwd(),
env: { ...process.env, BROWSE_STATE_FILE: stateFile || '' },
});
proc.stdin.end();
let buffer = '';
proc.stdout.on('data', (data: Buffer) => {
buffer += data.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try { handleStreamEvent(JSON.parse(line)); } catch {}
}
});
proc.stderr.on('data', () => {}); // Claude logs to stderr, ignore
proc.on('close', (code) => {
if (buffer.trim()) {
try { handleStreamEvent(JSON.parse(buffer)); } catch {}
}
sendEvent({ type: 'agent_done' }).then(() => {
isProcessing = false;
resolve();
});
});
proc.on('error', (err) => {
sendEvent({ type: 'agent_error', error: err.message }).then(() => {
isProcessing = false;
resolve();
});
});
// Timeout after 120 seconds
setTimeout(() => {
try { proc.kill(); } catch {}
sendEvent({ type: 'agent_error', error: 'Timed out after 120s' }).then(() => {
isProcessing = false;
resolve();
});
}, 120000);
});
}
// ─── Poll loop ───────────────────────────────────────────────────
function countLines(): number {
try {
const content = fs.readFileSync(QUEUE, 'utf-8');
return content.split('\n').filter(Boolean).length;
} catch {
return 0;
}
return fs.readFileSync(QUEUE, 'utf-8').split('\n').filter(Boolean).length;
} catch { return 0; }
}
function readLine(n: number): string | null {
try {
const lines = fs.readFileSync(QUEUE, 'utf-8').split('\n').filter(Boolean);
return lines[n - 1] || null;
} catch {
return null;
}
} catch { return null; }
}
async function poll() {
if (isProcessing) return; // One at a time — server handles queuing
const current = countLines();
if (current <= lastLine) return;
while (lastLine < current) {
while (lastLine < current && !isProcessing) {
lastLine++;
const line = readLine(lastLine);
if (!line) continue;
let message: string;
let entry: any;
try { entry = JSON.parse(line); } catch { continue; }
if (!entry.message && !entry.prompt) continue;
console.log(`[sidebar-agent] Processing: "${entry.message}"`);
try {
const parsed = JSON.parse(line);
message = parsed.message;
} catch {
continue;
}
if (!message) continue;
console.log(`[sidebar-agent] Processing: "${message}"`);
try {
await askClaude(message);
await askClaude(entry);
} catch (err) {
console.error(`[sidebar-agent] Error:`, err);
await sendEvent({ type: 'agent_error', error: String(err) });
@@ -280,31 +214,6 @@ async function poll() {
// ─── Main ────────────────────────────────────────────────────────
async function ensureStateFile(): Promise<string | null> {
// Write a state file pointing to the CDP server so claude -p's $B commands
// connect to the right browser (not a stale headless server).
try {
const resp = await fetch(`${SERVER_URL}/health`, { signal: AbortSignal.timeout(3000) });
if (!resp.ok) return null;
const data = await resp.json() as any;
if (!data.token) return null;
const stateDir = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent');
fs.mkdirSync(stateDir, { recursive: true });
const stateFile = path.join(stateDir, 'browse.json');
fs.writeFileSync(stateFile, JSON.stringify({
pid: process.pid,
port: 34567,
token: data.token,
startedAt: new Date().toISOString(),
mode: 'cdp',
}, null, 2));
return stateFile;
} catch {
return null;
}
}
async function main() {
const dir = path.dirname(QUEUE);
fs.mkdirSync(dir, { recursive: true });
@@ -313,17 +222,9 @@ async function main() {
lastLine = countLines();
await refreshToken();
// Write a state file that points claude -p at the CDP server
const stateFile = await ensureStateFile();
if (stateFile) {
// Set env so all claude -p subprocesses find the right browse server
process.env.BROWSE_STATE_FILE = stateFile;
console.log(`[sidebar-agent] State file: ${stateFile}`);
}
console.log(`[sidebar-agent] Started. Watching ${QUEUE} from line ${lastLine}`);
console.log(`[sidebar-agent] Browse binary: ${B}`);
console.log(`[sidebar-agent] Server: ${SERVER_URL}`);
console.log(`[sidebar-agent] Browse binary: ${B}`);
setInterval(poll, POLL_MS);
}