diff --git a/browse/src/cli.ts b/browse/src/cli.ts index fc89fbe9..e27ad44a 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -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); diff --git a/browse/src/server.ts b/browse/src/server.ts index d971c2f6..5ccb6413 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -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)) { diff --git a/browse/src/sidebar-agent.ts b/browse/src/sidebar-agent.ts index 40f2e8c0..16628fb5 100644 --- a/browse/src/sidebar-agent.ts +++ b/browse/src/sidebar-agent.ts @@ -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 { } } -// ─── Event streaming to sidebar ────────────────────────────────── +// ─── Event relay to server ────────────────────────────────────── async function sendEvent(event: Record): Promise { 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): Promise { } } -// ─── Claude subprocess with live streaming ─────────────────────── - -async function askClaude(userMessage: string): Promise { - // 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 — navigate`, - ` ${B} click <@ref> — click element`, - ` ${B} fill <@ref> — 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 { - 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 { + 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 { + 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 { - // 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); }