mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-21 12:18:24 +08:00
feat: sidebar agent — Claude-powered chat backend via file queue
Add /sidebar-command, /sidebar-response, and /sidebar-chat endpoints to the browse server. sidebar-agent.ts watches the command queue file, spawns claude -p with browse context for each message, and streams responses back to the sidebar chat. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -365,6 +365,7 @@ async function start() {
|
|||||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||||
tabs: browserManager.getTabCount(),
|
tabs: browserManager.getTabCount(),
|
||||||
currentUrl: browserManager.getCurrentUrl(),
|
currentUrl: browserManager.getCurrentUrl(),
|
||||||
|
token: AUTH_TOKEN, // Extension uses this to POST /command
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -468,6 +469,65 @@ async function start() {
|
|||||||
return handleCommand(body);
|
return handleCommand(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar → Claude Code command queue (file-based message passing)
|
||||||
|
if (url.pathname === '/sidebar-command' && req.method === 'POST') {
|
||||||
|
const body = await req.json();
|
||||||
|
const msg = body.message?.trim();
|
||||||
|
if (!msg) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Empty message' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
|
||||||
|
fs.mkdirSync(gstackDir, { recursive: true });
|
||||||
|
const entry = JSON.stringify({ ts: new Date().toISOString(), role: 'user', message: msg }) + '\n';
|
||||||
|
fs.appendFileSync(path.join(gstackDir, 'sidebar-commands.jsonl'), entry);
|
||||||
|
fs.appendFileSync(path.join(gstackDir, 'sidebar-chat.jsonl'), entry);
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude Code → Sidebar response (also file-based)
|
||||||
|
if (url.pathname === '/sidebar-response' && req.method === 'POST') {
|
||||||
|
const body = await req.json();
|
||||||
|
const msg = body.message?.trim();
|
||||||
|
if (!msg) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Empty message' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
|
||||||
|
fs.mkdirSync(gstackDir, { recursive: true });
|
||||||
|
const entry = JSON.stringify({ ts: new Date().toISOString(), role: 'assistant', message: msg }) + '\n';
|
||||||
|
fs.appendFileSync(path.join(gstackDir, 'sidebar-chat.jsonl'), entry);
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar chat history + polling
|
||||||
|
if (url.pathname === '/sidebar-chat') {
|
||||||
|
const afterParam = url.searchParams.get('after') || '0';
|
||||||
|
const afterLine = parseInt(afterParam, 10);
|
||||||
|
const chatFile = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-chat.jsonl');
|
||||||
|
let lines: string[] = [];
|
||||||
|
try {
|
||||||
|
lines = fs.readFileSync(chatFile, 'utf-8').split('\n').filter(Boolean);
|
||||||
|
} catch {}
|
||||||
|
const entries = lines.slice(afterLine).map((line: string, i: number) => {
|
||||||
|
try { return { ...JSON.parse(line), id: afterLine + i }; } catch { return null; }
|
||||||
|
}).filter(Boolean);
|
||||||
|
return new Response(JSON.stringify({ entries, total: lines.length }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
223
browse/src/sidebar-agent.ts
Normal file
223
browse/src/sidebar-agent.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Sidebar Agent — watches sidebar-commands.jsonl, spawns claude -p for each
|
||||||
|
* message, streams responses back to the sidebar via /sidebar-response.
|
||||||
|
*
|
||||||
|
* Usage: bun run browse/src/sidebar-agent.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 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');
|
||||||
|
|
||||||
|
let lastLine = 0;
|
||||||
|
let authToken: string | null = null;
|
||||||
|
|
||||||
|
// ─── Auth ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function refreshToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SERVER_URL}/health`, { signal: AbortSignal.timeout(3000) });
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
authToken = data.token || null;
|
||||||
|
return authToken;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendResponse(message: string): Promise<void> {
|
||||||
|
if (!authToken) await refreshToken();
|
||||||
|
if (!authToken) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${SERVER_URL}/sidebar-response`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sidebar-agent] Failed to send response:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Claude subprocess ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async function askClaude(userMessage: string): Promise<string> {
|
||||||
|
// 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 headless browser via the browse CLI.',
|
||||||
|
'',
|
||||||
|
`Browse binary: ${B}`,
|
||||||
|
`${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} back / forward / reload`,
|
||||||
|
` ${B} status — current URL and tab info`,
|
||||||
|
'',
|
||||||
|
'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.',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const prompt = `${systemPrompt}\n\nUser says: ${userMessage}`;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let fullText = '';
|
||||||
|
|
||||||
|
const proc = spawn('claude', [
|
||||||
|
'-p', prompt,
|
||||||
|
'--output-format', 'stream-json',
|
||||||
|
'--verbose',
|
||||||
|
], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentAssistantText = '';
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data: Buffer) => {
|
||||||
|
const lines = data.toString().split('\n').filter(Boolean);
|
||||||
|
for (const line of lines) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on('data', (data: Buffer) => {
|
||||||
|
// Claude logs to stderr, ignore
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
resolve(fullText || currentAssistantText || '(no response)');
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout after 60 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
proc.kill();
|
||||||
|
resolve(fullText || currentAssistantText || '(timed out)');
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Poll loop ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function countLines(): number {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(QUEUE, 'utf-8');
|
||||||
|
return content.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
const current = countLines();
|
||||||
|
if (current <= lastLine) return;
|
||||||
|
|
||||||
|
while (lastLine < current) {
|
||||||
|
lastLine++;
|
||||||
|
const line = readLine(lastLine);
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
let message: string;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
message = parsed.message;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message) continue;
|
||||||
|
|
||||||
|
console.log(`[sidebar-agent] Processing: "${message}"`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await askClaude(message);
|
||||||
|
console.log(`[sidebar-agent] Response: "${response.slice(0, 100)}..."`);
|
||||||
|
await sendResponse(response);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[sidebar-agent] Error:`, err);
|
||||||
|
await sendResponse(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
Reference in New Issue
Block a user