mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 21:25:27 +02:00
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:
+164
-56
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user