Merge remote-tracking branch 'origin/main' into garrytan/recover-voice-fix

This commit is contained in:
Garry Tan
2026-03-26 12:16:58 -06:00
63 changed files with 8028 additions and 285 deletions
+7
View File
@@ -480,6 +480,9 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| Command | Description |
|---------|-------------|
| `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] |
| `frame <sel|@ref|--name n|--url pattern|main>` | Switch to iframe context (or main to return) |
| `inbox [--clear]` | List messages from sidebar scout inbox |
| `watch [stop]` | Passive observation — periodic snapshots while user browses |
### Tabs
| Command | Description |
@@ -492,8 +495,12 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
### Server
| Command | Description |
|---------|-------------|
| `connect` | Launch headed Chromium with Chrome extension |
| `disconnect` | Disconnect headed browser, return to headless mode |
| `focus [@ref]` | Bring headed browser window to foreground (macOS) |
| `handoff [message]` | Open visible Chrome at current page for user takeover |
| `restart` | Restart server |
| `resume` | Re-snapshot after user takeover, return control to AI |
| `state save|load <name>` | Save/load browser state (cookies + URLs) |
| `status` | Health check |
| `stop` | Shutdown server |
+208
View File
@@ -0,0 +1,208 @@
/**
* Activity streaming — real-time feed of browse commands for the Chrome extension Side Panel
*
* Architecture:
* handleCommand() ──► emitActivity(command_start)
* ──► emitActivity(command_end)
* wirePageEvents() ──► emitActivity(navigation)
*
* GET /activity/stream?after=ID ──► SSE via ReadableStream
* GET /activity/history?limit=N ──► REST fallback
*
* Privacy: filterArgs() redacts passwords, auth tokens, and sensitive query params.
* Backpressure: subscribers notified via queueMicrotask (never blocks command path).
* Gap detection: client sends ?after=ID, server detects if ring buffer overflowed.
*/
import { CircularBuffer } from './buffers';
// ─── Types ──────────────────────────────────────────────────────
export interface ActivityEntry {
id: number;
timestamp: number;
type: 'command_start' | 'command_end' | 'navigation' | 'error';
command?: string;
args?: string[];
url?: string;
duration?: number;
status?: 'ok' | 'error';
error?: string;
result?: string;
tabs?: number;
mode?: string;
}
// ─── Buffer & Subscribers ───────────────────────────────────────
const BUFFER_CAPACITY = 1000;
const activityBuffer = new CircularBuffer<ActivityEntry>(BUFFER_CAPACITY);
let nextId = 1;
type ActivitySubscriber = (entry: ActivityEntry) => void;
const subscribers = new Set<ActivitySubscriber>();
// ─── Privacy Filtering ─────────────────────────────────────────
const SENSITIVE_COMMANDS = new Set(['fill', 'type', 'cookie', 'header']);
const SENSITIVE_PARAM_PATTERN = /\b(password|token|secret|key|auth|bearer|api[_-]?key)\b/i;
/**
* Redact sensitive data from command args before streaming.
*/
export function filterArgs(command: string, args: string[]): string[] {
if (!args || args.length === 0) return args;
// fill: redact the value (last arg) for password-type fields
if (command === 'fill' && args.length >= 2) {
const selector = args[0];
// If the selector suggests a password field, redact the value
if (/password|passwd|secret|token/i.test(selector)) {
return [selector, '[REDACTED]'];
}
return args;
}
// header: redact Authorization and other sensitive headers
if (command === 'header' && args.length >= 1) {
const headerLine = args[0];
if (/^(authorization|x-api-key|cookie|set-cookie)/i.test(headerLine)) {
const colonIdx = headerLine.indexOf(':');
if (colonIdx > 0) {
return [headerLine.substring(0, colonIdx + 1) + '[REDACTED]'];
}
}
return args;
}
// cookie: redact cookie values
if (command === 'cookie' && args.length >= 1) {
const cookieStr = args[0];
const eqIdx = cookieStr.indexOf('=');
if (eqIdx > 0) {
return [cookieStr.substring(0, eqIdx + 1) + '[REDACTED]'];
}
return args;
}
// type: always redact (could be a password field)
if (command === 'type') {
return ['[REDACTED]'];
}
// URL args: redact sensitive query params
return args.map(arg => {
if (arg.startsWith('http://') || arg.startsWith('https://')) {
try {
const url = new URL(arg);
let redacted = false;
for (const key of url.searchParams.keys()) {
if (SENSITIVE_PARAM_PATTERN.test(key)) {
url.searchParams.set(key, '[REDACTED]');
redacted = true;
}
}
return redacted ? url.toString() : arg;
} catch {
return arg;
}
}
return arg;
});
}
/**
* Truncate result text for streaming (max 200 chars).
*/
function truncateResult(result: string | undefined): string | undefined {
if (!result) return undefined;
if (result.length <= 200) return result;
return result.substring(0, 200) + '...';
}
// ─── Public API ─────────────────────────────────────────────────
/**
* Emit an activity event. Backpressure-safe: subscribers notified asynchronously.
*/
export function emitActivity(entry: Omit<ActivityEntry, 'id' | 'timestamp'>): ActivityEntry {
const full: ActivityEntry = {
...entry,
id: nextId++,
timestamp: Date.now(),
args: entry.args ? filterArgs(entry.command || '', entry.args) : undefined,
result: truncateResult(entry.result),
};
activityBuffer.push(full);
// Notify subscribers asynchronously — never block the command path
for (const notify of subscribers) {
queueMicrotask(() => {
try { notify(full); } catch { /* subscriber error — don't crash */ }
});
}
return full;
}
/**
* Subscribe to live activity events. Returns unsubscribe function.
*/
export function subscribe(fn: ActivitySubscriber): () => void {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
/**
* Get recent activity entries after the given cursor ID.
* Returns entries and gap info if the buffer has overflowed.
*/
export function getActivityAfter(afterId: number): {
entries: ActivityEntry[];
gap: boolean;
gapFrom?: number;
availableFrom?: number;
totalAdded: number;
} {
const total = activityBuffer.totalAdded;
const allEntries = activityBuffer.toArray();
if (afterId === 0) {
return { entries: allEntries, gap: false, totalAdded: total };
}
// Check for gap: if afterId is too old and has been evicted
const oldestId = allEntries.length > 0 ? allEntries[0].id : nextId;
if (afterId < oldestId) {
return {
entries: allEntries,
gap: true,
gapFrom: afterId + 1,
availableFrom: oldestId,
totalAdded: total,
};
}
// Filter to entries after the cursor
const filtered = allEntries.filter(e => e.id > afterId);
return { entries: filtered, gap: false, totalAdded: total };
}
/**
* Get the N most recent activity entries.
*/
export function getActivityHistory(limit: number = 50): {
entries: ActivityEntry[];
totalAdded: number;
} {
const allEntries = activityBuffer.toArray();
const sliced = limit < allEntries.length ? allEntries.slice(-limit) : allEntries;
return { entries: sliced, totalAdded: activityBuffer.totalAdded };
}
/**
* Get subscriber count (for debugging/health).
*/
export function getSubscriberCount(): number {
return subscribers.size;
}
+302 -38
View File
@@ -61,6 +61,88 @@ export class BrowserManager {
private isHeaded: boolean = false;
private consecutiveFailures: number = 0;
// ─── Watch Mode ─────────────────────────────────────────
private watching = false;
public watchInterval: ReturnType<typeof setInterval> | null = null;
private watchSnapshots: string[] = [];
private watchStartTime: number = 0;
// ─── Headed State ────────────────────────────────────────
private connectionMode: 'launched' | 'headed' = 'launched';
private intentionalDisconnect = false;
getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; }
// ─── Watch Mode Methods ─────────────────────────────────
isWatching(): boolean { return this.watching; }
startWatch(): void {
this.watching = true;
this.watchSnapshots = [];
this.watchStartTime = Date.now();
}
stopWatch(): { snapshots: string[]; duration: number } {
this.watching = false;
if (this.watchInterval) {
clearInterval(this.watchInterval);
this.watchInterval = null;
}
const snapshots = this.watchSnapshots;
const duration = Date.now() - this.watchStartTime;
this.watchSnapshots = [];
this.watchStartTime = 0;
return { snapshots, duration };
}
addWatchSnapshot(snapshot: string): void {
this.watchSnapshots.push(snapshot);
}
/**
* Find the gstack Chrome extension directory.
* Checks: repo root /extension, global install, dev install.
*/
private findExtensionPath(): string | null {
const fs = require('fs');
const path = require('path');
const candidates = [
// Relative to this source file (dev mode: browse/src/ -> ../../extension)
path.resolve(__dirname, '..', '..', 'extension'),
// Global gstack install
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'extension'),
// Git repo root (detected via BROWSE_STATE_FILE location)
(() => {
const stateFile = process.env.BROWSE_STATE_FILE || '';
if (stateFile) {
const repoRoot = path.resolve(path.dirname(stateFile), '..');
return path.join(repoRoot, '.claude', 'skills', 'gstack', 'extension');
}
return '';
})(),
].filter(Boolean);
for (const candidate of candidates) {
try {
if (fs.existsSync(path.join(candidate, 'manifest.json'))) {
return candidate;
}
} catch {}
}
return null;
}
/**
* Get the ref map for external consumers (e.g., /refs endpoint).
*/
getRefMap(): Array<{ ref: string; role: string; name: string }> {
const refs: Array<{ ref: string; role: string; name: string }> = [];
for (const [ref, entry] of this.refMap) {
refs.push({ ref, role: entry.role, name: entry.name });
}
return refs;
}
async launch() {
// ─── Extension Support ────────────────────────────────────
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
@@ -119,15 +201,140 @@ export class BrowserManager {
await this.newTab();
}
async close() {
// ─── Headed Mode ─────────────────────────────────────────────
/**
* Launch Playwright's bundled Chromium in headed mode with the gstack
* Chrome extension auto-loaded. Uses launchPersistentContext() which
* is required for extension loading (launch() + newContext() can't
* load extensions).
*
* The browser launches headed with a visible window — the user sees
* every action Claude takes in real time.
*/
async launchHeaded(): Promise<void> {
// Clear old state before repopulating
this.pages.clear();
this.refMap.clear();
this.nextTabId = 1;
// Find the gstack extension directory for auto-loading
const extensionPath = this.findExtensionPath();
const launchArgs = ['--hide-crash-restore-bubble'];
if (extensionPath) {
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
launchArgs.push(`--load-extension=${extensionPath}`);
}
// Launch headed Chromium via Playwright's persistent context.
// Extensions REQUIRE launchPersistentContext (not launch + newContext).
// Real Chrome (executablePath/channel) silently blocks --load-extension,
// so we use Playwright's bundled Chromium which reliably loads extensions.
const fs = require('fs');
const path = require('path');
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
fs.mkdirSync(userDataDir, { recursive: true });
this.context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: launchArgs,
viewport: null, // Use browser's default viewport (real window size)
// Playwright adds flags that block extension loading
ignoreDefaultArgs: [
'--disable-extensions',
'--disable-component-extensions-with-background-pages',
],
});
this.browser = this.context.browser();
this.connectionMode = 'headed';
this.intentionalDisconnect = false;
// Inject visual indicator — subtle top-edge amber gradient
// Extension's content script handles the floating pill
const indicatorScript = () => {
const injectIndicator = () => {
if (document.getElementById('gstack-ctrl')) return;
const topLine = document.createElement('div');
topLine.id = 'gstack-ctrl';
topLine.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg, #F59E0B, #FBBF24, #F59E0B);
background-size: 200% 100%;
animation: gstack-shimmer 3s linear infinite;
pointer-events: none; z-index: 2147483647;
opacity: 0.8;
`;
const style = document.createElement('style');
style.textContent = `
@keyframes gstack-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
#gstack-ctrl { animation: none !important; }
}
`;
document.documentElement.appendChild(style);
document.documentElement.appendChild(topLine);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectIndicator);
} else {
injectIndicator();
}
};
await this.context.addInitScript(indicatorScript);
// Persistent context opens a default page — adopt it instead of creating a new one
const existingPages = this.context.pages();
if (existingPages.length > 0) {
const page = existingPages[0];
const id = this.nextTabId++;
this.pages.set(id, page);
this.activeTabId = id;
this.wirePageEvents(page);
// Inject indicator on restored page (addInitScript only fires on new navigations)
try { await page.evaluate(indicatorScript); } catch {}
} else {
await this.newTab();
}
// Browser disconnect handler — exit code 2 distinguishes from crashes (1)
if (this.browser) {
// Remove disconnect handler to avoid exit during intentional close
this.browser.removeAllListeners('disconnected');
// Timeout: headed browser.close() can hang on macOS
await Promise.race([
this.browser.close(),
new Promise(resolve => setTimeout(resolve, 5000)),
]).catch(() => {});
this.browser.on('disconnected', () => {
if (this.intentionalDisconnect) return;
console.error('[browse] Real browser disconnected (user closed or crashed).');
console.error('[browse] Run `$B connect` to reconnect.');
process.exit(2);
});
}
// Headed mode defaults
this.dialogAutoAccept = false; // Don't dismiss user's real dialogs
this.isHeaded = true;
this.consecutiveFailures = 0;
}
async close() {
if (this.browser || (this.connectionMode === 'headed' && this.context)) {
if (this.connectionMode === 'headed') {
// Headed/persistent context mode: close the context (which closes the browser)
this.intentionalDisconnect = true;
if (this.browser) this.browser.removeAllListeners('disconnected');
await Promise.race([
this.context ? this.context.close() : Promise.resolve(),
new Promise(resolve => setTimeout(resolve, 5000)),
]).catch(() => {});
} else {
// Launched mode: close the browser we spawned
this.browser.removeAllListeners('disconnected');
await Promise.race([
this.browser.close(),
new Promise(resolve => setTimeout(resolve, 5000)),
]).catch(() => {});
}
this.browser = null;
}
}
@@ -195,6 +402,7 @@ export class BrowserManager {
switchTab(id: number): void {
if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
this.activeTabId = id;
this.activeFrame = null; // Frame context is per-tab
}
getTabCount(): number {
@@ -324,6 +532,42 @@ export class BrowserManager {
return this.customUserAgent;
}
// ─── Lifecycle helpers ───────────────────────────────
/**
* Close all open pages and clear the pages map.
* Used by state load to replace the current session.
*/
async closeAllPages(): Promise<void> {
for (const page of this.pages.values()) {
await page.close().catch(() => {});
}
this.pages.clear();
this.clearRefs();
}
// ─── Frame context ─────────────────────────────────
private activeFrame: import('playwright').Frame | null = null;
setFrame(frame: import('playwright').Frame | null): void {
this.activeFrame = frame;
}
getFrame(): import('playwright').Frame | null {
return this.activeFrame;
}
/**
* Returns the active frame if set, otherwise the current page.
* Use this for operations that work on both Page and Frame (locator, evaluate, etc.).
*/
getActiveFrameOrPage(): import('playwright').Page | import('playwright').Frame {
// Auto-recover from detached frames (iframe removed/navigated)
if (this.activeFrame?.isDetached()) {
this.activeFrame = null;
}
return this.activeFrame ?? this.getPage();
}
// ─── State Save/Restore (shared by recreateContext + handoff) ─
/**
* Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab.
@@ -416,6 +660,9 @@ export class BrowserManager {
* Falls back to a clean slate on any failure.
*/
async recreateContext(): Promise<string | null> {
if (this.connectionMode === 'headed') {
throw new Error('Cannot recreate context in headed mode. Use disconnect first.');
}
if (!this.browser || !this.context) {
throw new Error('Browser not launched');
}
@@ -482,7 +729,7 @@ export class BrowserManager {
* If step 2 fails → return error, headless browser untouched
*/
async handoff(message: string): Promise<string> {
if (this.isHeaded) {
if (this.connectionMode === 'headed' || this.isHeaded) {
return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`;
}
if (!this.browser || !this.context) {
@@ -493,53 +740,68 @@ export class BrowserManager {
const state = await this.saveState();
const currentUrl = this.getCurrentUrl();
// 2. Launch new headed browser (try-catch — if this fails, headless stays running)
let newBrowser: Browser;
// 2. Launch new headed browser with extension (same as launchHeaded)
// Uses launchPersistentContext so the extension auto-loads.
let newContext: BrowserContext;
try {
newBrowser = await chromium.launch({
const fs = require('fs');
const path = require('path');
const extensionPath = this.findExtensionPath();
const launchArgs = ['--hide-crash-restore-bubble'];
if (extensionPath) {
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
launchArgs.push(`--load-extension=${extensionPath}`);
console.log(`[browse] Handoff: loading extension from ${extensionPath}`);
} else {
console.log('[browse] Handoff: extension not found — headed mode without side panel');
}
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
fs.mkdirSync(userDataDir, { recursive: true });
newContext = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: launchArgs,
viewport: null,
ignoreDefaultArgs: [
'--disable-extensions',
'--disable-component-extensions-with-background-pages',
],
timeout: 15000,
chromiumSandbox: process.platform !== 'win32',
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`;
}
// 3. Create context and restore state into new headed browser
// 3. Restore state into new headed browser
try {
const contextOptions: BrowserContextOptions = {
viewport: { width: 1280, height: 720 },
};
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
const newContext = await newBrowser.newContext(contextOptions);
// Swap to new browser/context before restoreState (it uses this.context)
const oldBrowser = this.browser;
this.context = newContext;
this.browser = newContext.browser();
this.pages.clear();
this.connectionMode = 'headed';
if (Object.keys(this.extraHeaders).length > 0) {
await newContext.setExtraHTTPHeaders(this.extraHeaders);
}
// Swap to new browser/context before restoreState (it uses this.context)
const oldBrowser = this.browser;
const oldContext = this.context;
this.browser = newBrowser;
this.context = newContext;
this.pages.clear();
// Register crash handler on new browser
this.browser.on('disconnected', () => {
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
console.error('[browse] Console/network logs flushed to .gstack/browse-*.log');
process.exit(1);
});
if (this.browser) {
this.browser.on('disconnected', () => {
if (this.intentionalDisconnect) return;
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
process.exit(1);
});
}
await this.restoreState(state);
this.isHeaded = true;
this.dialogAutoAccept = false; // User controls dialogs in headed mode
// 4. Close old headless browser (fire-and-forget — close() can hang
// when another Playwright instance is active, so we don't await it)
// 4. Close old headless browser (fire-and-forget)
oldBrowser.removeAllListeners('disconnected');
oldBrowser.close().catch(() => {});
@@ -549,8 +811,8 @@ export class BrowserManager {
`STATUS: Waiting for user. Run 'resume' when done.`,
].join('\n');
} catch (err: unknown) {
// Restore failed — close the new browser, keep old one
await newBrowser.close().catch(() => {});
// Restore failed — close the new context, keep old state
await newContext.close().catch(() => {});
const msg = err instanceof Error ? err.message : String(err);
return `ERROR: Handoff failed during state restore — ${msg}. Headless browser still running.`;
}
@@ -564,6 +826,7 @@ export class BrowserManager {
resume(): void {
this.clearRefs();
this.resetFailures();
this.activeFrame = null;
}
getIsHeaded(): boolean {
@@ -593,6 +856,7 @@ export class BrowserManager {
page.on('framenavigated', (frame) => {
if (frame === page.mainFrame()) {
this.clearRefs();
this.activeFrame = null; // Navigation invalidates frame context
}
});
+150 -2
View File
@@ -90,6 +90,7 @@ interface ServerState {
startedAt: string;
serverPath: string;
binaryVersion?: string;
mode?: 'launched' | 'headed';
}
// ─── State File ────────────────────────────────────────────────
@@ -217,7 +218,7 @@ function cleanupLegacyState(): void {
}
// ─── Server Lifecycle ──────────────────────────────────────────
async function startServer(): Promise<ServerState> {
async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> {
ensureStateDir(config);
// Clean up stale state file and error log
@@ -241,7 +242,7 @@ async function startServer(): Promise<ServerState> {
// macOS/Linux: Bun.spawn + unref works correctly
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, ...extraEnv },
});
proc.unref();
}
@@ -328,6 +329,15 @@ async function ensureServer(): Promise<ServerState> {
return state;
}
// Guard: never silently replace a headed server with a headless one.
// Headed mode means a user-visible Chrome window is (or was) controlled.
// Silently replacing it would be confusing — tell the user to reconnect.
if (state && state.mode === 'headed' && isProcessAlive(state.pid)) {
console.error(`[browse] Headed server running (PID ${state.pid}) but not responding.`);
console.error(`[browse] Run '$B connect' to restart.`);
process.exit(1);
}
// Ensure state directory exists before lock acquisition (lock file lives there)
ensureStateDir(config);
@@ -471,6 +481,144 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
const command = args[0];
const commandArgs = args.slice(1);
// ─── Headed Connect (pre-server command) ────────────────────
// connect must be handled BEFORE ensureServer() because it needs
// to restart the server in headed mode with the Chrome extension.
if (command === 'connect') {
// Check if already in headed mode and healthy
const existingState = readState();
if (existingState && existingState.mode === 'headed' && isProcessAlive(existingState.pid)) {
try {
const resp = await fetch(`http://127.0.0.1:${existingState.port}/health`, {
signal: AbortSignal.timeout(2000),
});
if (resp.ok) {
console.log('Already connected in headed mode.');
process.exit(0);
}
} catch {
// Headed server alive but not responding — kill and restart
}
}
// Kill ANY existing server (SIGTERM → wait 2s → SIGKILL)
if (existingState && isProcessAlive(existingState.pid)) {
try { process.kill(existingState.pid, 'SIGTERM'); } catch {}
await new Promise(resolve => setTimeout(resolve, 2000));
if (isProcessAlive(existingState.pid)) {
try { process.kill(existingState.pid, 'SIGKILL'); } catch {}
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Clean up Chromium profile locks (can persist after crashes)
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
}
// Delete stale state file
try { fs.unlinkSync(config.stateFile); } catch {}
console.log('Launching headed Chromium with extension + sidebar agent...');
try {
// Start server in headed mode with extension auto-loaded
// Use a well-known port so the Chrome extension auto-connects
const serverEnv: Record<string, string> = {
BROWSE_HEADED: '1',
BROWSE_PORT: '34567',
BROWSE_SIDEBAR_CHAT: '1',
};
const newState = await startServer(serverEnv);
// Print connected status
const resp = await fetch(`http://127.0.0.1:${newState.port}/command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${newState.token}`,
},
body: JSON.stringify({ command: 'status', args: [] }),
signal: AbortSignal.timeout(5000),
});
const status = await resp.text();
console.log(`Connected to real Chrome\n${status}`);
// Auto-start sidebar agent
const agentScript = path.resolve(__dirname, 'sidebar-agent.ts');
try {
// Clear old agent queue
const agentQueue = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
try { fs.writeFileSync(agentQueue, ''); } catch {}
const agentProc = Bun.spawn(['bun', 'run', agentScript], {
cwd: config.projectDir,
env: {
...process.env,
BROWSE_BIN: path.resolve(__dirname, '..', 'dist', 'browse'),
BROWSE_STATE_FILE: config.stateFile,
BROWSE_SERVER_PORT: String(newState.port),
},
stdio: ['ignore', 'ignore', 'ignore'],
});
agentProc.unref();
console.log(`[browse] Sidebar agent started (PID: ${agentProc.pid})`);
} catch (err: any) {
console.error(`[browse] Sidebar agent failed to start: ${err.message}`);
console.error(`[browse] Run manually: bun run ${agentScript}`);
}
} catch (err: any) {
console.error(`[browse] Connect failed: ${err.message}`);
process.exit(1);
}
process.exit(0);
}
// ─── Headed Disconnect (pre-server command) ─────────────────
// disconnect must be handled BEFORE ensureServer() because the headed
// guard blocks all commands when the server is unresponsive.
if (command === 'disconnect') {
const existingState = readState();
if (!existingState || existingState.mode !== 'headed') {
console.log('Not in headed mode — nothing to disconnect.');
process.exit(0);
}
// Try graceful shutdown via server
try {
const resp = await fetch(`http://127.0.0.1:${existingState.port}/command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${existingState.token}`,
},
body: JSON.stringify({ command: 'disconnect', args: [] }),
signal: AbortSignal.timeout(3000),
});
if (resp.ok) {
console.log('Disconnected from real browser.');
process.exit(0);
}
} catch {
// Server not responding — force cleanup
}
// Force kill + cleanup
if (isProcessAlive(existingState.pid)) {
try { process.kill(existingState.pid, 'SIGTERM'); } catch {}
await new Promise(resolve => setTimeout(resolve, 2000));
if (isProcessAlive(existingState.pid)) {
try { process.kill(existingState.pid, 'SIGKILL'); } catch {}
}
}
// Clean profile locks and state file
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
}
try { fs.unlinkSync(config.stateFile); } catch {}
console.log('Disconnected (server was unresponsive — force cleaned).');
process.exit(0);
}
// Special case: chain reads from stdin
if (command === 'chain' && commandArgs.length === 0) {
const stdin = await Bun.stdin.text();
+17
View File
@@ -31,6 +31,11 @@ export const META_COMMANDS = new Set([
'chain', 'diff',
'url', 'snapshot',
'handoff', 'resume',
'connect', 'disconnect', 'focus',
'inbox',
'watch',
'state',
'frame',
]);
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
@@ -98,6 +103,18 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
// Handoff
'handoff': { category: 'Server', description: 'Open visible Chrome at current page for user takeover', usage: 'handoff [message]' },
'resume': { category: 'Server', description: 'Re-snapshot after user takeover, return control to AI', usage: 'resume' },
// Headed mode
'connect': { category: 'Server', description: 'Launch headed Chromium with Chrome extension', usage: 'connect' },
'disconnect': { category: 'Server', description: 'Disconnect headed browser, return to headless mode' },
'focus': { category: 'Server', description: 'Bring headed browser window to foreground (macOS)', usage: 'focus [@ref]' },
// Inbox
'inbox': { category: 'Meta', description: 'List messages from sidebar scout inbox', usage: 'inbox [--clear]' },
// Watch
'watch': { category: 'Meta', description: 'Passive observation — periodic snapshots while user browses', usage: 'watch [stop]' },
// State
'state': { category: 'Server', description: 'Save/load browser state (cookies + URLs)', usage: 'state save|load <name>' },
// Frame
'frame': { category: 'Meta', description: 'Switch to iframe context (or main to return)', usage: 'frame <sel|@ref|--name n|--url pattern|main>' },
};
// Load-time validation: descriptions must cover exactly the command sets
+276 -8
View File
@@ -11,6 +11,8 @@ import * as Diff from 'diff';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR, isPathWithin } from './platform';
import { resolveConfig } from './config';
import type { Frame } from 'playwright';
// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
@@ -23,6 +25,25 @@ export function validateOutputPath(filePath: string): void {
}
}
/** Tokenize a pipe segment respecting double-quoted strings. */
function tokenizePipeSegment(segment: string): string[] {
const tokens: string[] = [];
let current = '';
let inQuote = false;
for (let i = 0; i < segment.length; i++) {
const ch = segment[i];
if (ch === '"') {
inQuote = !inQuote;
} else if (ch === ' ' && !inQuote) {
if (current) { tokens.push(current); current = ''; }
} else {
current += ch;
}
}
if (current) tokens.push(current);
return tokens;
}
export async function handleMetaCommand(
command: string,
args: string[],
@@ -61,8 +82,10 @@ export async function handleMetaCommand(
case 'status': {
const page = bm.getPage();
const tabs = bm.getTabCount();
const mode = bm.getConnectionMode();
return [
`Status: healthy`,
`Mode: ${mode}`,
`URL: ${page.url()}`,
`Tabs: ${tabs}`,
`PID: ${process.pid}`,
@@ -185,35 +208,54 @@ export async function handleMetaCommand(
case 'chain': {
// Read JSON array from args[0] (if provided) or expect it was passed as body
const jsonStr = args[0];
if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain');
if (!jsonStr) throw new Error(
'Usage: echo \'[["goto","url"],["text"]]\' | browse chain\n' +
' or: browse chain \'goto url | click @e5 | snapshot -ic\''
);
let commands: string[][];
try {
commands = JSON.parse(jsonStr);
if (!Array.isArray(commands)) throw new Error('not array');
} catch {
throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]');
// Fallback: pipe-delimited format "goto url | click @e5 | snapshot -ic"
commands = jsonStr.split(' | ')
.filter(seg => seg.trim().length > 0)
.map(seg => tokenizePipeSegment(seg.trim()));
}
if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands');
const results: string[] = [];
const { handleReadCommand } = await import('./read-commands');
const { handleWriteCommand } = await import('./write-commands');
let lastWasWrite = false;
for (const cmd of commands) {
const [name, ...cmdArgs] = cmd;
try {
let result: string;
if (WRITE_COMMANDS.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
else if (READ_COMMANDS.has(name)) result = await handleReadCommand(name, cmdArgs, bm);
else if (META_COMMANDS.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
else throw new Error(`Unknown command: ${name}`);
if (WRITE_COMMANDS.has(name)) {
result = await handleWriteCommand(name, cmdArgs, bm);
lastWasWrite = true;
} else if (READ_COMMANDS.has(name)) {
result = await handleReadCommand(name, cmdArgs, bm);
lastWasWrite = false;
} else if (META_COMMANDS.has(name)) {
result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
lastWasWrite = false;
} else {
throw new Error(`Unknown command: ${name}`);
}
results.push(`[${name}] ${result}`);
} catch (err: any) {
results.push(`[${name}] ERROR: ${err.message}`);
}
}
// Wait for network to settle after write commands before returning
if (lastWasWrite) {
await bm.getPage().waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
}
return results.join('\n\n');
}
@@ -263,6 +305,232 @@ export async function handleMetaCommand(
return `RESUMED\n${snapshot}`;
}
// ─── Headed Mode ──────────────────────────────────────
case 'connect': {
// connect is handled as a pre-server command in cli.ts
// If we get here, server is already running — tell the user
if (bm.getConnectionMode() === 'headed') {
return 'Already in headed mode with extension.';
}
return 'The connect command must be run from the CLI (not sent to a running server). Run: $B connect';
}
case 'disconnect': {
if (bm.getConnectionMode() !== 'headed') {
return 'Not in headed mode — nothing to disconnect.';
}
// Signal that we want a restart in headless mode
console.log('[browse] Disconnecting headed browser. Restarting in headless mode.');
await shutdown();
return 'Disconnected. Server will restart in headless mode on next command.';
}
case 'focus': {
if (bm.getConnectionMode() !== 'headed') {
return 'focus requires headed mode. Run `$B connect` first.';
}
try {
const { execSync } = await import('child_process');
// Try common Chromium-based browser app names to bring to foreground
const appNames = ['Comet', 'Google Chrome', 'Arc', 'Brave Browser', 'Microsoft Edge'];
let activated = false;
for (const appName of appNames) {
try {
execSync(`osascript -e 'tell application "${appName}" to activate'`, { stdio: 'pipe', timeout: 3000 });
activated = true;
break;
} catch {
// Try next browser
}
}
if (!activated) {
return 'Could not bring browser to foreground. macOS only.';
}
// If a ref was passed, scroll it into view
if (args.length > 0 && args[0].startsWith('@')) {
try {
const resolved = await bm.resolveRef(args[0]);
if ('locator' in resolved) {
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
return `Browser activated. Scrolled ${args[0]} into view.`;
}
} catch {
// Ref not found — still activated the browser
}
}
return 'Browser window activated.';
} catch (err: any) {
return `focus failed: ${err.message}. macOS only.`;
}
}
// ─── Watch ──────────────────────────────────────────
case 'watch': {
if (args[0] === 'stop') {
if (!bm.isWatching()) return 'Not currently watching.';
const result = bm.stopWatch();
const durationSec = Math.round(result.duration / 1000);
return [
`WATCH STOPPED (${durationSec}s, ${result.snapshots.length} snapshots)`,
'',
'Last snapshot:',
result.snapshots.length > 0 ? result.snapshots[result.snapshots.length - 1] : '(none)',
].join('\n');
}
if (bm.isWatching()) return 'Already watching. Run `$B watch stop` to stop.';
if (bm.getConnectionMode() !== 'headed') {
return 'watch requires headed mode. Run `$B connect` first.';
}
bm.startWatch();
return 'WATCHING — observing user browsing. Periodic snapshots every 5s.\nRun `$B watch stop` to stop and get summary.';
}
// ─── Inbox ──────────────────────────────────────────
case 'inbox': {
const { execSync } = await import('child_process');
let gitRoot: string;
try {
gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
} catch {
return 'Not in a git repository — cannot locate inbox.';
}
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
if (!fs.existsSync(inboxDir)) return 'Inbox empty.';
const files = fs.readdirSync(inboxDir)
.filter(f => f.endsWith('.json') && !f.startsWith('.'))
.sort()
.reverse(); // newest first
if (files.length === 0) return 'Inbox empty.';
const messages: { timestamp: string; url: string; userMessage: string }[] = [];
for (const file of files) {
try {
const data = JSON.parse(fs.readFileSync(path.join(inboxDir, file), 'utf-8'));
messages.push({
timestamp: data.timestamp || '',
url: data.page?.url || 'unknown',
userMessage: data.userMessage || '',
});
} catch {
// Skip malformed files
}
}
if (messages.length === 0) return 'Inbox empty.';
const lines: string[] = [];
lines.push(`SIDEBAR INBOX (${messages.length} message${messages.length === 1 ? '' : 's'})`);
lines.push('────────────────────────────────');
for (const msg of messages) {
const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
lines.push(`${ts} ${msg.url}`);
lines.push(` "${msg.userMessage}"`);
lines.push('');
}
lines.push('────────────────────────────────');
// Handle --clear flag
if (args.includes('--clear')) {
for (const file of files) {
try { fs.unlinkSync(path.join(inboxDir, file)); } catch {}
}
lines.push(`Cleared ${files.length} message${files.length === 1 ? '' : 's'}.`);
}
return lines.join('\n');
}
// ─── State ────────────────────────────────────────
case 'state': {
const [action, name] = args;
if (!action || !name) throw new Error('Usage: state save|load <name>');
// Sanitize name: alphanumeric + hyphens + underscores only
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
throw new Error('State name must be alphanumeric (a-z, 0-9, _, -)');
}
const config = resolveConfig();
const stateDir = path.join(config.stateDir, 'browse-states');
fs.mkdirSync(stateDir, { recursive: true });
const statePath = path.join(stateDir, `${name}.json`);
if (action === 'save') {
const state = await bm.saveState();
// V1: cookies + URLs only (not localStorage — breaks on load-before-navigate)
const saveData = {
version: 1,
cookies: state.cookies,
pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })),
};
fs.writeFileSync(statePath, JSON.stringify(saveData, null, 2), { mode: 0o600 });
return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages — treat as sensitive)`;
}
if (action === 'load') {
if (!fs.existsSync(statePath)) throw new Error(`State not found: ${statePath}`);
const data = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
throw new Error('Invalid state file: expected cookies and pages arrays');
}
// Close existing pages, then restore (replace, not merge)
bm.setFrame(null);
await bm.closeAllPages();
await bm.restoreState({
cookies: data.cookies,
pages: data.pages.map((p: any) => ({ ...p, storage: null })),
});
return `State loaded: ${data.cookies.length} cookies, ${data.pages.length} pages`;
}
throw new Error('Usage: state save|load <name>');
}
// ─── Frame ───────────────────────────────────────
case 'frame': {
const target = args[0];
if (!target) throw new Error('Usage: frame <selector|@ref|--name name|--url pattern|main>');
if (target === 'main') {
bm.setFrame(null);
bm.clearRefs();
return 'Switched to main frame';
}
const page = bm.getPage();
let frame: Frame | null = null;
if (target === '--name') {
if (!args[1]) throw new Error('Usage: frame --name <name>');
frame = page.frame({ name: args[1] });
} else if (target === '--url') {
if (!args[1]) throw new Error('Usage: frame --url <pattern>');
frame = page.frame({ url: new RegExp(args[1]) });
} else {
// CSS selector or @ref for the iframe element
const resolved = await bm.resolveRef(target);
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
const elementHandle = await locator.elementHandle({ timeout: 5000 });
frame = await elementHandle?.contentFrame() ?? null;
await elementHandle?.dispose();
}
if (!frame) throw new Error(`Frame not found: ${target}`);
bm.setFrame(frame);
bm.clearRefs();
return `Switched to frame: ${frame.url()}`;
}
default:
throw new Error(`Unknown meta command: ${command}`);
}
+23 -15
View File
@@ -7,7 +7,7 @@
import type { BrowserManager } from './browser-manager';
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
import type { Page } from 'playwright';
import type { Page, Frame } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR, isPathWithin } from './platform';
@@ -57,7 +57,7 @@ export function validateReadPath(filePath: string): void {
* Extract clean text from a page (strips script/style/noscript/svg).
* Exported for DRY reuse in meta-commands (diff).
*/
export async function getCleanText(page: Page): Promise<string> {
export async function getCleanText(page: Page | Frame): Promise<string> {
return await page.evaluate(() => {
const body = document.body;
if (!body) return '';
@@ -77,10 +77,12 @@ export async function handleReadCommand(
bm: BrowserManager
): Promise<string> {
const page = bm.getPage();
// Frame-aware target for content extraction
const target = bm.getActiveFrameOrPage();
switch (command) {
case 'text': {
return await getCleanText(page);
return await getCleanText(target);
}
case 'html': {
@@ -90,13 +92,19 @@ export async function handleReadCommand(
if ('locator' in resolved) {
return await resolved.locator.innerHTML({ timeout: 5000 });
}
return await page.innerHTML(resolved.selector);
return await target.locator(resolved.selector).innerHTML({ timeout: 5000 });
}
return await page.content();
// page.content() is page-only; use evaluate for frame compat
const doctype = await target.evaluate(() => {
const dt = document.doctype;
return dt ? `<!DOCTYPE ${dt.name}>` : '';
});
const html = await target.evaluate(() => document.documentElement.outerHTML);
return doctype ? `${doctype}\n${html}` : html;
}
case 'links': {
const links = await page.evaluate(() =>
const links = await target.evaluate(() =>
[...document.querySelectorAll('a[href]')].map(a => ({
text: a.textContent?.trim().slice(0, 120) || '',
href: (a as HTMLAnchorElement).href,
@@ -106,7 +114,7 @@ export async function handleReadCommand(
}
case 'forms': {
const forms = await page.evaluate(() => {
const forms = await target.evaluate(() => {
return [...document.querySelectorAll('form')].map((form, i) => {
const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
const input = el as HTMLInputElement;
@@ -136,7 +144,7 @@ export async function handleReadCommand(
}
case 'accessibility': {
const snapshot = await page.locator("body").ariaSnapshot();
const snapshot = await target.locator("body").ariaSnapshot();
return snapshot;
}
@@ -144,7 +152,7 @@ export async function handleReadCommand(
const expr = args[0];
if (!expr) throw new Error('Usage: browse js <expression>');
const wrapped = wrapForEvaluate(expr);
const result = await page.evaluate(wrapped);
const result = await target.evaluate(wrapped);
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
}
@@ -155,7 +163,7 @@ export async function handleReadCommand(
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
const code = fs.readFileSync(filePath, 'utf-8');
const wrapped = wrapForEvaluate(code);
const result = await page.evaluate(wrapped);
const result = await target.evaluate(wrapped);
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
}
@@ -170,7 +178,7 @@ export async function handleReadCommand(
);
return value;
}
const value = await page.evaluate(
const value = await target.evaluate(
([sel, prop]) => {
const el = document.querySelector(sel);
if (!el) return `Element not found: ${sel}`;
@@ -195,7 +203,7 @@ export async function handleReadCommand(
});
return JSON.stringify(attrs, null, 2);
}
const attrs = await page.evaluate((sel) => {
const attrs = await target.evaluate((sel: string) => {
const el = document.querySelector(sel);
if (!el) return `Element not found: ${sel}`;
const result: Record<string, string> = {};
@@ -253,7 +261,7 @@ export async function handleReadCommand(
if ('locator' in resolved) {
locator = resolved.locator;
} else {
locator = page.locator(resolved.selector);
locator = target.locator(resolved.selector);
}
switch (property) {
@@ -283,10 +291,10 @@ export async function handleReadCommand(
if (args[0] === 'set' && args[1]) {
const key = args[1];
const value = args[2] || '';
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
await target.evaluate(([k, v]: string[]) => localStorage.setItem(k, v), [key, value]);
return `Set localStorage["${key}"]`;
}
const storage = await page.evaluate(() => ({
const storage = await target.evaluate(() => ({
localStorage: { ...localStorage },
sessionStorage: { ...sessionStorage },
}));
+758 -8
View File
@@ -19,8 +19,11 @@ import { handleWriteCommand } from './write-commands';
import { handleMetaCommand } from './meta-commands';
import { handleCookiePickerRoute } from './cookie-picker-routes';
import { COMMAND_DESCRIPTIONS } from './commands';
import { SNAPSHOT_FLAGS } from './snapshot';
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
// fail posix_spawn on all executables including /bin/bash)
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
@@ -33,6 +36,7 @@ ensureStateDir(config);
const AUTH_TOKEN = crypto.randomUUID();
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
// Sidebar chat is always enabled in headed mode (ungated in v0.12.0)
function validateAuth(req: Request): boolean {
const header = req.headers.get('authorization');
@@ -87,6 +91,377 @@ export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetwork
const CONSOLE_LOG_PATH = config.consoleLog;
const NETWORK_LOG_PATH = config.networkLog;
const DIALOG_LOG_PATH = config.dialogLog;
// ─── Sidebar Agent (integrated — no separate process) ─────────────
interface ChatEntry {
id: number;
ts: string;
role: 'user' | 'assistant' | 'agent';
message?: string;
type?: string;
tool?: string;
input?: string;
text?: string;
error?: string;
}
interface SidebarSession {
id: string;
name: string;
claudeSessionId: string | null;
worktreePath: string | null;
createdAt: string;
lastActiveAt: string;
}
const SESSIONS_DIR = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-sessions');
const AGENT_TIMEOUT_MS = 300_000; // 5 minutes — multi-page tasks need time
const MAX_QUEUE = 5;
let sidebarSession: SidebarSession | null = null;
let agentProcess: ChildProcess | null = null;
let agentStatus: 'idle' | 'processing' | 'hung' = 'idle';
let agentStartTime: number | null = null;
let messageQueue: Array<{message: string, ts: string}> = [];
let currentMessage: string | null = null;
let chatBuffer: ChatEntry[] = [];
let chatNextId = 0;
// Find the browse binary for the claude subprocess system prompt
function findBrowseBin(): string {
const candidates = [
path.resolve(__dirname, '..', 'dist', 'browse'),
path.resolve(__dirname, '..', '..', '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'),
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'),
];
for (const c of candidates) {
try { if (fs.existsSync(c)) return c; } catch {}
}
return 'browse'; // fallback to PATH
}
const BROWSE_BIN = findBrowseBin();
function findClaudeBin(): string | null {
const home = process.env.HOME || '';
const candidates = [
// Conductor app bundled binary (not a symlink — works reliably)
path.join(home, 'Library', 'Application Support', 'com.conductor.app', 'bin', 'claude'),
// Direct versioned binary (not a symlink)
...(() => {
try {
const versionsDir = path.join(home, '.local', 'share', 'claude', 'versions');
const entries = fs.readdirSync(versionsDir).filter(e => /^\d/.test(e)).sort().reverse();
return entries.map(e => path.join(versionsDir, e));
} catch { return []; }
})(),
// Standard install (symlink — resolve it)
path.join(home, '.local', 'bin', 'claude'),
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
];
// Also check if 'claude' is in current PATH
try {
const proc = Bun.spawnSync(['which', 'claude'], { stdout: 'pipe', stderr: 'pipe', timeout: 2000 });
if (proc.exitCode === 0) {
const p = proc.stdout.toString().trim();
if (p) candidates.unshift(p);
}
} catch {}
for (const c of candidates) {
try {
if (!fs.existsSync(c)) continue;
// Resolve symlinks — posix_spawn can fail on symlinks in compiled bun binaries
return fs.realpathSync(c);
} catch {}
}
return null;
}
function shortenPath(str: string): string {
return str
.replace(new RegExp(BROWSE_BIN.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 = shortenPath(input.command);
return cmd.length > 80 ? cmd.slice(0, 80) + '…' : cmd;
}
if (tool === 'Read' && input.file_path) return shortenPath(input.file_path);
if (tool === 'Edit' && input.file_path) return shortenPath(input.file_path);
if (tool === 'Write' && input.file_path) return shortenPath(input.file_path);
if (tool === 'Grep' && input.pattern) return `/${input.pattern}/`;
if (tool === 'Glob' && input.pattern) return input.pattern;
try { return shortenPath(JSON.stringify(input)).slice(0, 60); } catch { return ''; }
}
function addChatEntry(entry: Omit<ChatEntry, 'id'>): ChatEntry {
const full: ChatEntry = { ...entry, id: chatNextId++ };
chatBuffer.push(full);
// Persist to disk (best-effort)
if (sidebarSession) {
const chatFile = path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl');
try { fs.appendFileSync(chatFile, JSON.stringify(full) + '\n'); } catch {}
}
return full;
}
function loadSession(): SidebarSession | null {
try {
const activeFile = path.join(SESSIONS_DIR, 'active.json');
const activeData = JSON.parse(fs.readFileSync(activeFile, 'utf-8'));
const sessionFile = path.join(SESSIONS_DIR, activeData.id, 'session.json');
const session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8')) as SidebarSession;
// Load chat history
const chatFile = path.join(SESSIONS_DIR, session.id, 'chat.jsonl');
try {
const lines = fs.readFileSync(chatFile, 'utf-8').split('\n').filter(Boolean);
chatBuffer = lines.map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
chatNextId = chatBuffer.length > 0 ? Math.max(...chatBuffer.map(e => e.id)) + 1 : 0;
} catch {}
return session;
} catch {
return null;
}
}
/**
* Create a git worktree for session isolation.
* Falls back to null (use main cwd) if:
* - not in a git repo
* - git worktree add fails (submodules, LFS, permissions)
* - worktree dir already exists (collision from prior crash)
*/
function createWorktree(sessionId: string): string | null {
try {
// Check if we're in a git repo
const gitCheck = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
stdout: 'pipe', stderr: 'pipe', timeout: 3000,
});
if (gitCheck.exitCode !== 0) return null;
const repoRoot = gitCheck.stdout.toString().trim();
const worktreeDir = path.join(process.env.HOME || '/tmp', '.gstack', 'worktrees', sessionId.slice(0, 8));
// Clean up if dir exists from prior crash
if (fs.existsSync(worktreeDir)) {
Bun.spawnSync(['git', 'worktree', 'remove', '--force', worktreeDir], {
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 5000,
});
try { fs.rmSync(worktreeDir, { recursive: true, force: true }); } catch {}
}
// Get current branch/commit
const headCheck = Bun.spawnSync(['git', 'rev-parse', 'HEAD'], {
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 3000,
});
if (headCheck.exitCode !== 0) return null;
const head = headCheck.stdout.toString().trim();
// Create worktree (detached HEAD — no branch conflicts)
const result = Bun.spawnSync(['git', 'worktree', 'add', '--detach', worktreeDir, head], {
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 10000,
});
if (result.exitCode !== 0) {
console.log(`[browse] Worktree creation failed: ${result.stderr.toString().trim()}`);
return null;
}
console.log(`[browse] Created worktree: ${worktreeDir}`);
return worktreeDir;
} catch (err: any) {
console.log(`[browse] Worktree creation error: ${err.message}`);
return null;
}
}
function removeWorktree(worktreePath: string | null): void {
if (!worktreePath) return;
try {
const gitCheck = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
stdout: 'pipe', stderr: 'pipe', timeout: 3000,
});
if (gitCheck.exitCode === 0) {
Bun.spawnSync(['git', 'worktree', 'remove', '--force', worktreePath], {
cwd: gitCheck.stdout.toString().trim(), stdout: 'pipe', stderr: 'pipe', timeout: 5000,
});
}
// Cleanup dir if git worktree remove didn't
try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch {}
} catch {}
}
function createSession(): SidebarSession {
const id = crypto.randomUUID();
const worktreePath = createWorktree(id);
const session: SidebarSession = {
id,
name: 'Chrome sidebar',
claudeSessionId: null,
worktreePath,
createdAt: new Date().toISOString(),
lastActiveAt: new Date().toISOString(),
};
const sessionDir = path.join(SESSIONS_DIR, id);
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(path.join(sessionDir, 'session.json'), JSON.stringify(session, null, 2));
fs.writeFileSync(path.join(sessionDir, 'chat.jsonl'), '');
fs.writeFileSync(path.join(SESSIONS_DIR, 'active.json'), JSON.stringify({ id }));
chatBuffer = [];
chatNextId = 0;
return session;
}
function saveSession(): void {
if (!sidebarSession) return;
sidebarSession.lastActiveAt = new Date().toISOString();
const sessionFile = path.join(SESSIONS_DIR, sidebarSession.id, 'session.json');
try { fs.writeFileSync(sessionFile, JSON.stringify(sidebarSession, null, 2)); } catch {}
}
function listSessions(): Array<SidebarSession & { chatLines: number }> {
try {
const dirs = fs.readdirSync(SESSIONS_DIR).filter(d => d !== 'active.json');
return dirs.map(d => {
try {
const session = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, d, 'session.json'), 'utf-8'));
let chatLines = 0;
try { chatLines = fs.readFileSync(path.join(SESSIONS_DIR, d, 'chat.jsonl'), 'utf-8').split('\n').filter(Boolean).length; } catch {}
return { ...session, chatLines };
} catch { return null; }
}).filter(Boolean);
} catch { return []; }
}
function processAgentEvent(event: any): void {
if (event.type === 'system' && event.session_id && sidebarSession && !sidebarSession.claudeSessionId) {
// Capture session_id from first claude init event for --resume
sidebarSession.claudeSessionId = event.session_id;
saveSession();
}
if (event.type === 'assistant' && event.message?.content) {
for (const block of event.message.content) {
if (block.type === 'tool_use') {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'tool_use', tool: block.name, input: summarizeToolInput(block.name, block.input) });
} else if (block.type === 'text' && block.text) {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'text', text: block.text });
}
}
}
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', 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?.type === 'text_delta' && event.delta.text) {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'text_delta', text: event.delta.text });
}
if (event.type === 'result') {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'result', text: event.text || event.result || '' });
}
}
function spawnClaude(userMessage: string): void {
agentStatus = 'processing';
agentStartTime = Date.now();
currentMessage = userMessage;
const pageUrl = browserManager.getCurrentUrl() || 'about:blank';
const B = BROWSE_BIN;
const systemPrompt = [
'You are a browser assistant running in a Chrome sidebar.',
`Current page: ${pageUrl}`,
`Browse binary: ${B}`,
'',
'Commands (run via bash):',
` ${B} goto <url> ${B} click <@ref> ${B} fill <@ref> <text>`,
` ${B} snapshot -i ${B} text ${B} screenshot`,
` ${B} back ${B} forward ${B} reload`,
'',
'Rules: run snapshot -i before clicking. Keep responses SHORT.',
].join('\n');
const prompt = `${systemPrompt}\n\nUser: ${userMessage}`;
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose',
'--allowedTools', 'Bash,Read,Glob,Grep'];
if (sidebarSession?.claudeSessionId) {
args.push('--resume', sidebarSession.claudeSessionId);
}
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_start' });
// Compiled bun binaries CANNOT spawn external processes (posix_spawn
// fails with ENOENT on everything, including /bin/bash). Instead,
// write the command to a queue file that the sidebar-agent process
// (running as non-compiled bun) picks up and spawns claude.
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
const agentQueue = path.join(gstackDir, 'sidebar-agent-queue.jsonl');
const entry = JSON.stringify({
ts: new Date().toISOString(),
message: userMessage,
prompt,
args,
stateFile: config.stateFile,
cwd: (sidebarSession as any)?.worktreePath || process.cwd(),
sessionId: sidebarSession?.claudeSessionId || null,
});
try {
fs.mkdirSync(gstackDir, { recursive: true });
fs.appendFileSync(agentQueue, entry + '\n');
} catch (err: any) {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: `Failed to queue: ${err.message}` });
agentStatus = 'idle';
agentStartTime = null;
currentMessage = null;
return;
}
// The sidebar-agent.ts process polls this file and spawns claude.
// It POST events back via /sidebar-event which processAgentEvent handles.
// Agent status transitions happen when we receive agent_done/agent_error events.
}
function killAgent(): void {
if (agentProcess) {
try { agentProcess.kill('SIGTERM'); } catch {}
setTimeout(() => { try { agentProcess?.kill('SIGKILL'); } catch {} }, 3000);
}
agentProcess = null;
agentStartTime = null;
currentMessage = null;
agentStatus = 'idle';
}
// Agent health check — detect hung processes
let agentHealthInterval: ReturnType<typeof setInterval> | null = null;
function startAgentHealthCheck(): void {
agentHealthInterval = setInterval(() => {
if (agentStatus === 'processing' && agentStartTime && Date.now() - agentStartTime > AGENT_TIMEOUT_MS) {
agentStatus = 'hung';
console.log(`[browse] Sidebar agent hung (>${AGENT_TIMEOUT_MS / 1000}s)`);
}
}, 10000);
}
// Initialize session on startup
function initSidebarSession(): void {
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
sidebarSession = loadSession();
if (!sidebarSession) {
sidebarSession = createSession();
}
console.log(`[browse] Sidebar session: ${sidebarSession.id} (${chatBuffer.length} chat entries loaded)`);
startAgentHealthCheck();
}
let lastConsoleFlushed = 0;
let lastNetworkFlushed = 0;
let lastDialogFlushed = 0;
@@ -224,6 +599,27 @@ async function handleCommand(body: any): Promise<Response> {
});
}
// Block mutation commands while watching (read-only observation mode)
if (browserManager.isWatching() && WRITE_COMMANDS.has(command)) {
return new Response(JSON.stringify({
error: 'Cannot run mutation commands while watching. Run `$B watch stop` first.',
}), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Activity: emit command_start
const startTime = Date.now();
emitActivity({
type: 'command_start',
command,
args,
url: browserManager.getCurrentUrl(),
tabs: browserManager.getTabCount(),
mode: browserManager.getConnectionMode(),
});
try {
let result: string;
@@ -233,6 +629,22 @@ async function handleCommand(body: any): Promise<Response> {
result = await handleWriteCommand(command, args, browserManager);
} else if (META_COMMANDS.has(command)) {
result = await handleMetaCommand(command, args, browserManager, shutdown);
// Start periodic snapshot interval when watch mode begins
if (command === 'watch' && args[0] !== 'stop' && browserManager.isWatching()) {
const watchInterval = setInterval(async () => {
if (!browserManager.isWatching()) {
clearInterval(watchInterval);
return;
}
try {
const snapshot = await handleSnapshot(['-i'], browserManager);
browserManager.addWatchSnapshot(snapshot);
} catch {
// Page may be navigating — skip this snapshot
}
}, 5000);
browserManager.watchInterval = watchInterval;
}
} else if (command === 'help') {
const helpText = generateHelpText();
return new Response(helpText, {
@@ -249,12 +661,38 @@ async function handleCommand(body: any): Promise<Response> {
});
}
// Activity: emit command_end (success)
emitActivity({
type: 'command_end',
command,
args,
url: browserManager.getCurrentUrl(),
duration: Date.now() - startTime,
status: 'ok',
result: result,
tabs: browserManager.getTabCount(),
mode: browserManager.getConnectionMode(),
});
browserManager.resetFailures();
return new Response(result, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
} catch (err: any) {
// Activity: emit command_end (error)
emitActivity({
type: 'command_end',
command,
args,
url: browserManager.getCurrentUrl(),
duration: Date.now() - startTime,
status: 'error',
error: err.message,
tabs: browserManager.getTabCount(),
mode: browserManager.getConnectionMode(),
});
browserManager.incrementFailures();
let errorMsg = wrapError(err);
const hint = browserManager.getFailureHint();
@@ -271,12 +709,25 @@ async function shutdown() {
isShuttingDown = true;
console.log('[browse] Shutting down...');
// Stop watch mode if active
if (browserManager.isWatching()) browserManager.stopWatch();
killAgent();
messageQueue = [];
saveSession(); // Persist chat history before exit
if (sidebarSession?.worktreePath) removeWorktree(sidebarSession.worktreePath);
if (agentHealthInterval) clearInterval(agentHealthInterval);
clearInterval(flushInterval);
clearInterval(idleCheckInterval);
await flushBuffers(); // Final flush (async now)
await browserManager.close();
// Clean up Chromium profile locks (prevent SingletonLock on next launch)
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
}
// Clean up state file
try { fs.unlinkSync(config.stateFile); } catch {}
@@ -294,6 +745,32 @@ if (process.platform === 'win32') {
});
}
// Emergency cleanup for crashes (OOM, uncaught exceptions, browser disconnect)
function emergencyCleanup() {
if (isShuttingDown) return;
isShuttingDown = true;
// Kill agent subprocess if running
try { killAgent(); } catch {}
// Save session state so chat history persists across crashes
try { saveSession(); } catch {}
// Clean Chromium profile locks
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
}
try { fs.unlinkSync(config.stateFile); } catch {}
}
process.on('uncaughtException', (err) => {
console.error('[browse] FATAL uncaught exception:', err.message);
emergencyCleanup();
process.exit(1);
});
process.on('unhandledRejection', (err: any) => {
console.error('[browse] FATAL unhandled rejection:', err?.message || err);
emergencyCleanup();
process.exit(1);
});
// ─── Start ─────────────────────────────────────────────────────
async function start() {
// Clear old log files
@@ -303,16 +780,20 @@ async function start() {
const port = await findPort();
// Launch browser
await browserManager.launch();
// Launch browser (headless or headed with extension)
const headed = process.env.BROWSE_HEADED === '1';
if (headed) {
await browserManager.launchHeaded();
console.log(`[browse] Launched headed Chromium with extension`);
} else {
await browserManager.launch();
}
const startTime = Date.now();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
fetch: async (req) => {
resetIdleTimer();
const url = new URL(req.url);
// Cookie picker routes — no auth required (localhost-only)
@@ -320,21 +801,285 @@ async function start() {
return handleCookiePickerRoute(url, req, browserManager);
}
// Health check — no auth required (now async)
// Health check — no auth required, does NOT reset idle timer
if (url.pathname === '/health') {
const healthy = await browserManager.isHealthy();
return new Response(JSON.stringify({
status: healthy ? 'healthy' : 'unhealthy',
mode: browserManager.getConnectionMode(),
uptime: Math.floor((Date.now() - startTime) / 1000),
tabs: browserManager.getTabCount(),
currentUrl: browserManager.getCurrentUrl(),
token: AUTH_TOKEN, // Extension uses this for Bearer auth
chatEnabled: true,
agent: {
status: agentStatus,
runningFor: agentStartTime ? Date.now() - agentStartTime : null,
currentMessage,
queueLength: messageQueue.length,
},
session: sidebarSession ? { id: sidebarSession.id, name: sidebarSession.name } : null,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// All other endpoints require auth
// Refs endpoint — no auth required (localhost-only), does NOT reset idle timer
if (url.pathname === '/refs') {
const refs = browserManager.getRefMap();
return new Response(JSON.stringify({
refs,
url: browserManager.getCurrentUrl(),
mode: browserManager.getConnectionMode(),
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
});
}
// Activity stream — SSE, no auth (localhost-only), does NOT reset idle timer
if (url.pathname === '/activity/stream') {
const afterId = parseInt(url.searchParams.get('after') || '0', 10);
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// 1. Gap detection + replay
const { entries, gap, gapFrom, availableFrom } = getActivityAfter(afterId);
if (gap) {
controller.enqueue(encoder.encode(`event: gap\ndata: ${JSON.stringify({ gapFrom, availableFrom })}\n\n`));
}
for (const entry of entries) {
controller.enqueue(encoder.encode(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`));
}
// 2. Subscribe for live events
const unsubscribe = subscribe((entry) => {
try {
controller.enqueue(encoder.encode(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`));
} catch {
unsubscribe();
}
});
// 3. Heartbeat every 15s
const heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
} catch {
clearInterval(heartbeat);
unsubscribe();
}
}, 15000);
// 4. Cleanup on disconnect
req.signal.addEventListener('abort', () => {
clearInterval(heartbeat);
unsubscribe();
try { controller.close(); } catch {}
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
},
});
}
// Activity history — REST, no auth (localhost-only), does NOT reset idle timer
if (url.pathname === '/activity/history') {
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const { entries, totalAdded } = getActivityHistory(limit);
return new Response(JSON.stringify({ entries, totalAdded, subscribers: getSubscriberCount() }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
});
}
// ─── Sidebar endpoints (auth required — token from /health) ────
// Sidebar routes are always available in headed mode (ungated in v0.12.0)
// Sidebar chat history — read from in-memory buffer
if (url.pathname === '/sidebar-chat') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
const afterId = parseInt(url.searchParams.get('after') || '0', 10);
const entries = chatBuffer.filter(e => e.id >= afterId);
return new Response(JSON.stringify({ entries, total: chatNextId }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
});
}
// Sidebar → server: user message → queue or process immediately
if (url.pathname === '/sidebar-command' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
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 ts = new Date().toISOString();
addChatEntry({ ts, role: 'user', message: msg });
if (sidebarSession) { sidebarSession.lastActiveAt = ts; saveSession(); }
if (agentStatus === 'idle') {
spawnClaude(msg);
return new Response(JSON.stringify({ ok: true, processing: true }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
} else if (messageQueue.length < MAX_QUEUE) {
messageQueue.push({ message: msg, ts });
return new Response(JSON.stringify({ ok: true, queued: true, position: messageQueue.length }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
} else {
return new Response(JSON.stringify({ error: 'Queue full (max 5)' }), {
status: 429, headers: { 'Content-Type': 'application/json' },
});
}
}
// Clear sidebar chat
if (url.pathname === '/sidebar-chat/clear' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
chatBuffer = [];
chatNextId = 0;
if (sidebarSession) {
try { fs.writeFileSync(path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl'), ''); } catch {}
}
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
// Kill hung agent
if (url.pathname === '/sidebar-agent/kill' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
killAgent();
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: 'Killed by user' });
// Process next in queue
if (messageQueue.length > 0) {
const next = messageQueue.shift()!;
spawnClaude(next.message);
}
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
// Stop agent (user-initiated) — queued messages remain for dismissal
if (url.pathname === '/sidebar-agent/stop' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
killAgent();
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: 'Stopped by user' });
return new Response(JSON.stringify({ ok: true, queuedMessages: messageQueue.length }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
}
// Dismiss a queued message by index
if (url.pathname === '/sidebar-queue/dismiss' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
const body = await req.json();
const idx = body.index;
if (typeof idx === 'number' && idx >= 0 && idx < messageQueue.length) {
messageQueue.splice(idx, 1);
}
return new Response(JSON.stringify({ ok: true, queueLength: messageQueue.length }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
}
// Session info
if (url.pathname === '/sidebar-session') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({
session: sidebarSession,
agent: { status: agentStatus, runningFor: agentStartTime ? Date.now() - agentStartTime : null, currentMessage, queueLength: messageQueue.length, queue: messageQueue },
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
// Create new session
if (url.pathname === '/sidebar-session/new' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
killAgent();
messageQueue = [];
// Clean up old session's worktree before creating new one
if (sidebarSession?.worktreePath) removeWorktree(sidebarSession.worktreePath);
sidebarSession = createSession();
return new Response(JSON.stringify({ ok: true, session: sidebarSession }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
}
// List all sessions
if (url.pathname === '/sidebar-session/list') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ sessions: listSessions(), activeId: sidebarSession?.id }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
}
// Agent event relay — sidebar-agent.ts POSTs events here
if (url.pathname === '/sidebar-agent/event' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
const body = await req.json();
processAgentEvent(body);
// Handle agent lifecycle events
if (body.type === 'agent_done' || body.type === 'agent_error') {
agentProcess = null;
agentStartTime = null;
currentMessage = null;
if (body.type === 'agent_done') {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_done' });
}
// Process next queued message
if (messageQueue.length > 0) {
const next = messageQueue.shift()!;
spawnClaude(next.message);
} else {
agentStatus = 'idle';
}
}
// Capture claude session ID for --resume
if (body.claudeSessionId && sidebarSession && !sidebarSession.claudeSessionId) {
sidebarSession.claudeSessionId = body.claudeSessionId;
saveSession();
}
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
// ─── Auth-required endpoints ──────────────────────────────────
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
@@ -343,6 +1088,7 @@ async function start() {
}
if (url.pathname === '/command' && req.method === 'POST') {
resetIdleTimer(); // Only commands reset idle timer
const body = await req.json();
return handleCommand(body);
}
@@ -352,13 +1098,14 @@ async function start() {
});
// Write state file (atomic: write .tmp then rename)
const state = {
const state: Record<string, unknown> = {
pid: process.pid,
port,
token: AUTH_TOKEN,
startedAt: new Date().toISOString(),
serverPath: path.resolve(import.meta.dir, 'server.ts'),
binaryVersion: readVersionHash() || undefined,
mode: browserManager.getConnectionMode(),
};
const tmpFile = config.stateFile + '.tmp';
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
@@ -368,6 +1115,9 @@ async function start() {
console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
console.log(`[browse] State file: ${config.stateFile}`);
console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
// Initialize sidebar session (load existing or create new)
initSidebarSession();
}
start().catch((err) => {
+278
View File
@@ -0,0 +1,278 @@
/**
* Sidebar Agent — polls agent-queue from server, spawns claude -p for each
* message, streams live events back to the server via /sidebar-agent/event.
*
* This runs as a NON-COMPILED bun process because compiled bun binaries
* cannot posix_spawn external executables. The server writes to the queue
* file, this process reads it and spawns claude.
*
* Usage: BROWSE_BIN=/path/to/browse 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-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 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;
// ─── File drop relay ──────────────────────────────────────────
function getGitRoot(): string | null {
try {
const { execSync } = require('child_process');
return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
} catch {
return null;
}
}
function writeToInbox(message: string, pageUrl?: string, sessionId?: string): void {
const gitRoot = getGitRoot();
if (!gitRoot) {
console.error('[sidebar-agent] Cannot write to inbox — not in a git repo');
return;
}
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
const now = new Date();
const timestamp = now.toISOString().replace(/:/g, '-');
const filename = `${timestamp}-observation.json`;
const tmpFile = path.join(inboxDir, `.${filename}.tmp`);
const finalFile = path.join(inboxDir, filename);
const inboxMessage = {
type: 'observation',
timestamp: now.toISOString(),
page: { url: pageUrl || 'unknown', title: '' },
userMessage: message,
sidebarSessionId: sessionId || 'unknown',
};
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2));
fs.renameSync(tmpFile, finalFile);
console.log(`[sidebar-agent] Wrote inbox message: ${filename}`);
}
// ─── 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;
}
}
// ─── Event relay to server ──────────────────────────────────────
async function sendEvent(event: Record<string, any>): Promise<void> {
if (!authToken) await refreshToken();
if (!authToken) return;
try {
await fetch(`${SERVER_URL}/sidebar-agent/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify(event),
});
} catch (err) {
console.error('[sidebar-agent] Failed to send event:', err);
}
}
// ─── Claude subprocess ──────────────────────────────────────────
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 ''; }
}
async function handleStreamEvent(event: any): Promise<void> {
if (event.type === 'system' && event.session_id) {
// Relay claude session ID for --resume support
await sendEvent({ type: 'system', claudeSessionId: event.session_id });
}
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) });
} else if (block.type === 'text' && block.text) {
await sendEvent({ type: 'text', text: block.text });
}
}
}
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) });
}
if (event.type === 'content_block_delta' && 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 || '' });
}
}
async function askClaude(queueEntry: any): Promise<void> {
const { prompt, args, stateFile, cwd } = queueEntry;
isProcessing = true;
await sendEvent({ type: 'agent_start' });
return new Promise((resolve) => {
// Build args fresh — don't trust --resume from queue (session may be stale)
let claudeArgs = ['-p', prompt, '--output-format', 'stream-json', '--verbose',
'--allowedTools', 'Bash,Read,Glob,Grep'];
// Validate cwd exists — queue may reference a stale worktree
let effectiveCwd = cwd || process.cwd();
try { fs.accessSync(effectiveCwd); } catch { effectiveCwd = process.cwd(); }
const proc = spawn('claude', claudeArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: effectiveCwd,
env: { ...process.env, BROWSE_STATE_FILE: stateFile || '' },
});
proc.stdin.end();
let buffer = '';
proc.stdout.on('data', (data: Buffer) => {
buffer += data.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try { handleStreamEvent(JSON.parse(line)); } catch {}
}
});
proc.stderr.on('data', () => {}); // Claude logs to stderr, ignore
proc.on('close', (code) => {
if (buffer.trim()) {
try { handleStreamEvent(JSON.parse(buffer)); } catch {}
}
sendEvent({ type: 'agent_done' }).then(() => {
isProcessing = false;
resolve();
});
});
proc.on('error', (err) => {
sendEvent({ type: 'agent_error', error: err.message }).then(() => {
isProcessing = false;
resolve();
});
});
// Timeout after 300 seconds (5 min — multi-page tasks need time)
setTimeout(() => {
try { proc.kill(); } catch {}
sendEvent({ type: 'agent_error', error: 'Timed out after 300s' }).then(() => {
isProcessing = false;
resolve();
});
}, 300000);
});
}
// ─── Poll loop ───────────────────────────────────────────────────
function countLines(): number {
try {
return fs.readFileSync(QUEUE, 'utf-8').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() {
if (isProcessing) return; // One at a time — server handles queuing
const current = countLines();
if (current <= lastLine) return;
while (lastLine < current && !isProcessing) {
lastLine++;
const line = readLine(lastLine);
if (!line) continue;
let entry: any;
try { entry = JSON.parse(line); } catch { continue; }
if (!entry.message && !entry.prompt) continue;
console.log(`[sidebar-agent] Processing: "${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) });
}
}
}
// ─── Main ────────────────────────────────────────────────────────
async function main() {
const dir = path.dirname(QUEUE);
fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '');
lastLine = countLines();
await refreshToken();
console.log(`[sidebar-agent] Started. Watching ${QUEUE} from line ${lastLine}`);
console.log(`[sidebar-agent] Server: ${SERVER_URL}`);
console.log(`[sidebar-agent] Browse binary: ${B}`);
setInterval(poll, POLL_MS);
}
main().catch(console.error);
+16 -7
View File
@@ -17,7 +17,7 @@
* Later: "click @e3" → look up Locator → locator.click()
*/
import type { Page, Locator } from 'playwright';
import type { Page, Frame, Locator } from 'playwright';
import type { BrowserManager, RefEntry } from './browser-manager';
import * as Diff from 'diff';
import { TEMP_DIR, isPathWithin } from './platform';
@@ -136,15 +136,18 @@ export async function handleSnapshot(
): Promise<string> {
const opts = parseSnapshotArgs(args);
const page = bm.getPage();
// Frame-aware target for accessibility tree
const target = bm.getActiveFrameOrPage();
const inFrame = bm.getFrame() !== null;
// Get accessibility tree via ariaSnapshot
let rootLocator: Locator;
if (opts.selector) {
rootLocator = page.locator(opts.selector);
rootLocator = target.locator(opts.selector);
const count = await rootLocator.count();
if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
} else {
rootLocator = page.locator('body');
rootLocator = target.locator('body');
}
const ariaText = await rootLocator.ariaSnapshot();
@@ -205,11 +208,11 @@ export async function handleSnapshot(
let locator: Locator;
if (opts.selector) {
locator = page.locator(opts.selector).getByRole(node.role as any, {
locator = target.locator(opts.selector).getByRole(node.role as any, {
name: node.name || undefined,
});
} else {
locator = page.getByRole(node.role as any, {
locator = target.getByRole(node.role as any, {
name: node.name || undefined,
});
}
@@ -233,7 +236,7 @@ export async function handleSnapshot(
// ─── Cursor-interactive scan (-C) ─────────────────────────
if (opts.cursorInteractive) {
try {
const cursorElements = await page.evaluate(() => {
const cursorElements = await target.evaluate(() => {
const STANDARD_INTERACTIVE = new Set([
'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS',
]);
@@ -287,7 +290,7 @@ export async function handleSnapshot(
let cRefCounter = 1;
for (const elem of cursorElements) {
const ref = `c${cRefCounter++}`;
const locator = page.locator(elem.selector);
const locator = target.locator(elem.selector);
refMap.set(ref, { locator, role: 'cursor-interactive', name: elem.text });
output.push(`@${ref} [${elem.reason}] "${elem.text}"`);
}
@@ -394,5 +397,11 @@ export async function handleSnapshot(
// Store for future diffs
bm.setLastSnapshot(snapshotText);
// Add frame context header when operating inside an iframe
if (inFrame) {
const frameUrl = bm.getFrame()?.url() ?? 'unknown';
output.unshift(`[Context: iframe src="${frameUrl}"]`);
}
return output.join('\n');
}
+23 -13
View File
@@ -18,9 +18,13 @@ export async function handleWriteCommand(
bm: BrowserManager
): Promise<string> {
const page = bm.getPage();
// Frame-aware target for locator-based operations (click, fill, etc.)
const target = bm.getActiveFrameOrPage();
const inFrame = bm.getFrame() !== null;
switch (command) {
case 'goto': {
if (inFrame) throw new Error('Cannot use goto inside a frame. Run \'frame main\' first.');
const url = args[0];
if (!url) throw new Error('Usage: browse goto <url>');
await validateNavigationUrl(url);
@@ -30,16 +34,19 @@ export async function handleWriteCommand(
}
case 'back': {
if (inFrame) throw new Error('Cannot use back inside a frame. Run \'frame main\' first.');
await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 });
return `Back → ${page.url()}`;
}
case 'forward': {
if (inFrame) throw new Error('Cannot use forward inside a frame. Run \'frame main\' first.');
await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 });
return `Forward → ${page.url()}`;
}
case 'reload': {
if (inFrame) throw new Error('Cannot use reload inside a frame. Run \'frame main\' first.');
await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
return `Reloaded ${page.url()}`;
}
@@ -73,15 +80,14 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.click({ timeout: 5000 });
} else {
await page.click(resolved.selector, { timeout: 5000 });
await target.locator(resolved.selector).click({ timeout: 5000 });
}
} catch (err: any) {
// Enhanced error guidance: clicking <option> elements always fails (not visible / timeout)
const isOption = 'locator' in resolved
? await resolved.locator.evaluate(el => el.tagName === 'OPTION').catch(() => false)
: await page.evaluate(
(sel: string) => document.querySelector(sel)?.tagName === 'OPTION',
(resolved as { selector: string }).selector
: await target.locator(resolved.selector).evaluate(
el => el.tagName === 'OPTION'
).catch(() => false);
if (isOption) {
throw new Error(
@@ -90,8 +96,8 @@ export async function handleWriteCommand(
}
throw err;
}
// Wait briefly for any navigation/DOM update
await page.waitForLoadState('domcontentloaded').catch(() => {});
// Wait for network to settle (catches XHR/fetch triggered by clicks)
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
return `Clicked ${selector} → now at ${page.url()}`;
}
@@ -103,8 +109,10 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.fill(value, { timeout: 5000 });
} else {
await page.fill(resolved.selector, value, { timeout: 5000 });
await target.locator(resolved.selector).fill(value, { timeout: 5000 });
}
// Wait for network to settle (form validation XHRs)
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
return `Filled ${selector}`;
}
@@ -116,8 +124,10 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.selectOption(value, { timeout: 5000 });
} else {
await page.selectOption(resolved.selector, value, { timeout: 5000 });
await target.locator(resolved.selector).selectOption(value, { timeout: 5000 });
}
// Wait for network to settle (dropdown-triggered requests)
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
return `Selected "${value}" in ${selector}`;
}
@@ -128,7 +138,7 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.hover({ timeout: 5000 });
} else {
await page.hover(resolved.selector, { timeout: 5000 });
await target.locator(resolved.selector).hover({ timeout: 5000 });
}
return `Hovered ${selector}`;
}
@@ -154,11 +164,11 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
} else {
await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
await target.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
}
return `Scrolled ${selector} into view`;
}
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await target.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
return 'Scrolled to bottom';
}
@@ -183,7 +193,7 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.waitFor({ state: 'visible', timeout });
} else {
await page.waitForSelector(resolved.selector, { timeout });
await target.locator(resolved.selector).waitFor({ state: 'visible', timeout });
}
return `Element ${selector} appeared`;
}
@@ -248,7 +258,7 @@ export async function handleWriteCommand(
if ('locator' in resolved) {
await resolved.locator.setInputFiles(filePaths);
} else {
await page.locator(resolved.selector).setInputFiles(filePaths);
await target.locator(resolved.selector).setInputFiles(filePaths);
}
const fileInfo = filePaths.map(fp => {
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'bun:test';
import { filterArgs, emitActivity, getActivityAfter, getActivityHistory, subscribe } from '../src/activity';
describe('filterArgs — privacy filtering', () => {
it('redacts fill value for password fields', () => {
expect(filterArgs('fill', ['#password', 'mysecret123'])).toEqual(['#password', '[REDACTED]']);
expect(filterArgs('fill', ['input[type=passwd]', 'abc'])).toEqual(['input[type=passwd]', '[REDACTED]']);
});
it('preserves fill value for non-password fields', () => {
expect(filterArgs('fill', ['#email', 'user@test.com'])).toEqual(['#email', 'user@test.com']);
});
it('redacts type command args', () => {
expect(filterArgs('type', ['my password'])).toEqual(['[REDACTED]']);
});
it('redacts Authorization header', () => {
expect(filterArgs('header', ['Authorization:Bearer abc123'])).toEqual(['Authorization:[REDACTED]']);
});
it('preserves non-sensitive headers', () => {
expect(filterArgs('header', ['Content-Type:application/json'])).toEqual(['Content-Type:application/json']);
});
it('redacts cookie values', () => {
expect(filterArgs('cookie', ['session_id=abc123'])).toEqual(['session_id=[REDACTED]']);
});
it('redacts sensitive URL query params', () => {
const result = filterArgs('goto', ['https://example.com?api_key=secret&page=1']);
expect(result[0]).toContain('api_key=%5BREDACTED%5D');
expect(result[0]).toContain('page=1');
});
it('preserves non-sensitive URL query params', () => {
const result = filterArgs('goto', ['https://example.com?page=1&sort=name']);
expect(result[0]).toBe('https://example.com?page=1&sort=name');
});
it('handles empty args', () => {
expect(filterArgs('click', [])).toEqual([]);
});
it('handles non-URL non-sensitive args', () => {
expect(filterArgs('click', ['@e3'])).toEqual(['@e3']);
});
});
describe('emitActivity', () => {
it('emits with auto-incremented id', () => {
const e1 = emitActivity({ type: 'command_start', command: 'goto', args: ['https://example.com'] });
const e2 = emitActivity({ type: 'command_end', command: 'goto', status: 'ok', duration: 100 });
expect(e2.id).toBe(e1.id + 1);
});
it('truncates long results', () => {
const longResult = 'x'.repeat(500);
const entry = emitActivity({ type: 'command_end', command: 'text', result: longResult });
expect(entry.result!.length).toBeLessThanOrEqual(203); // 200 + "..."
});
it('applies privacy filtering', () => {
const entry = emitActivity({ type: 'command_start', command: 'type', args: ['my secret password'] });
expect(entry.args).toEqual(['[REDACTED]']);
});
});
describe('getActivityAfter', () => {
it('returns entries after cursor', () => {
const e1 = emitActivity({ type: 'command_start', command: 'test1' });
const e2 = emitActivity({ type: 'command_start', command: 'test2' });
const result = getActivityAfter(e1.id);
expect(result.entries.some(e => e.id === e2.id)).toBe(true);
expect(result.gap).toBe(false);
});
it('returns all entries when cursor is 0', () => {
emitActivity({ type: 'command_start', command: 'test3' });
const result = getActivityAfter(0);
expect(result.entries.length).toBeGreaterThan(0);
});
});
describe('getActivityHistory', () => {
it('returns limited entries', () => {
for (let i = 0; i < 5; i++) {
emitActivity({ type: 'command_start', command: `history-test-${i}` });
}
const result = getActivityHistory(3);
expect(result.entries.length).toBeLessThanOrEqual(3);
});
});
describe('subscribe', () => {
it('receives new events', async () => {
const received: any[] = [];
const unsub = subscribe((entry) => received.push(entry));
emitActivity({ type: 'command_start', command: 'sub-test' });
// queueMicrotask is async — wait a tick
await new Promise(resolve => setTimeout(resolve, 10));
expect(received.length).toBeGreaterThanOrEqual(1);
expect(received[received.length - 1].command).toBe('sub-test');
unsub();
});
it('stops receiving after unsubscribe', async () => {
const received: any[] = [];
const unsub = subscribe((entry) => received.push(entry));
unsub();
emitActivity({ type: 'command_start', command: 'should-not-see' });
await new Promise(resolve => setTimeout(resolve, 10));
expect(received.filter(e => e.command === 'should-not-see').length).toBe(0);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'bun:test';
// ─── BrowserManager basic unit tests ─────────────────────────────
describe('BrowserManager defaults', () => {
it('getConnectionMode defaults to launched', async () => {
const { BrowserManager } = await import('../src/browser-manager');
const bm = new BrowserManager();
expect(bm.getConnectionMode()).toBe('launched');
});
it('getRefMap returns empty array initially', async () => {
const { BrowserManager } = await import('../src/browser-manager');
const bm = new BrowserManager();
expect(bm.getRefMap()).toEqual([]);
});
});
+235 -7
View File
@@ -1323,13 +1323,12 @@ describe('Errors', () => {
}
});
test('chain with invalid JSON throws', async () => {
try {
await handleMetaCommand('chain', ['not json'], bm, async () => {});
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('Invalid JSON');
}
test('chain with invalid JSON falls back to pipe format', async () => {
// Non-JSON input is now treated as pipe-delimited format
// 'not json' → [["not", "json"]] → "not" is unknown command → error in result
const result = await handleMetaCommand('chain', ['not json'], bm, async () => {});
expect(result).toContain('ERROR');
expect(result).toContain('Unknown command: not');
});
test('chain with no arg throws', async () => {
@@ -1834,3 +1833,232 @@ describe('Chain with cookie-import', () => {
}
});
});
// ─── Network Idle Detection ─────────────────────────────────────
describe('Network idle', () => {
test('click on fetch button waits for XHR to complete', async () => {
await handleWriteCommand('goto', [baseUrl + '/network-idle.html'], bm);
// Click the button that triggers a fetch → networkidle waits for it
await handleWriteCommand('click', ['#fetch-btn'], bm);
// The DOM should be updated by the time click returns
const result = await handleReadCommand('js', ['document.getElementById("result").textContent'], bm);
expect(result).toContain('Data loaded');
});
test('click on static button has no latency penalty', async () => {
await handleWriteCommand('goto', [baseUrl + '/network-idle.html'], bm);
const start = Date.now();
await handleWriteCommand('click', ['#static-btn'], bm);
const elapsed = Date.now() - start;
// Static click should complete well under 2s (the networkidle timeout)
// networkidle resolves immediately when no requests are in flight
expect(elapsed).toBeLessThan(1500);
const result = await handleReadCommand('js', ['document.getElementById("static-result").textContent'], bm);
expect(result).toBe('Static action done');
});
test('fill triggers networkidle wait', async () => {
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
// fill should complete without error (networkidle resolves immediately on static page)
const result = await handleWriteCommand('fill', ['#email', 'idle@test.com'], bm);
expect(result).toContain('Filled');
});
});
// ─── Chain Pipe Format ──────────────────────────────────────────
describe('Chain pipe format', () => {
test('pipe-delimited commands work', async () => {
const result = await handleMetaCommand(
'chain',
[`goto ${baseUrl}/basic.html | js document.title`],
bm,
async () => {}
);
expect(result).toContain('[goto]');
expect(result).toContain('[js]');
expect(result).toContain('Test Page - Basic');
});
test('pipe format with quoted args', async () => {
const result = await handleMetaCommand(
'chain',
[`goto ${baseUrl}/forms.html | fill #email "pipe@test.com"`],
bm,
async () => {}
);
expect(result).toContain('[fill]');
expect(result).toContain('Filled');
// Verify the fill actually worked
const val = await handleReadCommand('js', ['document.querySelector("#email").value'], bm);
expect(val).toBe('pipe@test.com');
});
test('JSON format still works', async () => {
const commands = JSON.stringify([
['goto', baseUrl + '/basic.html'],
['js', 'document.title'],
]);
const result = await handleMetaCommand('chain', [commands], bm, async () => {});
expect(result).toContain('[goto]');
expect(result).toContain('Test Page - Basic');
});
test('pipe format with unknown command includes error', async () => {
const result = await handleMetaCommand(
'chain',
['bogus command'],
bm,
async () => {}
);
expect(result).toContain('ERROR');
expect(result).toContain('Unknown command: bogus');
});
});
// ─── State Persistence ──────────────────────────────────────────
describe('State persistence', () => {
test('state save and load round-trip', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
// Set a cookie so we can verify it persists
await handleWriteCommand('cookie', ['state_test=hello'], bm);
// Save state
const saveResult = await handleMetaCommand('state', ['save', 'test-roundtrip'], bm, async () => {});
expect(saveResult).toContain('State saved');
expect(saveResult).toContain('treat as sensitive');
// Navigate away
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
// Load state — should restore to basic.html with cookie
const loadResult = await handleMetaCommand('state', ['load', 'test-roundtrip'], bm, async () => {});
expect(loadResult).toContain('State loaded');
// Verify we're back on basic.html
const url = await handleReadCommand('js', ['location.pathname'], bm);
expect(url).toContain('basic.html');
// Clean up
try {
const { resolveConfig } = await import('../src/config');
const config = resolveConfig();
fs.unlinkSync(`${config.stateDir}/browse-states/test-roundtrip.json`);
} catch {}
});
test('state save rejects invalid names', async () => {
try {
await handleMetaCommand('state', ['save', '../../evil'], bm, async () => {});
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('alphanumeric');
}
});
test('state save accepts valid names', async () => {
const result = await handleMetaCommand('state', ['save', 'my-state_1'], bm, async () => {});
expect(result).toContain('State saved');
// Clean up
try {
const { resolveConfig } = await import('../src/config');
const config = resolveConfig();
fs.unlinkSync(`${config.stateDir}/browse-states/my-state_1.json`);
} catch {}
});
test('state load rejects missing state', async () => {
try {
await handleMetaCommand('state', ['load', 'nonexistent-state-xyz'], bm, async () => {});
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('State not found');
}
});
test('state requires action and name', async () => {
try {
await handleMetaCommand('state', [], bm, async () => {});
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('Usage');
}
});
});
// ─── Frame (Iframe Support) ─────────────────────────────────────
describe('Frame', () => {
test('frame switch to iframe and back', async () => {
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
// Verify we're on the main page
const mainTitle = await handleReadCommand('js', ['document.getElementById("main-title").textContent'], bm);
expect(mainTitle).toBe('Main Page');
// Switch to iframe by CSS selector
const switchResult = await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
expect(switchResult).toContain('Switched to frame');
// Verify we can read iframe content
const frameTitle = await handleReadCommand('js', ['document.getElementById("frame-title").textContent'], bm);
expect(frameTitle).toBe('Inside Frame');
// Switch back to main
const mainResult = await handleMetaCommand('frame', ['main'], bm, async () => {});
expect(mainResult).toBe('Switched to main frame');
// Verify we're back on the main page
const mainTitleAgain = await handleReadCommand('js', ['document.getElementById("main-title").textContent'], bm);
expect(mainTitleAgain).toBe('Main Page');
});
test('snapshot shows frame context header', async () => {
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
expect(snap).toContain('[Context: iframe');
// Clean up — return to main
await handleMetaCommand('frame', ['main'], bm, async () => {});
});
test('goto throws error when in frame context', async () => {
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
try {
await handleWriteCommand('goto', ['https://example.com'], bm);
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('Cannot use goto inside a frame');
}
await handleMetaCommand('frame', ['main'], bm, async () => {});
});
test('frame requires argument', async () => {
try {
await handleMetaCommand('frame', [], bm, async () => {});
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('Usage');
}
});
test('fill works inside iframe', async () => {
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
const result = await handleWriteCommand('fill', ['#frame-input', 'hello from frame'], bm);
expect(result).toContain('Filled');
const value = await handleReadCommand('js', ['document.getElementById("frame-input").value'], bm);
expect(value).toBe('hello from frame');
await handleMetaCommand('frame', ['main'], bm, async () => {});
});
});
+271
View File
@@ -0,0 +1,271 @@
/**
* Tests for the inbox meta-command handler (file drop relay).
*
* Tests the inbox display, --clear flag, and edge cases by creating
* temp directories with test JSON files and calling handleMetaCommand.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { handleMetaCommand } from '../src/meta-commands';
import { BrowserManager } from '../src/browser-manager';
let tmpDir: string;
let bm: BrowserManager;
// We need a BrowserManager instance for handleMetaCommand, but inbox
// doesn't use it. We also need to mock git rev-parse to point to our
// temp directory. We'll test the inbox logic directly by manipulating
// the filesystem and using child_process.execSync override.
// ─── Direct filesystem tests (bypassing handleMetaCommand) ──────
// The inbox handler in meta-commands.ts calls `git rev-parse --show-toplevel`
// to find the inbox directory. Since we can't easily mock that in unit tests,
// we test the inbox parsing logic directly.
interface InboxMessage {
timestamp: string;
url: string;
userMessage: string;
}
/** Replicate the inbox file reading logic from meta-commands.ts */
function readInbox(inboxDir: string): InboxMessage[] {
if (!fs.existsSync(inboxDir)) return [];
const files = fs.readdirSync(inboxDir)
.filter(f => f.endsWith('.json') && !f.startsWith('.'))
.sort()
.reverse();
if (files.length === 0) return [];
const messages: InboxMessage[] = [];
for (const file of files) {
try {
const data = JSON.parse(fs.readFileSync(path.join(inboxDir, file), 'utf-8'));
messages.push({
timestamp: data.timestamp || '',
url: data.page?.url || 'unknown',
userMessage: data.userMessage || '',
});
} catch {
// Skip malformed files
}
}
return messages;
}
/** Replicate the inbox formatting logic from meta-commands.ts */
function formatInbox(messages: InboxMessage[]): string {
if (messages.length === 0) return 'Inbox empty.';
const lines: string[] = [];
lines.push(`SIDEBAR INBOX (${messages.length} message${messages.length === 1 ? '' : 's'})`);
lines.push('────────────────────────────────');
for (const msg of messages) {
const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
lines.push(`${ts} ${msg.url}`);
lines.push(` "${msg.userMessage}"`);
lines.push('');
}
lines.push('────────────────────────────────');
return lines.join('\n');
}
/** Replicate the --clear logic from meta-commands.ts */
function clearInbox(inboxDir: string): number {
const files = fs.readdirSync(inboxDir)
.filter(f => f.endsWith('.json') && !f.startsWith('.'));
for (const file of files) {
try { fs.unlinkSync(path.join(inboxDir, file)); } catch {}
}
return files.length;
}
function writeTestInboxFile(
inboxDir: string,
message: string,
pageUrl: string,
timestamp: string,
): string {
fs.mkdirSync(inboxDir, { recursive: true });
const filename = `${timestamp.replace(/:/g, '-')}-observation.json`;
const filePath = path.join(inboxDir, filename);
fs.writeFileSync(filePath, JSON.stringify({
type: 'observation',
timestamp,
page: { url: pageUrl, title: '' },
userMessage: message,
sidebarSessionId: 'test-session',
}, null, 2));
return filePath;
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-drop-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ─── Empty Inbox ─────────────────────────────────────────────────
describe('inbox — empty states', () => {
test('no .context/sidebar-inbox directory returns empty', () => {
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
const messages = readInbox(inboxDir);
expect(messages.length).toBe(0);
expect(formatInbox(messages)).toBe('Inbox empty.');
});
test('empty inbox directory returns empty', () => {
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
const messages = readInbox(inboxDir);
expect(messages.length).toBe(0);
expect(formatInbox(messages)).toBe('Inbox empty.');
});
test('directory with only dotfiles returns empty', () => {
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
fs.writeFileSync(path.join(inboxDir, '.tmp-file.json'), '{}');
const messages = readInbox(inboxDir);
expect(messages.length).toBe(0);
});
});
// ─── Valid Messages ──────────────────────────────────────────────
describe('inbox — valid messages', () => {
test('displays formatted output with timestamps and URLs', () => {
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
writeTestInboxFile(inboxDir, 'This button is broken', 'https://example.com/page', '2024-06-15T10:30:00.000Z');
writeTestInboxFile(inboxDir, 'Login form fails', 'https://example.com/login', '2024-06-15T10:31:00.000Z');
const messages = readInbox(inboxDir);
expect(messages.length).toBe(2);
const output = formatInbox(messages);
expect(output).toContain('SIDEBAR INBOX (2 messages)');
expect(output).toContain('https://example.com/page');
expect(output).toContain('https://example.com/login');
expect(output).toContain('"This button is broken"');
expect(output).toContain('"Login form fails"');
expect(output).toContain('[2024-06-15T10:30:00.000Z]');
expect(output).toContain('[2024-06-15T10:31:00.000Z]');
});
test('single message uses singular form', () => {
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
writeTestInboxFile(inboxDir, 'Just one', 'https://example.com', '2024-06-15T10:30:00.000Z');
const messages = readInbox(inboxDir);
const output = formatInbox(messages);
expect(output).toContain('1 message)');
expect(output).not.toContain('messages)');
});
test('messages sorted newest first', () => {
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
writeTestInboxFile(inboxDir, 'older', 'https://example.com', '2024-06-15T10:00:00.000Z');
writeTestInboxFile(inboxDir, 'newer', 'https://example.com', '2024-06-15T11:00:00.000Z');
const messages = readInbox(inboxDir);
// Filenames sort lexicographically, reversed = newest first
expect(messages[0].userMessage).toBe('newer');
expect(messages[1].userMessage).toBe('older');
});
});
// ─── Malformed Files ─────────────────────────────────────────────
describe('inbox — malformed files', () => {
test('malformed JSON files are skipped gracefully', () => {
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
// Write a valid message
writeTestInboxFile(inboxDir, 'valid message', 'https://example.com', '2024-06-15T10:30:00.000Z');
// Write a malformed JSON file
fs.writeFileSync(
path.join(inboxDir, '2024-06-15T10-35-00.000Z-observation.json'),
'this is not valid json {{{',
);
const messages = readInbox(inboxDir);
expect(messages.length).toBe(1);
expect(messages[0].userMessage).toBe('valid message');
});
test('JSON file missing fields uses defaults', () => {
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
// Write a JSON file with missing fields
fs.writeFileSync(
path.join(inboxDir, '2024-06-15T10-30-00.000Z-observation.json'),
JSON.stringify({ type: 'observation' }),
);
const messages = readInbox(inboxDir);
expect(messages.length).toBe(1);
expect(messages[0].timestamp).toBe('');
expect(messages[0].url).toBe('unknown');
expect(messages[0].userMessage).toBe('');
});
});
// ─── Clear Flag ──────────────────────────────────────────────────
describe('inbox — --clear flag', () => {
test('files deleted after clear', () => {
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
writeTestInboxFile(inboxDir, 'message 1', 'https://example.com', '2024-06-15T10:30:00.000Z');
writeTestInboxFile(inboxDir, 'message 2', 'https://example.com', '2024-06-15T10:31:00.000Z');
// Verify files exist
const filesBefore = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
expect(filesBefore.length).toBe(2);
// Clear
const cleared = clearInbox(inboxDir);
expect(cleared).toBe(2);
// Verify files deleted
const filesAfter = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
expect(filesAfter.length).toBe(0);
});
test('clear on empty directory does nothing', () => {
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
const cleared = clearInbox(inboxDir);
expect(cleared).toBe(0);
});
test('clear preserves dotfiles', () => {
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
// Write a dotfile and a regular file
fs.writeFileSync(path.join(inboxDir, '.keep'), '');
writeTestInboxFile(inboxDir, 'to be cleared', 'https://example.com', '2024-06-15T10:30:00.000Z');
clearInbox(inboxDir);
// Dotfile should remain
expect(fs.existsSync(path.join(inboxDir, '.keep'))).toBe(true);
// Regular file should be gone
const jsonFiles = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
expect(jsonFiles.length).toBe(0);
});
});
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Test Page - Iframe</title>
<style>
body { font-family: sans-serif; padding: 20px; }
iframe { border: 1px solid #ccc; width: 400px; height: 200px; }
</style>
</head>
<body>
<h1 id="main-title">Main Page</h1>
<iframe id="test-frame" name="testframe" srcdoc='
<!DOCTYPE html>
<html>
<body>
<h1 id="frame-title">Inside Frame</h1>
<button id="frame-btn">Frame Button</button>
<input id="frame-input" type="text" placeholder="Type here">
<div id="frame-result"></div>
<script>
document.getElementById("frame-btn").addEventListener("click", () => {
document.getElementById("frame-result").textContent = "Frame button clicked";
});
</script>
</body>
</html>
'></iframe>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Test Page - Network Idle</title>
<style>
body { font-family: sans-serif; padding: 20px; }
#result { margin-top: 10px; color: green; }
</style>
</head>
<body>
<button id="fetch-btn">Load Data</button>
<div id="result"></div>
<button id="static-btn">Static Action</button>
<div id="static-result"></div>
<script>
document.getElementById('fetch-btn').addEventListener('click', async () => {
// Simulate an XHR that takes 200ms
const res = await fetch('/echo');
const data = await res.json();
document.getElementById('result').textContent = 'Data loaded: ' + Object.keys(data).length + ' headers';
});
document.getElementById('static-btn').addEventListener('click', () => {
// No network activity — purely client-side
document.getElementById('static-result').textContent = 'Static action done';
});
</script>
</body>
</html>
+199
View File
@@ -0,0 +1,199 @@
/**
* Tests for sidebar agent queue parsing and inbox writing.
*
* sidebar-agent.ts functions are not exported (it's an entry-point script),
* so we test the same logic inline: JSONL parsing, writeToInbox filesystem
* behavior, and edge cases.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// ─── Helpers: replicate sidebar-agent logic for unit testing ──────
/** Parse a single JSONL line — same logic as sidebar-agent poll() */
function parseQueueLine(line: string): any | null {
if (!line.trim()) return null;
try {
const entry = JSON.parse(line);
if (!entry.message && !entry.prompt) return null;
return entry;
} catch {
return null;
}
}
/** Read all valid entries from a JSONL string — same as countLines + readLine loop */
function parseQueueFile(content: string): any[] {
const entries: any[] = [];
const lines = content.split('\n').filter(Boolean);
for (const line of lines) {
const entry = parseQueueLine(line);
if (entry) entries.push(entry);
}
return entries;
}
/** Write to inbox — extracted logic from sidebar-agent.ts writeToInbox() */
function writeToInbox(
gitRoot: string,
message: string,
pageUrl?: string,
sessionId?: string,
): string | null {
if (!gitRoot) return null;
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
const now = new Date();
const timestamp = now.toISOString().replace(/:/g, '-');
const filename = `${timestamp}-observation.json`;
const tmpFile = path.join(inboxDir, `.${filename}.tmp`);
const finalFile = path.join(inboxDir, filename);
const inboxMessage = {
type: 'observation',
timestamp: now.toISOString(),
page: { url: pageUrl || 'unknown', title: '' },
userMessage: message,
sidebarSessionId: sessionId || 'unknown',
};
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2));
fs.renameSync(tmpFile, finalFile);
return finalFile;
}
// ─── Test setup ──────────────────────────────────────────────────
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-agent-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ─── Queue File Parsing ─────────────────────────────────────────
describe('queue file parsing', () => {
test('valid JSONL line parsed correctly', () => {
const line = JSON.stringify({ message: 'hello', prompt: 'check this', pageUrl: 'https://example.com' });
const entry = parseQueueLine(line);
expect(entry).not.toBeNull();
expect(entry.message).toBe('hello');
expect(entry.prompt).toBe('check this');
expect(entry.pageUrl).toBe('https://example.com');
});
test('malformed JSON line skipped without crash', () => {
const entry = parseQueueLine('this is not json {{{');
expect(entry).toBeNull();
});
test('valid JSON without message or prompt is skipped', () => {
const line = JSON.stringify({ foo: 'bar' });
const entry = parseQueueLine(line);
expect(entry).toBeNull();
});
test('empty file returns no entries', () => {
const entries = parseQueueFile('');
expect(entries).toEqual([]);
});
test('file with blank lines returns no entries', () => {
const entries = parseQueueFile('\n\n\n');
expect(entries).toEqual([]);
});
test('mixed valid and invalid lines', () => {
const content = [
JSON.stringify({ message: 'first' }),
'not json',
JSON.stringify({ unrelated: true }),
JSON.stringify({ message: 'second', prompt: 'do stuff' }),
].join('\n');
const entries = parseQueueFile(content);
expect(entries.length).toBe(2);
expect(entries[0].message).toBe('first');
expect(entries[1].message).toBe('second');
});
});
// ─── writeToInbox ────────────────────────────────────────────────
describe('writeToInbox', () => {
test('creates .context/sidebar-inbox/ directory', () => {
writeToInbox(tmpDir, 'test message');
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
expect(fs.existsSync(inboxDir)).toBe(true);
expect(fs.statSync(inboxDir).isDirectory()).toBe(true);
});
test('writes valid JSON file', () => {
const filePath = writeToInbox(tmpDir, 'test message', 'https://example.com', 'session-123');
expect(filePath).not.toBeNull();
expect(fs.existsSync(filePath!)).toBe(true);
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
expect(data.type).toBe('observation');
expect(data.userMessage).toBe('test message');
expect(data.page.url).toBe('https://example.com');
expect(data.sidebarSessionId).toBe('session-123');
expect(data.timestamp).toBeTruthy();
});
test('atomic write — final file exists, no .tmp left', () => {
const filePath = writeToInbox(tmpDir, 'atomic test');
expect(filePath).not.toBeNull();
expect(fs.existsSync(filePath!)).toBe(true);
// Check no .tmp files remain in the inbox directory
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
const files = fs.readdirSync(inboxDir);
const tmpFiles = files.filter(f => f.endsWith('.tmp'));
expect(tmpFiles.length).toBe(0);
// Final file should end with -observation.json
const jsonFiles = files.filter(f => f.endsWith('-observation.json') && !f.startsWith('.'));
expect(jsonFiles.length).toBe(1);
});
test('handles missing git root gracefully', () => {
const result = writeToInbox('', 'test');
expect(result).toBeNull();
});
test('defaults pageUrl to unknown when not provided', () => {
const filePath = writeToInbox(tmpDir, 'no url provided');
expect(filePath).not.toBeNull();
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
expect(data.page.url).toBe('unknown');
});
test('defaults sessionId to unknown when not provided', () => {
const filePath = writeToInbox(tmpDir, 'no session');
expect(filePath).not.toBeNull();
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
expect(data.sidebarSessionId).toBe('unknown');
});
test('multiple writes create separate files', () => {
writeToInbox(tmpDir, 'message 1');
// Tiny delay to ensure different timestamps
const t = Date.now();
while (Date.now() === t) {} // spin until next ms
writeToInbox(tmpDir, 'message 2');
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
const files = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
expect(files.length).toBe(2);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Tests for watch mode state machine in BrowserManager.
*
* Pure unit tests — no browser needed. Just instantiate BrowserManager
* and test the watch state methods (startWatch, stopWatch, addWatchSnapshot,
* isWatching).
*/
import { describe, test, expect } from 'bun:test';
import { BrowserManager } from '../src/browser-manager';
describe('watch mode — state machine', () => {
test('isWatching returns false by default', () => {
const bm = new BrowserManager();
expect(bm.isWatching()).toBe(false);
});
test('startWatch sets isWatching to true', () => {
const bm = new BrowserManager();
bm.startWatch();
expect(bm.isWatching()).toBe(true);
});
test('stopWatch clears isWatching and returns snapshots', () => {
const bm = new BrowserManager();
bm.startWatch();
bm.addWatchSnapshot('snapshot-1');
bm.addWatchSnapshot('snapshot-2');
const result = bm.stopWatch();
expect(bm.isWatching()).toBe(false);
expect(result.snapshots).toEqual(['snapshot-1', 'snapshot-2']);
expect(result.snapshots.length).toBe(2);
});
test('stopWatch returns correct duration (approximately)', async () => {
const bm = new BrowserManager();
bm.startWatch();
// Wait ~50ms to get a measurable duration
await new Promise(resolve => setTimeout(resolve, 50));
const result = bm.stopWatch();
// Duration should be at least 40ms (allowing for timer imprecision)
expect(result.duration).toBeGreaterThanOrEqual(40);
// And less than 5 seconds (sanity check)
expect(result.duration).toBeLessThan(5000);
});
test('addWatchSnapshot stores snapshots', () => {
const bm = new BrowserManager();
bm.startWatch();
bm.addWatchSnapshot('page A content');
bm.addWatchSnapshot('page B content');
bm.addWatchSnapshot('page C content');
const result = bm.stopWatch();
expect(result.snapshots.length).toBe(3);
expect(result.snapshots[0]).toBe('page A content');
expect(result.snapshots[1]).toBe('page B content');
expect(result.snapshots[2]).toBe('page C content');
});
test('stopWatch resets snapshots for next cycle', () => {
const bm = new BrowserManager();
// First cycle
bm.startWatch();
bm.addWatchSnapshot('first-cycle-snapshot');
const result1 = bm.stopWatch();
expect(result1.snapshots.length).toBe(1);
// Second cycle — should start fresh
bm.startWatch();
const result2 = bm.stopWatch();
expect(result2.snapshots.length).toBe(0);
});
test('multiple start/stop cycles work correctly', () => {
const bm = new BrowserManager();
// Cycle 1
bm.startWatch();
expect(bm.isWatching()).toBe(true);
bm.addWatchSnapshot('snap-1');
const r1 = bm.stopWatch();
expect(bm.isWatching()).toBe(false);
expect(r1.snapshots).toEqual(['snap-1']);
// Cycle 2
bm.startWatch();
expect(bm.isWatching()).toBe(true);
bm.addWatchSnapshot('snap-2a');
bm.addWatchSnapshot('snap-2b');
const r2 = bm.stopWatch();
expect(bm.isWatching()).toBe(false);
expect(r2.snapshots).toEqual(['snap-2a', 'snap-2b']);
// Cycle 3 — no snapshots added
bm.startWatch();
expect(bm.isWatching()).toBe(true);
const r3 = bm.stopWatch();
expect(bm.isWatching()).toBe(false);
expect(r3.snapshots).toEqual([]);
});
test('stopWatch clears watchInterval if set', () => {
const bm = new BrowserManager();
bm.startWatch();
// Simulate an interval being set (as the server does)
bm.watchInterval = setInterval(() => {}, 100000);
expect(bm.watchInterval).not.toBeNull();
bm.stopWatch();
expect(bm.watchInterval).toBeNull();
});
test('stopWatch without startWatch returns empty results', () => {
const bm = new BrowserManager();
// Calling stopWatch without startWatch should not throw
const result = bm.stopWatch();
expect(result.snapshots).toEqual([]);
expect(result.duration).toBeLessThanOrEqual(Date.now()); // duration = now - 0
expect(bm.isWatching()).toBe(false);
});
});