mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-19 19:02:29 +08:00
feat: per-tab agent isolation via BROWSE_TAB environment variable
Prevents parallel sidebar agents from interfering with each other's tab context. Three-layer fix: - sidebar-agent.ts: passes BROWSE_TAB=<tabId> env var to each claude process, per-tab processing set allows concurrent agents across tabs - cli.ts: reads process.env.BROWSE_TAB and includes tabId in command request body - server.ts: handleCommand() temporarily switches activeTabId when tabId is present, restores after command completes (safe: Bun event loop is single-threaded) Also: per-tab agent state (TabAgentState map), per-tab message queuing, per-tab chat buffers, verbose streaming narration, stop button endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,12 +16,13 @@ import * as path from 'path';
|
||||
const QUEUE = process.env.SIDEBAR_QUEUE_PATH || 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 POLL_MS = 200; // 200ms poll — keeps time-to-first-token low
|
||||
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;
|
||||
// Per-tab processing — each tab can run its own agent concurrently
|
||||
const processingTabs = new Set<number>();
|
||||
|
||||
// ─── File drop relay ──────────────────────────────────────────
|
||||
|
||||
@@ -80,7 +81,7 @@ async function refreshToken(): Promise<string | null> {
|
||||
|
||||
// ─── Event relay to server ──────────────────────────────────────
|
||||
|
||||
async function sendEvent(event: Record<string, any>): Promise<void> {
|
||||
async function sendEvent(event: Record<string, any>, tabId?: number): Promise<void> {
|
||||
if (!authToken) await refreshToken();
|
||||
if (!authToken) return;
|
||||
|
||||
@@ -91,7 +92,7 @@ async function sendEvent(event: Record<string, any>): Promise<void> {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
body: JSON.stringify({ ...event, tabId: tabId ?? null }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[sidebar-agent] Failed to send event:', err);
|
||||
@@ -109,54 +110,119 @@ function shorten(str: string): string {
|
||||
.replace(/browse\/dist\/browse/g, '$B');
|
||||
}
|
||||
|
||||
function summarizeToolInput(tool: string, input: any): string {
|
||||
function describeToolCall(tool: string, input: any): string {
|
||||
if (!input) return '';
|
||||
|
||||
// For Bash commands, generate a plain-English description
|
||||
if (tool === 'Bash' && input.command) {
|
||||
let cmd = shorten(input.command);
|
||||
return cmd.length > 80 ? cmd.slice(0, 80) + '…' : cmd;
|
||||
const cmd = input.command;
|
||||
|
||||
// Browse binary commands — the most common case
|
||||
const browseMatch = cmd.match(/\$B\s+(\w+)|browse[^\s]*\s+(\w+)/);
|
||||
if (browseMatch) {
|
||||
const browseCmd = browseMatch[1] || browseMatch[2];
|
||||
const args = cmd.split(/\s+/).slice(2).join(' ');
|
||||
switch (browseCmd) {
|
||||
case 'goto': return `Opening ${args.replace(/['"]/g, '')}`;
|
||||
case 'snapshot': return args.includes('-i') ? 'Scanning for interactive elements' : args.includes('-D') ? 'Checking what changed' : 'Taking a snapshot of the page';
|
||||
case 'screenshot': return `Saving screenshot${args ? ` to ${shorten(args)}` : ''}`;
|
||||
case 'click': return `Clicking ${args}`;
|
||||
case 'fill': { const parts = args.split(/\s+/); return `Typing "${parts.slice(1).join(' ')}" into ${parts[0]}`; }
|
||||
case 'text': return 'Reading page text';
|
||||
case 'html': return args ? `Reading HTML of ${args}` : 'Reading full page HTML';
|
||||
case 'links': return 'Finding all links on the page';
|
||||
case 'forms': return 'Looking for forms';
|
||||
case 'console': return 'Checking browser console for errors';
|
||||
case 'network': return 'Checking network requests';
|
||||
case 'url': return 'Checking current URL';
|
||||
case 'back': return 'Going back';
|
||||
case 'forward': return 'Going forward';
|
||||
case 'reload': return 'Reloading the page';
|
||||
case 'scroll': return args ? `Scrolling to ${args}` : 'Scrolling down';
|
||||
case 'wait': return `Waiting for ${args}`;
|
||||
case 'inspect': return args ? `Inspecting CSS of ${args}` : 'Getting CSS for last picked element';
|
||||
case 'style': return `Changing CSS: ${args}`;
|
||||
case 'cleanup': return 'Removing page clutter (ads, popups, banners)';
|
||||
case 'prettyscreenshot': return 'Taking a clean screenshot';
|
||||
case 'css': return `Checking CSS property: ${args}`;
|
||||
case 'is': return `Checking if element is ${args}`;
|
||||
case 'diff': return `Comparing ${args}`;
|
||||
case 'responsive': return 'Taking screenshots at mobile, tablet, and desktop sizes';
|
||||
case 'status': return 'Checking browser status';
|
||||
case 'tabs': return 'Listing open tabs';
|
||||
case 'focus': return 'Bringing browser to front';
|
||||
case 'select': return `Selecting option in ${args}`;
|
||||
case 'hover': return `Hovering over ${args}`;
|
||||
case 'viewport': return `Setting viewport to ${args}`;
|
||||
case 'upload': return `Uploading file to ${args.split(/\s+/)[0]}`;
|
||||
default: return `Running browse ${browseCmd} ${args}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Non-browse bash commands
|
||||
if (cmd.includes('git ')) return `Running: ${shorten(cmd)}`;
|
||||
let short = shorten(cmd);
|
||||
return short.length > 100 ? short.slice(0, 100) + '…' : short;
|
||||
}
|
||||
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 ''; }
|
||||
|
||||
if (tool === 'Read' && input.file_path) return `Reading ${shorten(input.file_path)}`;
|
||||
if (tool === 'Edit' && input.file_path) return `Editing ${shorten(input.file_path)}`;
|
||||
if (tool === 'Write' && input.file_path) return `Writing ${shorten(input.file_path)}`;
|
||||
if (tool === 'Grep' && input.pattern) return `Searching for "${input.pattern}"`;
|
||||
if (tool === 'Glob' && input.pattern) return `Finding files matching ${input.pattern}`;
|
||||
try { return shorten(JSON.stringify(input)).slice(0, 80); } catch { return ''; }
|
||||
}
|
||||
|
||||
async function handleStreamEvent(event: any): Promise<void> {
|
||||
// Keep the old name as an alias for backward compat
|
||||
function summarizeToolInput(tool: string, input: any): string {
|
||||
return describeToolCall(tool, input);
|
||||
}
|
||||
|
||||
async function handleStreamEvent(event: any, tabId?: number): Promise<void> {
|
||||
if (event.type === 'system' && event.session_id) {
|
||||
// Relay claude session ID for --resume support
|
||||
await sendEvent({ type: 'system', claudeSessionId: event.session_id });
|
||||
await sendEvent({ type: 'system', claudeSessionId: event.session_id }, tabId);
|
||||
}
|
||||
|
||||
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) });
|
||||
await sendEvent({ type: 'tool_use', tool: block.name, input: summarizeToolInput(block.name, block.input) }, tabId);
|
||||
} else if (block.type === 'text' && block.text) {
|
||||
await sendEvent({ type: 'text', text: block.text });
|
||||
await sendEvent({ type: 'text', text: block.text }, tabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) });
|
||||
await sendEvent({ type: 'tool_use', tool: event.content_block.name, input: summarizeToolInput(event.content_block.name, event.content_block.input) }, tabId);
|
||||
}
|
||||
|
||||
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
|
||||
await sendEvent({ type: 'text_delta', text: event.delta.text });
|
||||
await sendEvent({ type: 'text_delta', text: event.delta.text }, tabId);
|
||||
}
|
||||
|
||||
// Relay tool results so the sidebar can show what happened
|
||||
if (event.type === 'content_block_delta' && event.delta?.type === 'input_json_delta') {
|
||||
// Tool input streaming — skip, we already announced the tool
|
||||
}
|
||||
|
||||
if (event.type === 'result') {
|
||||
await sendEvent({ type: 'result', text: event.result || '' });
|
||||
await sendEvent({ type: 'result', text: event.result || '' }, tabId);
|
||||
}
|
||||
|
||||
// Tool result events — summarize and relay
|
||||
if (event.type === 'tool_result' || (event.type === 'assistant' && event.message?.content)) {
|
||||
// Tool results come in the next assistant turn — handled above
|
||||
}
|
||||
}
|
||||
|
||||
async function askClaude(queueEntry: any): Promise<void> {
|
||||
const { prompt, args, stateFile, cwd } = queueEntry;
|
||||
const { prompt, args, stateFile, cwd, tabId } = queueEntry;
|
||||
const tid = tabId ?? 0;
|
||||
|
||||
isProcessing = true;
|
||||
await sendEvent({ type: 'agent_start' });
|
||||
processingTabs.add(tid);
|
||||
await sendEvent({ type: 'agent_start' }, tid);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Build args fresh — don't trust --resume from queue (session may be stale)
|
||||
@@ -170,7 +236,13 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
const proc = spawn('claude', claudeArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: effectiveCwd,
|
||||
env: { ...process.env, BROWSE_STATE_FILE: stateFile || '' },
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_STATE_FILE: stateFile || '',
|
||||
// Pin this agent to its tab — prevents cross-tab interference
|
||||
// when multiple agents run simultaneously
|
||||
BROWSE_TAB: String(tid),
|
||||
},
|
||||
});
|
||||
|
||||
proc.stdin.end();
|
||||
@@ -183,7 +255,7 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try { handleStreamEvent(JSON.parse(line)); } catch {}
|
||||
try { handleStreamEvent(JSON.parse(line), tid); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -191,17 +263,17 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (buffer.trim()) {
|
||||
try { handleStreamEvent(JSON.parse(buffer)); } catch {}
|
||||
try { handleStreamEvent(JSON.parse(buffer), tid); } catch {}
|
||||
}
|
||||
sendEvent({ type: 'agent_done' }).then(() => {
|
||||
isProcessing = false;
|
||||
sendEvent({ type: 'agent_done' }, tid).then(() => {
|
||||
processingTabs.delete(tid);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
sendEvent({ type: 'agent_error', error: err.message }).then(() => {
|
||||
isProcessing = false;
|
||||
sendEvent({ type: 'agent_error', error: err.message }, tid).then(() => {
|
||||
processingTabs.delete(tid);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -210,8 +282,8 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
const timeoutMs = parseInt(process.env.SIDEBAR_AGENT_TIMEOUT || '300000', 10);
|
||||
setTimeout(() => {
|
||||
try { proc.kill(); } catch {}
|
||||
sendEvent({ type: 'agent_error', error: `Timed out after ${timeoutMs / 1000}s` }).then(() => {
|
||||
isProcessing = false;
|
||||
sendEvent({ type: 'agent_error', error: `Timed out after ${timeoutMs / 1000}s` }, tid).then(() => {
|
||||
processingTabs.delete(tid);
|
||||
resolve();
|
||||
});
|
||||
}, timeoutMs);
|
||||
@@ -234,12 +306,10 @@ function readLine(n: number): string | null {
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (isProcessing) return; // One at a time — server handles queuing
|
||||
|
||||
const current = countLines();
|
||||
if (current <= lastLine) return;
|
||||
|
||||
while (lastLine < current && !isProcessing) {
|
||||
while (lastLine < current) {
|
||||
lastLine++;
|
||||
const line = readLine(lastLine);
|
||||
if (!line) continue;
|
||||
@@ -248,15 +318,18 @@ async function poll() {
|
||||
try { entry = JSON.parse(line); } catch { continue; }
|
||||
if (!entry.message && !entry.prompt) continue;
|
||||
|
||||
console.log(`[sidebar-agent] Processing: "${entry.message}"`);
|
||||
const tid = entry.tabId ?? 0;
|
||||
// Skip if this tab already has an agent running — server queues per-tab
|
||||
if (processingTabs.has(tid)) continue;
|
||||
|
||||
console.log(`[sidebar-agent] Processing tab ${tid}: "${entry.message}"`);
|
||||
// Write to inbox so workspace agent can pick it up
|
||||
writeToInbox(entry.message || entry.prompt, entry.pageUrl, entry.sessionId);
|
||||
try {
|
||||
await askClaude(entry);
|
||||
} catch (err) {
|
||||
console.error(`[sidebar-agent] Error:`, err);
|
||||
await sendEvent({ type: 'agent_error', error: String(err) });
|
||||
}
|
||||
// Fire and forget — each tab's agent runs concurrently
|
||||
askClaude(entry).catch((err) => {
|
||||
console.error(`[sidebar-agent] Error on tab ${tid}:`, err);
|
||||
sendEvent({ type: 'agent_error', error: String(err) }, tid);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user