/** * gstack CLI — thin wrapper that talks to the persistent server * * Flow: * 1. Read /tmp/browse-server.json for port + token * 2. If missing or stale PID → start server in background * 3. Health check * 4. Send command via HTTP POST * 5. Print response to stdout (or stderr for errors) */ import * as fs from 'fs'; import * as path from 'path'; const PORT_OFFSET = 45600; const BROWSE_PORT = process.env.CONDUCTOR_PORT ? parseInt(process.env.CONDUCTOR_PORT, 10) - PORT_OFFSET : parseInt(process.env.BROWSE_PORT || '0', 10); const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : ''; const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`; const MAX_START_WAIT = 8000; // 8 seconds to start export function resolveServerScript( env: Record = process.env, metaDir: string = import.meta.dir, execPath: string = process.execPath ): string { if (env.BROWSE_SERVER_SCRIPT) { return env.BROWSE_SERVER_SCRIPT; } // Dev mode: cli.ts runs directly from browse/src if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) { const direct = path.resolve(metaDir, 'server.ts'); if (fs.existsSync(direct)) { return direct; } } // Compiled binary: derive the source tree from browse/dist/browse if (execPath) { const adjacent = path.resolve(path.dirname(execPath), '..', 'src', 'server.ts'); if (fs.existsSync(adjacent)) { return adjacent; } } // Legacy fallback for user-level installs return path.resolve(env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts'); } const SERVER_SCRIPT = resolveServerScript(); interface ServerState { pid: number; port: number; token: string; startedAt: string; serverPath: string; } // ─── State File ──────────────────────────────────────────────── function readState(): ServerState | null { try { const data = fs.readFileSync(STATE_FILE, 'utf-8'); return JSON.parse(data); } catch { return null; } } function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } // ─── Server Lifecycle ────────────────────────────────────────── async function startServer(): Promise { // Clean up stale state file try { fs.unlinkSync(STATE_FILE); } catch {} // Start server as detached background process const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env }, }); // Don't hold the CLI open proc.unref(); // Wait for state file to appear const start = Date.now(); while (Date.now() - start < MAX_START_WAIT) { const state = readState(); if (state && isProcessAlive(state.pid)) { return state; } await Bun.sleep(100); } // If we get here, server didn't start in time // Try to read stderr for error message const stderr = proc.stderr; if (stderr) { const reader = stderr.getReader(); const { value } = await reader.read(); if (value) { const errText = new TextDecoder().decode(value); throw new Error(`Server failed to start:\n${errText}`); } } throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`); } async function ensureServer(): Promise { const state = readState(); if (state && isProcessAlive(state.pid)) { // Server appears alive — do a health check try { const resp = await fetch(`http://127.0.0.1:${state.port}/health`, { signal: AbortSignal.timeout(2000), }); if (resp.ok) { const health = await resp.json() as any; if (health.status === 'healthy') { return state; } } } catch { // Health check failed — server is dead or unhealthy } } // Need to (re)start console.error('[browse] Starting server...'); return startServer(); } // ─── Command Dispatch ────────────────────────────────────────── async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise { const body = JSON.stringify({ command, args }); try { const resp = await fetch(`http://127.0.0.1:${state.port}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${state.token}`, }, body, signal: AbortSignal.timeout(30000), }); if (resp.status === 401) { // Token mismatch — server may have restarted console.error('[browse] Auth failed — server may have restarted. Retrying...'); const newState = readState(); if (newState && newState.token !== state.token) { return sendCommand(newState, command, args); } throw new Error('Authentication failed'); } const text = await resp.text(); if (resp.ok) { process.stdout.write(text); if (!text.endsWith('\n')) process.stdout.write('\n'); } else { // Try to parse as JSON error try { const err = JSON.parse(text); console.error(err.error || text); if (err.hint) console.error(err.hint); } catch { console.error(text); } process.exit(1); } } catch (err: any) { if (err.name === 'AbortError') { console.error('[browse] Command timed out after 30s'); process.exit(1); } // Connection error — server may have crashed if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) { if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting'); console.error('[browse] Server connection lost. Restarting...'); const newState = await startServer(); return sendCommand(newState, command, args, retries + 1); } throw err; } } // ─── Main ────────────────────────────────────────────────────── async function main() { const args = process.argv.slice(2); if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { console.log(`gstack browse — Fast headless browser for AI coding agents Usage: browse [args...] Navigation: goto | back | forward | reload | url Content: text | html [sel] | links | forms | accessibility Interaction: click | fill | select hover | type | press scroll [sel] | wait | viewport Inspection: js | eval | css | attrs console [--clear] | network [--clear] cookies | storage [set ] | perf Visual: screenshot [path] | pdf [path] | responsive [prefix] Snapshot: snapshot [-i] [-c] [-d N] [-s sel] Compare: diff Multi-step: chain (reads JSON from stdin) Tabs: tabs | tab | newtab [url] | closetab [id] Server: status | cookie = | header : useragent | stop | restart Refs: After 'snapshot', use @e1, @e2... as selectors: click @e3 | fill @e4 "value" | hover @e1`); process.exit(0); } const command = args[0]; const commandArgs = args.slice(1); // Special case: chain reads from stdin if (command === 'chain' && commandArgs.length === 0) { const stdin = await Bun.stdin.text(); commandArgs.push(stdin.trim()); } const state = await ensureServer(); await sendCommand(state, command, commandArgs); } if (import.meta.main) { main().catch((err) => { console.error(`[browse] ${err.message}`); process.exit(1); }); }