diff --git a/browse/src/sidebar-agent.ts b/browse/src/sidebar-agent.ts index 031cc588..40f2e8c0 100644 --- a/browse/src/sidebar-agent.ts +++ b/browse/src/sidebar-agent.ts @@ -1,8 +1,8 @@ /** * Sidebar Agent — watches sidebar-commands.jsonl, spawns claude -p for each - * message, streams responses back to the sidebar via /sidebar-response. + * message, streams live events back to the sidebar. * - * Usage: bun run browse/src/sidebar-agent.ts + * Usage: BROWSE_BIN=/path/to/browse bun run browse/src/sidebar-agent.ts */ import { spawn } from 'child_process'; @@ -10,7 +10,6 @@ import * as fs from 'fs'; import * as path from 'path'; const QUEUE = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-commands.jsonl'); -const CHAT = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-chat.jsonl'); const SERVER_URL = 'http://127.0.0.1:34567'; const POLL_MS = 1500; const B = process.env.BROWSE_BIN || path.resolve(__dirname, '../../.claude/skills/gstack/browse/dist/browse'); @@ -32,27 +31,29 @@ async function refreshToken(): Promise { } } -async function sendResponse(message: string): Promise { +// ─── Event streaming to sidebar ────────────────────────────────── + +async function sendEvent(event: Record): Promise { if (!authToken) await refreshToken(); if (!authToken) return; try { - await fetch(`${SERVER_URL}/sidebar-response`, { + await fetch(`${SERVER_URL}/sidebar-event`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, }, - body: JSON.stringify({ message }), + body: JSON.stringify(event), }); } catch (err) { - console.error('[sidebar-agent] Failed to send response:', err); + console.error('[sidebar-agent] Failed to send event:', err); } } -// ─── Claude subprocess ─────────────────────────────────────────── +// ─── Claude subprocess with live streaming ─────────────────────── -async function askClaude(userMessage: string): Promise { +async function askClaude(userMessage: string): Promise { // Get current page context let pageContext = ''; try { @@ -65,89 +66,168 @@ async function askClaude(userMessage: string): Promise { const systemPrompt = [ 'You are a browser assistant running in a Chrome sidebar.', - 'You control a headless browser via the browse CLI.', + 'You control a browser via the browse CLI.', '', `Browse binary: ${B}`, - `${pageContext}`, + pageContext, '', 'Available commands (run via bash):', - ` ${B} goto — navigate to a URL`, - ` ${B} click <@ref> — click an element by ref`, - ` ${B} fill <@ref> — fill an input`, - ` ${B} snapshot -i — get interactive element refs`, - ` ${B} text — get page text content`, - ` ${B} screenshot — take a screenshot`, + ` ${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 and tab info`, + ` ${B} status — current URL`, '', - 'IMPORTANT:', - '- Before clicking, always run snapshot -i first to get fresh refs.', - '- Keep responses SHORT — they show in a narrow sidebar chat bubble.', - '- Use markdown sparingly. No headers. Brief bullet points are ok.', - '- If the user asks about page content, use `text` command.', - '- You can also read/write files, run git commands, etc.', + '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 says: ${userMessage}`; + const prompt = `${systemPrompt}\n\nUser: ${userMessage}`; - return new Promise((resolve, reject) => { - const chunks: string[] = []; - let fullText = ''; + // 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 currentAssistantText = ''; + let buffer = ''; + + // Close stdin immediately so claude doesn't wait for input + proc.stdin.end(); proc.stdout.on('data', (data: Buffer) => { - const lines = data.toString().split('\n').filter(Boolean); + 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); - // Collect assistant text from the stream - if (event.type === 'assistant' && event.message?.content) { - for (const block of event.message.content) { - if (block.type === 'text') { - currentAssistantText = block.text; - } - } - } - // Result event has the final text - if (event.type === 'result' && event.result) { - fullText = event.result; - } + handleStreamEvent(event); } catch { - // Not JSON, skip + // Not JSON } } }); proc.stderr.on('data', (data: Buffer) => { - // Claude logs to stderr, ignore + console.error('[sidebar-agent] stderr:', data.toString().slice(0, 200)); }); proc.on('close', (code) => { - resolve(fullText || currentAssistantText || '(no response)'); + 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) => { - reject(err); + sendEvent({ type: 'agent_error', error: err.message }).then(resolve); }); - // Timeout after 60 seconds + // Timeout after 90 seconds setTimeout(() => { proc.kill(); - resolve(fullText || currentAssistantText || '(timed out)'); - }, 60000); + 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 || '', + }); + } +} + +function shorten(str: string): string { + return str + .replace(new RegExp(B.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '$B') + .replace(/\/Users\/[^/]+/g, '~') + .replace(/\/conductor\/workspaces\/[^/]+\/[^/]+/g, '') + .replace(/\.claude\/skills\/gstack\//g, '') + .replace(/browse\/dist\/browse/g, '$B'); +} + +function summarizeToolInput(tool: string, input: any): string { + if (!input) return ''; + if (tool === 'Bash' && input.command) { + let cmd = shorten(input.command); + return cmd.length > 80 ? cmd.slice(0, 80) + '…' : cmd; + } + if (tool === 'Read' && input.file_path) return shorten(input.file_path); + if (tool === 'Edit' && input.file_path) return shorten(input.file_path); + if (tool === 'Write' && input.file_path) return shorten(input.file_path); + if (tool === 'Grep' && input.pattern) return `/${input.pattern}/`; + if (tool === 'Glob' && input.pattern) return input.pattern; + try { return shorten(JSON.stringify(input)).slice(0, 60); } catch { return ''; } +} + // ─── Poll loop ─────────────────────────────────────────────────── function countLines(): number { @@ -190,33 +270,61 @@ async function poll() { console.log(`[sidebar-agent] Processing: "${message}"`); try { - const response = await askClaude(message); - console.log(`[sidebar-agent] Response: "${response.slice(0, 100)}..."`); - await sendResponse(response); + await askClaude(message); } catch (err) { console.error(`[sidebar-agent] Error:`, err); - await sendResponse(`Error: ${err instanceof Error ? err.message : String(err)}`); + await sendEvent({ type: 'agent_error', error: String(err) }); } } } // ─── 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() { - // Ensure queue file exists const dir = path.dirname(QUEUE); fs.mkdirSync(dir, { recursive: true }); if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, ''); - // Start from current end of file 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}`); - // Poll loop setInterval(poll, POLL_MS); }