feat: sidebar agent streaming events + session state management

Enhance sidebar-agent.ts with:
- Live streaming of claude -p events (tool_use, text, result) to sidebar
- Session state file for BROWSE_STATE_FILE propagation to claude subprocess
- Improved logging (stderr, exit codes, event types)
- stdin.end() to prevent claude waiting for input
- summarizeToolInput() with path shortening for compact sidebar display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-21 16:58:41 -07:00
parent 29260246b4
commit be871b702e
+164 -56
View File
@@ -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<string | null> {
}
}
async function sendResponse(message: string): Promise<void> {
// ─── Event streaming to sidebar ──────────────────────────────────
async function sendEvent(event: Record<string, any>): Promise<void> {
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<string> {
async function askClaude(userMessage: string): Promise<void> {
// Get current page context
let pageContext = '';
try {
@@ -65,89 +66,168 @@ async function askClaude(userMessage: string): Promise<string> {
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 <url> — navigate to a URL`,
` ${B} click <@ref> — click an element by ref`,
` ${B} fill <@ref> <text> — fill an input`,
` ${B} snapshot -i — get interactive element refs`,
` ${B} text — get page text content`,
` ${B} screenshot — take a screenshot`,
` ${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 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<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 || '',
});
}
}
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<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() {
// 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);
}