     1→/**
     2→ * Snapshot command — accessibility tree with ref-based element selection
     3→ *
     4→ * Architecture (Locator map — no DOM mutation):
     5→ *   1. page.locator(scope).ariaSnapshot() → YAML-like accessibility tree
     6→ *   2. Parse tree, assign refs @e1, @e2, ...
     7→ *   3. Build Playwright Locator for each ref (getByRole + nth)
     8→ *   4. Store Map<string, Locator> on BrowserManager
     9→ *   5. Return compact text output with refs prepended
    10→ *
    11→ * Extended features:
    12→ *   --diff / -D:       Compare against last snapshot, return unified diff
    13→ *   --annotate / -a:   Screenshot with overlay boxes at each @ref
    14→ *   --output / -o:     Output path for annotated screenshot
    15→ *   -C / --cursor-interactive: Scan for cursor:pointer/onclick/tabindex elements
    16→ *
    17→ * Later: "click @e3" → look up Locator → locator.click()
    18→ */
    19→
    20→import type { Page, Locator } from 'playwright';
    21→import type { BrowserManager, RefEntry } from './browser-manager';
    22→import * as Diff from 'diff';
    23→
    24→// Roles considered "interactive" for the -i flag
    25→const INTERACTIVE_ROLES = new Set([
    26→  'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
    27→  'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
    28→  'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab',
    29→  'treeitem',
    30→]);
    31→
    32→interface SnapshotOptions {
    33→  interactive?: boolean;       // -i: only interactive elements
    34→  compact?: boolean;           // -c: remove empty structural elements
    35→  depth?: number;              // -d N: limit tree depth
    36→  selector?: string;           // -s SEL: scope to CSS selector
    37→  diff?: boolean;              // -D / --diff: diff against last snapshot
    38→  annotate?: boolean;          // -a / --annotate: annotated screenshot
    39→  outputPath?: string;         // -o / --output: path for annotated screenshot
    40→  cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc.
    41→}
    42→
    43→/**
    44→ * Snapshot flag metadata — single source of truth for CLI parsing and doc generation.
    45→ *
    46→ * Imported by:
    47→ *   - gen-skill-docs.ts (generates {{SNAPSHOT_FLAGS}} tables)
    48→ *   - skill-parser.ts (validates flags in SKILL.md examples)
    49→ */
    50→export const SNAPSHOT_FLAGS: Array<{
    51→  short: string;
    52→  long: string;
    53→  description: string;
    54→  takesValue?: boolean;
    55→  valueHint?: string;
    56→  optionKey: keyof SnapshotOptions;
    57→}> = [
    58→  { short: '-i', long: '--interactive', description: 'Interactive elements only (buttons, links, inputs) with @e refs', optionKey: 'interactive' },
    59→  { short: '-c', long: '--compact', description: 'Compact (no empty structural nodes)', optionKey: 'compact' },
    60→  { short: '-d', long: '--depth', description: 'Limit tree depth (0 = root only, default: unlimited)', takesValue: true, valueHint: '<N>', optionKey: 'depth' },
    61→  { short: '-s', long: '--selector', description: 'Scope to CSS selector', takesValue: true, valueHint: '<sel>', optionKey: 'selector' },
    62→  { short: '-D', long: '--diff', description: 'Unified diff against previous snapshot (first call stores baseline)', optionKey: 'diff' },
    63→  { short: '-a', long: '--annotate', description: 'Annotated screenshot with red overlay boxes and ref labels', optionKey: 'annotate' },
    64→  { short: '-o', long: '--output', description: 'Output path for annotated screenshot (default: /tmp/browse-annotated.png)', takesValue: true, valueHint: '<path>', optionKey: 'outputPath' },
    65→  { short: '-C', long: '--cursor-interactive', description: 'Cursor-interactive elements (@c refs — divs with pointer, onclick)', optionKey: 'cursorInteractive' },
    66→];
    67→
    68→interface ParsedNode {
    69→  indent: number;
    70→  role: string;
    71→  name: string | null;
    72→  props: string;      // e.g., "[level=1]"
    73→  children: string;   // inline text content after ":"
    74→  rawLine: string;
    75→}
    76→
    77→/**
    78→ * Parse CLI args into SnapshotOptions — driven by SNAPSHOT_FLAGS metadata.
    79→ */
    80→export function parseSnapshotArgs(args: string[]): SnapshotOptions {
    81→  const opts: SnapshotOptions = {};
    82→  for (let i = 0; i < args.length; i++) {
    83→    const flag = SNAPSHOT_FLAGS.find(f => f.short === args[i] || f.long === args[i]);
    84→    if (!flag) throw new Error(`Unknown snapshot flag: ${args[i]}`);
    85→    if (flag.takesValue) {
    86→      const value = args[++i];
    87→      if (!value) throw new Error(`Usage: snapshot ${flag.short} <value>`);
    88→      if (flag.optionKey === 'depth') {
    89→        (opts as any)[flag.optionKey] = parseInt(value, 10);
    90→        if (isNaN(opts.depth!)) throw new Error('Usage: snapshot -d <number>');
    91→      } else {
    92→        (opts as any)[flag.optionKey] = value;
    93→      }
    94→    } else {
    95→      (opts as any)[flag.optionKey] = true;
    96→    }
    97→  }
    98→  return opts;
    99→}
   100→
   101→/**
   102→ * Parse one line of ariaSnapshot output.
   103→ *
   104→ * Format examples:
   105→ *   - heading "Test" [level=1]
   106→ *   - link "Link A":
   107→ *     - /url: /a
   108→ *   - textbox "Name"
   109→ *   - paragraph: Some text
   110→ *   - combobox "Role":
   111→ */
   112→function parseLine(line: string): ParsedNode | null {
   113→  // Match: (indent)(- )(role)( "name")?( [props])?(: inline)?
   114→  const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/);
   115→  if (!match) {
   116→    // Skip metadata lines like "- /url: /a"
   117→    return null;
   118→  }
   119→  return {
   120→    indent: match[1].length,
   121→    role: match[2],
   122→    name: match[3] ?? null,
   123→    props: match[4] || '',
   124→    children: match[5]?.trim() || '',
   125→    rawLine: line,
   126→  };
   127→}
   128→
   129→/**
   130→ * Take an accessibility snapshot and build the ref map.
   131→ */
   132→export async function handleSnapshot(
   133→  args: string[],
   134→  bm: BrowserManager
   135→): Promise<string> {
   136→  const opts = parseSnapshotArgs(args);
   137→  const page = bm.getPage();
   138→
   139→  // Get accessibility tree via ariaSnapshot
   140→  let rootLocator: Locator;
   141→  if (opts.selector) {
   142→    rootLocator = page.locator(opts.selector);
   143→    const count = await rootLocator.count();
   144→    if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
   145→  } else {
   146→    rootLocator = page.locator('body');
   147→  }
   148→
   149→  const ariaText = await rootLocator.ariaSnapshot();
   150→  if (!ariaText || ariaText.trim().length === 0) {


Let me now read the server.ts to understand the full architecture:


bash "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags-shell.sh" "pre:observe" "skills/continuous-learning-v2/hooks/observe.sh" "standard,strict"


bash "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags-shell.sh" "post:observe" "skills/continuous-learning-v2/hooks/observe.sh" "standard,strict"


     1→/**
     2→ * gstack browse server — persistent Chromium daemon
     3→ *
     4→ * Architecture:
     5→ *   Bun.serve HTTP on localhost → routes commands to Playwright
     6→ *   Console/network/dialog buffers: CircularBuffer in-memory + async disk flush
     7→ *   Chromium crash → server EXITS with clear error (CLI auto-restarts)
     8→ *   Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min)
     9→ *
    10→ * State:
    11→ *   State file: <project-root>/.gstack/browse.json (set via BROWSE_STATE_FILE env)
    12→ *   Log files:  <project-root>/.gstack/browse-{console,network,dialog}.log
    13→ *   Port:       random 10000-60000 (or BROWSE_PORT env for debug override)
    14→ */
    15→
    16→import { BrowserManager } from './browser-manager';
    17→import { handleReadCommand } from './read-commands';
    18→import { handleWriteCommand } from './write-commands';
    19→import { handleMetaCommand } from './meta-commands';
    20→import { handleCookiePickerRoute } from './cookie-picker-routes';
    21→import { COMMAND_DESCRIPTIONS } from './commands';
    22→import { SNAPSHOT_FLAGS } from './snapshot';
    23→import { resolveConfig, ensureStateDir, readVersionHash } from './config';
    24→import * as fs from 'fs';
    25→import * as path from 'path';
    26→import * as crypto from 'crypto';
    27→
    28→// ─── Config ─────────────────────────────────────────────────────
    29→const config = resolveConfig();
    30→ensureStateDir(config);
    31→
    32→// ─── Auth ───────────────────────────────────────────────────────
    33→const AUTH_TOKEN = crypto.randomUUID();
    34→const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
    35→const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
    36→
    37→function validateAuth(req: Request): boolean {
    38→  const header = req.headers.get('authorization');
    39→  return header === `Bearer ${AUTH_TOKEN}`;
    40→}
    41→
    42→// ─── Help text (auto-generated from COMMAND_DESCRIPTIONS) ────────
    43→function generateHelpText(): string {
    44→  // Group commands by category
    45→  const groups = new Map<string, string[]>();
    46→  for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) {
    47→    const display = meta.usage || cmd;
    48→    const list = groups.get(meta.category) || [];
    49→    list.push(display);
    50→    groups.set(meta.category, list);
    51→  }
    52→
    53→  const categoryOrder = [
    54→    'Navigation', 'Reading', 'Interaction', 'Inspection',
    55→    'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server',
    56→  ];
    57→
    58→  const lines = ['gstack browse — headless browser for AI agents', '', 'Commands:'];
    59→  for (const cat of categoryOrder) {
    60→    const cmds = groups.get(cat);
    61→    if (!cmds) continue;
    62→    lines.push(`  ${(cat + ':').padEnd(15)}${cmds.join(', ')}`);
    63→  }
    64→
    65→  // Snapshot flags from source of truth
    66→  lines.push('');
    67→  lines.push('Snapshot flags:');
    68→  const flagPairs: string[] = [];
    69→  for (const flag of SNAPSHOT_FLAGS) {
    70→    const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short;
    71→    flagPairs.push(`${label}  ${flag.long}`);
    72→  }
    73→  // Print two flags per line for compact display
    74→  for (let i = 0; i < flagPairs.length; i += 2) {
    75→    const left = flagPairs[i].padEnd(28);
    76→    const right = flagPairs[i + 1] || '';
    77→    lines.push(`  ${left}${right}`);
    78→  }
    79→
    80→  return lines.join('\n');
    81→}
    82→
    83→// ─── Buffer (from buffers.ts) ────────────────────────────────────
    84→import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers';
    85→export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry };
    86→
    87→const CONSOLE_LOG_PATH = config.consoleLog;
    88→const NETWORK_LOG_PATH = config.networkLog;
    89→const DIALOG_LOG_PATH = config.dialogLog;
    90→let lastConsoleFlushed = 0;
    91→let lastNetworkFlushed = 0;
    92→let lastDialogFlushed = 0;
    93→let flushInProgress = false;
    94→
    95→async function flushBuffers() {
    96→  if (flushInProgress) return; // Guard against concurrent flush
    97→  flushInProgress = true;
    98→
    99→  try {
   100→    // Console buffer
   101→    const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed;
   102→    if (newConsoleCount > 0) {
   103→      const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length));
   104→      const lines = entries.map(e =>
   105→        `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
   106→      ).join('\n') + '\n';
   107→      fs.appendFileSync(CONSOLE_LOG_PATH, lines);
   108→      lastConsoleFlushed = consoleBuffer.totalAdded;
   109→    }
   110→
   111→    // Network buffer
   112→    const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed;
   113→    if (newNetworkCount > 0) {
   114→      const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length));
   115→      const lines = entries.map(e =>
   116→        `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
   117→      ).join('\n') + '\n';
   118→      fs.appendFileSync(NETWORK_LOG_PATH, lines);
   119→      lastNetworkFlushed = networkBuffer.totalAdded;
   120→    }
   121→
   122→    // Dialog buffer
   123→    const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed;
   124→    if (newDialogCount > 0) {
   125→      const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length));
   126→      const lines = entries.map(e =>
   127→        `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
   128→      ).join('\n') + '\n';
   129→      fs.appendFileSync(DIALOG_LOG_PATH, lines);
   130→      lastDialogFlushed = dialogBuffer.totalAdded;
   131→    }
   132→  } catch {
   133→    // Flush failures are non-fatal — buffers are in memory
   134→  } finally {
   135→    flushInProgress = false;
   136→  }
   137→}
   138→
   139→// Flush every 1 second
   140→const flushInterval = setInterval(flushBuffers, 1000);
   141→
   142→// ─── Idle Timer ────────────────────────────────────────────────
   143→let lastActivity = Date.now();
   144→
   145→function resetIdleTimer() {
   146→  lastActivity = Date.now();
   147→}
   148→
   149→const idleCheckInterval = setInterval(() => {
   150→  if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
   151→    console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`);
   152→    shutdown();
   153→  }
   154→}, 60_000);
   155→
   156→// ─── Command Sets (from commands.ts — single source of truth) ───
   157→import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
   158→export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
   159→
   160→// ─── Server ────────────────────────────────────────────────────
   161→const browserManager = new BrowserManager();
   162→let isShuttingDown = false;
   163→
   164→// Find port: explicit BROWSE_PORT, or random in 10000-60000
   165→async function findPort(): Promise<number> {
   166→  // Explicit port override (for debugging)
   167→  if (BROWSE_PORT) {
   168→    try {
   169→      const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') });
   170→      testServer.stop();
   171→      return BROWSE_PORT;
   172→    } catch {
   173→      throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`);
   174→    }
   175→  }
   176→
   177→  // Random port with retry
   178→  const MIN_PORT = 10000;
   179→  const MAX_PORT = 60000;
   180→  const MAX_RETRIES = 5;
   181→  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
   182→    const port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT));
   183→    try {
   184→      const testServer = Bun.serve({ port, fetch: () => new Response('ok') });
   185→      testServer.stop();
   186→      return port;
   187→    } catch {
   188→      continue;
   189→    }
   190→  }
   191→  throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`);
   192→}
   193→
   194→/**
   195→ * Translate Playwright errors into actionable messages for AI agents.
   196→ */
   197→function wrapError(err: any): string {
   198→  const msg = err.message || String(err);
   199→  // Timeout errors
   200→  if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) {
   201→    if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) {
   202→      return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`;
   203→    }
   204→    if (msg.includes('page.goto') || msg.includes('Navigation')) {
   205→      return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`;
   206→    }
   207→    return `Operation timed out: ${msg.split('\n')[0]}`;
   208→  }
   209→  // Multiple elements matched
   210→  if (msg.includes('resolved to') && msg.includes('elements')) {
   211→    return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`;
   212→  }
   213→  // Pass through other errors
   214→  return msg;
   215→}
   216→
   217→async function handleCommand(body: any): Promise<Response> {
   218→  const { command, args = [] } = body;
   219→
   220→  if (!command) {
   221→    return new Response(JSON.stringify({ error: 'Missing "command" field' }), {
   222→      status: 400,
   223→      headers: { 'Content-Type': 'application/json' },
   224→    });
   225→  }
   226→
   227→  try {
   228→    let result: string;
   229→
   230→    if (READ_COMMANDS.has(command)) {
   231→      result = await handleReadCommand(command, args, browserManager);
   232→    } else if (WRITE_COMMANDS.has(command)) {
   233→      result = await handleWriteCommand(command, args, browserManager);
   234→    } else if (META_COMMANDS.has(command)) {
   235→      result = await handleMetaCommand(command, args, browserManager, shutdown);
   236→    } else if (command === 'help') {
   237→      const helpText = generateHelpText();
   238→      return new Response(helpText, {
   239→        status: 200,
   240→        headers: { 'Content-Type': 'text/plain' },
   241→      });
   242→    } else {
   243→      return new Response(JSON.stringify({
   244→        error: `Unknown command: ${command}`,
   245→        hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`,
   246→      }), {
   247→        status: 400,
   248→        headers: { 'Content-Type': 'application/json' },
   249→      });
   250→    }


     1→/**
     2→ * Browser lifecycle manager
     3→ *
     4→ * Chromium crash handling:
     5→ *   browser.on('disconnected') → log error → process.exit(1)
     6→ *   CLI detects dead server → auto-restarts on next command
     7→ *   We do NOT try to self-heal — don't hide failure.
     8→ *
     9→ * Dialog handling:
    10→ *   page.on('dialog') → auto-accept by default → store in dialog buffer
    11→ *   Prevents browser lockup from alert/confirm/prompt
    12→ *
    13→ * Context recreation (useragent):
    14→ *   recreateContext() saves cookies/storage/URLs, creates new context,
    15→ *   restores state. Falls back to clean slate on any failure.
    16→ */
    17→
    18→import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator } from 'playwright';
    19→import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
    20→
    21→export interface RefEntry {
    22→  locator: Locator;
    23→  role: string;
    24→  name: string;
    25→}
    26→
    27→export class BrowserManager {
    28→  private browser: Browser | null = null;
    29→  private context: BrowserContext | null = null;
    30→  private pages: Map<number, Page> = new Map();
    31→  private activeTabId: number = 0;
    32→  private nextTabId: number = 1;
    33→  private extraHeaders: Record<string, string> = {};
    34→  private customUserAgent: string | null = null;
    35→
    36→  /** Server port — set after server starts, used by cookie-import-browser command */
    37→  public serverPort: number = 0;
    38→
    39→  // ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ────────
    40→  private refMap: Map<string, RefEntry> = new Map();
    41→
    42→  // ─── Snapshot Diffing ─────────────────────────────────────
    43→  // NOT cleared on navigation — it's a text baseline for diffing
    44→  private lastSnapshot: string | null = null;
    45→
    46→  // ─── Dialog Handling ──────────────────────────────────────
    47→  private dialogAutoAccept: boolean = true;
    48→  private dialogPromptText: string | null = null;
    49→
    50→  async launch() {
    51→    this.browser = await chromium.launch({ headless: true });
    52→
    53→    // Chromium crash → exit with clear message
    54→    this.browser.on('disconnected', () => {
    55→      console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
    56→      console.error('[browse] Console/network logs flushed to .gstack/browse-*.log');
    57→      process.exit(1);
    58→    });
    59→
    60→    const contextOptions: BrowserContextOptions = {
    61→      viewport: { width: 1280, height: 720 },
    62→    };
    63→    if (this.customUserAgent) {
    64→      contextOptions.userAgent = this.customUserAgent;
    65→    }
    66→    this.context = await this.browser.newContext(contextOptions);
    67→
    68→    if (Object.keys(this.extraHeaders).length > 0) {
    69→      await this.context.setExtraHTTPHeaders(this.extraHeaders);
    70→    }
    71→
    72→    // Create first tab
    73→    await this.newTab();
    74→  }
    75→
    76→  async close() {
    77→    if (this.browser) {
    78→      // Remove disconnect handler to avoid exit during intentional close
    79→      this.browser.removeAllListeners('disconnected');
    80→      await this.browser.close();
    81→      this.browser = null;
    82→    }
    83→  }
    84→
    85→  /** Health check — verifies Chromium is connected AND responsive */
    86→  async isHealthy(): Promise<boolean> {
    87→    if (!this.browser || !this.browser.isConnected()) return false;
    88→    try {
    89→      const page = this.pages.get(this.activeTabId);
    90→      if (!page) return true; // connected but no pages — still healthy
    91→      await Promise.race([
    92→        page.evaluate('1'),
    93→        new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000)),
    94→      ]);
    95→      return true;
    96→    } catch {
    97→      return false;
    98→    }
    99→  }
   100→
   101→  // ─── Tab Management ────────────────────────────────────────
   102→  async newTab(url?: string): Promise<number> {
   103→    if (!this.context) throw new Error('Browser not launched');
   104→
   105→    const page = await this.context.newPage();
   106→    const id = this.nextTabId++;
   107→    this.pages.set(id, page);
   108→    this.activeTabId = id;
   109→
   110→    // Wire up console/network/dialog capture
   111→    this.wirePageEvents(page);
   112→
   113→    if (url) {
   114→      await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
   115→    }
   116→
   117→    return id;
   118→  }
   119→
   120→  async closeTab(id?: number): Promise<void> {
   121→    const tabId = id ?? this.activeTabId;
   122→    const page = this.pages.get(tabId);
   123→    if (!page) throw new Error(`Tab ${tabId} not found`);
   124→
   125→    await page.close();
   126→    this.pages.delete(tabId);
   127→
   128→    // Switch to another tab if we closed the active one
   129→    if (tabId === this.activeTabId) {
   130→      const remaining = [...this.pages.keys()];
   131→      if (remaining.length > 0) {
   132→        this.activeTabId = remaining[remaining.length - 1];
   133→      } else {
   134→        // No tabs left — create a new blank one
   135→        await this.newTab();
   136→      }
   137→    }
   138→  }
   139→
   140→  switchTab(id: number): void {
   141→    if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
   142→    this.activeTabId = id;
   143→  }
   144→
   145→  getTabCount(): number {
   146→    return this.pages.size;
   147→  }
   148→
   149→  async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean }>> {
   150→    const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
   151→    for (const [id, page] of this.pages) {
   152→      tabs.push({
   153→        id,
   154→        url: page.url(),
   155→        title: await page.title().catch(() => ''),
   156→        active: id === this.activeTabId,
   157→      });
   158→    }
   159→    return tabs;
   160→  }
   161→
   162→  // ─── Page Access ───────────────────────────────────────────
   163→  getPage(): Page {
   164→    const page = this.pages.get(this.activeTabId);
   165→    if (!page) throw new Error('No active page. Use "browse goto <url>" first.');
   166→    return page;
   167→  }
   168→
   169→  getCurrentUrl(): string {
   170→    try {
   171→      return this.getPage().url();
   172→    } catch {
   173→      return 'about:blank';
   174→    }
   175→  }
   176→
   177→  // ─── Ref Map ──────────────────────────────────────────────
   178→  setRefMap(refs: Map<string, RefEntry>) {
   179→    this.refMap = refs;
   180→  }
   181→
   182→  clearRefs() {
   183→    this.refMap.clear();
   184→  }
   185→
   186→  /**
   187→   * Resolve a selector that may be a @ref (e.g., "@e3", "@c1") or a CSS selector.
   188→   * Returns { locator } for refs or { selector } for CSS selectors.
   189→   */
   190→  async resolveRef(selector: string): Promise<{ locator: Locator } | { selector: string }> {
   191→    if (selector.startsWith('@e') || selector.startsWith('@c')) {
   192→      const ref = selector.slice(1); // "e3" or "c1"
   193→      const entry = this.refMap.get(ref);
   194→      if (!entry) {
   195→        throw new Error(
   196→          `Ref ${selector} not found. Run 'snapshot' to get fresh refs.`
   197→        );
   198→      }
   199→      const count = await entry.locator.count();
   200→      if (count === 0) {


Perfect! Now I have a comprehensive understanding of the gstack project. Let me prepare a detailed summary report.

## AI/Eval System and Testing Infrastructure - Comprehensive Exploration Report

### Project Overview

gstack is a sophisticated AI-assisted CLI development framework that blends headless browser automation with AI-powered code generation, testing, and skill validation. The system is designed around **skills** (autonomous workflows for AI agents) and a **multi-tier testing/eval infrastructure**.

---

## 1. AI EVALUATION PIPELINE

### Architecture (3-Tier Testing)

```
Tier 1 (Free, ~<1s):
  └─ skill-validation.test.ts — Static validation (no AI)
     • $B command syntax checking
     • Snapshot flag validation
     • SKILL.md freshness checks

Tier 2 (E2E, ~$3.85/run):
  └─ skill-e2e.test.ts — Full agent workflows via claude -p
     • Spawns independent `claude` subprocess
     • Streams NDJSON output real-time
     • Detects browse errors + exit codes
     • Planted-bug detection scoring

Tier 3 (LLM-Judge, ~$0.15/run):
  └─ skill-llm-eval.test.ts — Quality scoring via Anthropic API
     • claude-sonnet-4-6 evaluates doc clarity/completeness/actionability
     • Judges command reference, snapshot flags, setup instructions
```

### Key Files

| File | Purpose |
|------|---------|
| `/test/helpers/session-runner.ts` | Spawns `claude -p` subprocess, streams NDJSON, parses results |
| `/test/helpers/llm-judge.ts` | `callJudge<T>()`, `judge()`, `outcomeJudge()` for LLM-as-judge evaluation |
| `/test/helpers/eval-store.ts` | `EvalCollector` — accumulates results, writes to `~/.gstack-dev/evals/`, auto-compares vs previous run |
| `/test/helpers/touchfiles.ts` | Diff-based test selection (glob matching, git diff scanning) |

---

## 2. SESSION RUNNER (E2E ORCHESTRATION)

### `session-runner.ts` - Key Mechanisms

**Subprocess Architecture:**
- Spawns `claude -p --output-format stream-json --verbose --dangerously-skip-permissions --max-turns N`
- Prompt piped via stdin to avoid shell escaping
- Captures stdout as streaming NDJSON, stderr for errors
- Monitors process via `Bun.spawn()` with timeout racing

**Real-Time Progress Tracking:**
- Parses NDJSON events on-the-fly (no buffering)
- Emits progress to stderr: `[elapsed_s] turn N tool #M: ToolName(args...)`
- Writes heartbeat to `~/.gstack-dev/e2e-live.json` (atomic writes)
- Persists progress.log per run

**Result Parsing (`parseNDJSON`):**
```typescript
interface ParsedNDJSON {
  transcript: any[];          // Full event stream
  resultLine: any | null;     // Final result event
  turnCount: number;          // Assistant turns
  toolCallCount: number;
  toolCalls: Array<{ tool, input, output }>;
}
```

**Cost Tracking:**
- Extracts from `resultLine.total_cost_usd`, `usage.{input,output}_tokens`, `num_turns`
- Fallback: estimates from prompt/result char lengths
- Returns `CostEstimate` with USD cost, token usage, turn count

**Error Detection:**
- Scans transcript + stderr against `BROWSE_ERROR_PATTERNS`
- Captures: unknown commands, unknown flags, binary missing, server failure

**Failure Persistence:**
- Saves transcripts to `.gstack/test-transcripts/` or `~/.gstack-dev/e2e-runs/{runId}/`
- Includes: prompt (truncated), exit reason, browse errors, last tool call, stderr snippet

---

## 3. LLM-AS-JUDGE EVALUATION

### `llm-judge.ts` - Quality Assessment

**Judge Architecture:**
```typescript
async function callJudge<T>(prompt: string): Promise<T>
  → client.messages.create({ model: 'claude-sonnet-4-6', max_tokens: 1024 })
  → extract JSON from response
  → retry once on 429 rate limit
```

**Scoring Model (1-5 scale):**
```typescript
interface JudgeScore {
  clarity: number;         // Can agent understand what each command does?
  completeness: number;    // Are all args, values, behaviors documented?
  actionability: number;   // Can agent construct correct invocations?
  reasoning: string;       // Brief explanation
}
```

**Planted-Bug Detection (`outcomeJudge`):**
```typescript
interface OutcomeJudgeResult {
  detected: string[];      // Bug IDs found in report
  missed: string[];        // Bug IDs not found
  false_positives: number; // Issues not matching any planted bug
  detection_rate: number;  // Count of detected bugs
  evidence_quality: 1-5;   // Quality of evidence (screenshots, repro steps)
  reasoning: string;
}
```

---

## 4. EVAL RESULT STORAGE & COMPARISON

### `eval-store.ts` - Persistence & Analytics

**Storage Schema:**
```typescript
interface EvalResult {
  schema_version: number;
  version: string;
  branch: string;
  git_sha: string;
  timestamp: string;
  hostname: string;
  tier: 'e2e' | 'llm-judge';
  total_tests: number;
  passed: number;
  failed: number;
  total_cost_usd: number;
  total_duration_ms: number;
  tests: EvalTestEntry[];
}

interface EvalTestEntry {
  name: string;
  suite: string;
  tier: 'e2e' | 'llm-judge';
  passed: boolean;
  duration_ms: number;
  cost_usd: number;
  // E2E fields
  transcript?: any[];
  output?: string;
  turns_used?: number;
  browse_errors?: string[];
  exit_reason?: string;
  timeout_at_turn?: number;
  last_tool_call?: string;
  // LLM-judge fields
  judge_scores?: Record<string, number>;
  judge_reasoning?: string;
  // Outcome eval (planted bugs)
  detection_rate?: number;
  false_positives?: number;
  evidence_quality?: number;
  detected_bugs?: string[];
  missed_bugs?: string[];
}
```

**File Location:**
- `~/.gstack-dev/evals/{version}-{branch}-{tier}-{timestamp}.json`
- Auto-incremental saves to `_partial-e2e.json` (non-fatal if fails)

**Comparison Algorithm:**
```typescript
compareEvalResults(before: EvalResult, after: EvalResult)
  → Match tests by name
  → Track: improved/regressed/unchanged
  → Extract tool summaries from transcripts
  → Calculate deltas: cost, duration, turns, detection_rate
  → Generate commentary (with thresholds: ≥20% delta triggers report)
```

**Pass/Fail Logic (Planted Bugs):**
```typescript
function judgePassed(judgeResult, groundTruth): boolean {
  return detection_rate >= groundTruth.minimum_detection
      && false_positives <= groundTruth.max_false_positives
      && evidence_quality >= 2;
}
```

---

## 5. SKILL VALIDATION SYSTEM

### `skill-parser.ts` - Static Analysis

**Extraction:**
- Parses SKILL.md for bash code blocks
- Extracts `$B command arg1 arg2 # comment` invocations
- Handles quoted strings, inline comments, multiple commands per line

**Validation:**
```typescript
function validateSkill(skillPath): ValidationResult {
  • Check all $B commands exist in ALL_COMMANDS registry
  • For `snapshot` commands, parse & validate flags
  • Collect: valid, invalid, snapshotFlagErrors, warnings
}

function extractRemoteSlugPatterns(rootDir, subdirs): Map<filename, patterns[]>
  → Finds REMOTE_SLUG=$(...) assignments (checks consistency across files)

function extractWeightsFromTable(content): Map<category, percentage>
  → Parses ### Weights section for validation
```

**Test Coverage (Tier 1):**
- All SKILL.md files: `validateSkill()` for command syntax + flags
- Templates: cross-check `.tmpl` vs generated `.md` files
- Freshness: `gen-skill-docs --dry-run` detects stale files
- Consistency: COMMAND_DESCRIPTIONS, SNAPSHOT_FLAGS coverage
- Usage format: structural skeleton matching (implementation vs docs)

---

## 6. DIFF-BASED TEST SELECTION

### `touchfiles.ts` - Intelligent Test Filtering

**Concept:**
Each test declares **dependencies** (glob patterns of files it cares about). Only tests whose touchfiles were modified run, reducing CI cost.

**Registry:**
```typescript
const E2E_TOUCHFILES = {
  'browse-basic': ['browse/src/**'],
  'qa-quick': ['qa/**', 'browse/src/**'],
  'review-sql-injection': ['review/**', 'test/fixtures/review-eval-vuln.rb'],
  // ...
}

const LLM_JUDGE_TOUCHFILES = {
  'command reference table': ['SKILL.md', 'SKILL.md.tmpl', 'browse/src/commands.ts'],
  'snapshot flags reference': ['SKILL.md', 'SKILL.md.tmpl', 'browse/src/snapshot.ts'],
  // ...
}

const GLOBAL_TOUCHFILES = [
  'test/helpers/session-runner.ts',
  'test/helpers/eval-store.ts',
  'test/helpers/llm-judge.ts',
  'scripts/gen-skill-docs.ts',
  'browse/test/test-server.ts',
]
```

**Selection Algorithm:**
1. Detect base branch: try `origin/main` → `origin/master` → `main` → `master`
2. Get changed files: `git diff --name-only <base>...HEAD`
3. If any file matches GLOBAL_TOUCHFILES → **run all tests**
4. Otherwise, for each test, check if any changed file matches its patterns
5. Return `{ selected: [...], skipped: [...], reason: 'diff' | 'global' }`

**Usage:**
- `bun run test:evals` → diff-based (only changed tests)
- `bun run test:evals:all` → force all tests (override with `EVALS_ALL=1`)
- `bun run eval:select` → preview which tests would run

---

## 7. TEMPLATE GENERATION SYSTEM

### `scripts/gen-skill-docs.ts` - Dynamic Doc Building

**Pipeline:**
```
.tmpl file → find {{PLACEHOLDERS}} → resolve from source → format → .md file
```

**Key Resolvers:**

| Placeholder | Source | Output |
|-------------|--------|--------|
| `{{COMMAND_REFERENCE}}` | `COMMAND_DESCRIPTIONS` | Markdown table, grouped by category |
| `{{SNAPSHOT_FLAGS}}` | `SNAPSHOT_FLAGS` metadata | Formatted flag list with -i, -c, -d, etc. |
| `{{PREAMBLE}}` | Hard-coded | Update check, session tracking, completeness intro, contributor mode |
| `{{BROWSE_SETUP}}` | Hard-coded | Binary discovery & setup checks |
| `{{BASE_BRANCH_DETECT}}` | Hard-coded | Step 0 for PR-targeting skills |
| `{{QA_METHODOLOGY}}` | Hard-coded | Modes: Diff-aware, Full, Quick, Regression |

**Validation:**
- Dry-run mode: `--dry-run` flag generates to memory, exits 1 if different from committed
- No unresolved `{{placeholders}}` should remain
- All generated files must have `AUTO-GENERATED` header

**Update Check Preamble:**
Ensures SKILL.md files call `/gstack-upgrade` on startup:
```bash
_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
[ -n "$_UPD" ] && echo "$_UPD" || true
```

---

## 8. HEALTH DASHBOARD

### `scripts/skill-check.ts` - Status Overview

**Reports:**
1. **Skills:** Validates each SKILL.md file
   - Shows: valid/invalid command count, snapshot errors
   - Symbols: ✅ (all good), ❌ (errors), ⚠ (warnings)

2. **Templates:** Checks .tmpl → .md pairs
   - Ensures generated files exist and are fresh
   - Prompts to run `bun run gen:skill-docs` if stale

3. **Freshness:** Runs `gen-skill-docs --dry-run`
   - Detects drift between templates and committed files

**Exit Code:**
- 0 if all checks pass
- 1 if any errors found

---

## 9. BROWSER AUTOMATION SYSTEM

### `browse/src/` - Architecture

**Command Registry (Single Source of Truth):**
```typescript
// commands.ts
export const READ_COMMANDS = new Set([
  'text', 'html', 'links', 'forms', 'accessibility',
  'js', 'eval', 'css', 'attrs', 'console', 'network', 'cookies', ...
]);

export const WRITE_COMMANDS = new Set([
  'goto', 'back', 'forward', 'reload',
  'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
  'viewport', 'cookie', 'cookie-import', 'header', 'useragent', 'upload', ...
]);

export const META_COMMANDS = new Set([
  'tabs', 'tab', 'newtab', 'closetab',
  'status', 'stop', 'restart',
  'screenshot', 'pdf', 'responsive',
  'chain', 'diff', 'url', 'snapshot'
]);

export const COMMAND_DESCRIPTIONS = { ... }  // ← used by gen-skill-docs, skill-parser, server
```

**Server Architecture (`server.ts`):**
- **HTTP+Auth:** Bun.serve on random port 10000-60000 (or explicit BROWSE_PORT)
- **Token:** Bearer token authentication (random UUID)
- **Routing:** Dispatch command → `handleReadCommand()`, `handleWriteCommand()`, `handleMetaCommand()`
- **Buffers:** CircularBuffer in-memory + async disk flush (1s interval)
  - Console log: `~/.gstack/browse-console.log`
  - Network log: `~/.gstack/browse-network.log`
  - Dialog log: `~/.gstack/browse-dialog.log`
- **Idle Shutdown:** Default 30 min (`BROWSE_IDLE_TIMEOUT` env)
- **Crash Handling:** Chromium disconnect → log + `process.exit(1)` (CLI auto-restarts)

**BrowserManager (`browser-manager.ts`):**
```typescript
class BrowserManager {
  // Tab management
  async newTab(url?): Promise<number>
  async closeTab(id?): void
  switchTab(id: number): void

  // Ref map (snapshot → @e1, @e2, @c1, @c2)
  setRefMap(refs: Map<string, RefEntry>)
  clearRefs()
  async resolveRef(selector: "@e3" | "body > div"): { locator | selector }

  // Dialog handling
  setDialogAutoAccept(yes: boolean)
  setDialogPromptText(text: string)

  // Health check
  async isHealthy(): boolean  // Verifies Chromium connected + responsive
}
```

**Snapshot System (`snapshot.ts`):**

*Architecture:* **Locator map** (no DOM mutation)
```
1. page.locator(scope).ariaSnapshot() → YAML accessibility tree
2. Parse tree, assign refs @e1, @e2, ... (tree order)
3. Build Playwright Locator for each ref (getByRole + nth)
4. Store Map<"e3", Locator> on BrowserManager
5. Return compact text output
```

*Flags (Source of Truth):*
```typescript
export const SNAPSHOT_FLAGS = [
  { short: '-i', long: '--interactive', optionKey: 'interactive' },        // Buttons, links, inputs only
  { short: '-c', long: '--compact', optionKey: 'compact' },                // No empty structural nodes
  { short: '-d', long: '--depth', takesValue: true, optionKey: 'depth' },  // Limit tree depth
  { short: '-s', long: '--selector', takesValue: true, optionKey: 'selector' },
  { short: '-D', long: '--diff', optionKey: 'diff' },                      // Unified diff vs last
  { short: '-a', long: '--annotate', optionKey: 'annotate' },              // Overlay screenshot
  { short: '-o', long: '--output', takesValue: true, optionKey: 'outputPath' },
  { short: '-C', long: '--cursor-interactive', optionKey: 'cursorInteractive' },
];

export function parseSnapshotArgs(args: string[]): SnapshotOptions { ... }
```

*Output Format:*
```
  @e1 [heading] "Welcome" [level=1]
  @e2 [textbox] "Email"
  @e3 [button] "Submit"
```

*Later:* `click @e3` → look up `refMap.get("e3")` → locator.click()

---

## 10. KEY PATTERNS & PRINCIPLES

### Testing Philosophy

**Completeness Principle — Boil the Lake:**
- With AI assistance, marginal cost of completeness ≈ zero
- Always recommend complete implementation over shortcuts
- When effort scales: human 2 weeks / CC 1 hour → compression ~10x
- Never defer edge cases, error handling, or test coverage to "follow-up PR"

**E2E Eval Failure Blame Protocol:**
> Never claim "pre-existing" without proof. These systems have invisible couplings — preamble text affects agent behavior, SKILL.md regeneration shifts prompt context, timing changes can break flakey tests.
>
> **Required before attributing failure to pre-existing:**
> 1. Run same eval on main branch
> 2. Show it fails there too
> 3. If it passes on main but fails on branch → **it IS your change**

### Commit Style (Bisect Commits)

Every commit = single logical change. Examples:
- Rename/move separate from behavior changes
- Test infrastructure (touchfiles, helpers) separate from test implementations
- Template changes separate from generated file regeneration
- Mechanical refactors separate from new features

### Shared Infrastructure

**Symlinked Vendoring:**
`~/.claude/skills/gstack` may be a symlink to working directory (gitignored `.claude/skills/gstack`).
- Skill changes are **live immediately**
- Breaking changes to SKILL.md.tmpl can break concurrent gstack sessions
- During refactors, remove symlink to use global install instead

---

## 11. FILE MAPPING

| Path | Purpose | Role in Eval Pipeline |
|------|---------|----------------------|
| `/test/helpers/session-runner.ts` | E2E subprocess orchestration | Core: spawns `claude -p`, parses NDJSON, tracks cost |
| `/test/helpers/llm-judge.ts` | LLM quality scoring | Core: Anthropic API calls for clarity/completeness/actionability |
| `/test/helpers/eval-store.ts` | Result persistence & comparison | Core: accumulates, writes to ~/.gstack-dev/evals/, auto-compares |
| `/test/helpers/touchfiles.ts` | Diff-based test selection | Core: glob matching, git diff scanning, test filtering |
| `/test/helpers/skill-parser.ts` | SKILL.md static validation | Supports: command/flag extraction, freshness checks |
| `/test/skill-validation.test.ts` | Tier 1: Static validation | Runs: command syntax, flag validation, template freshness |
| `/test/skill-llm-eval.test.ts` | Tier 3: LLM-as-judge | Runs: doc quality scoring, regression vs baselines |
| `/test/skill-e2e.test.ts` | Tier 2: Full E2E workflows | Runs: planted-bug QA, skill demonstrations |
| `/scripts/gen-skill-docs.ts` | Dynamic SKILL.md generation | Generates: command tables, snapshot flags, preamble |
| `/scripts/skill-check.ts` | Health dashboard | Reports: validation status, template freshness |
| `/browse/src/commands.ts` | Command registry (single source of truth) | Used by: server dispatch, doc generation, skill parser |
| `/browse/src/snapshot.ts` | Snapshot system (accessibility tree + @refs) | Used by: all agents needing page inspection |
| `/browse/src/server.ts` | HTTP server + command dispatch | Routes: all $B commands → read/write/meta handlers |
| `/browse/src/browser-manager.ts` | Chromium lifecycle + state management | Core: tab management, ref map, dialog handling |

---

## Summary

gstack implements a **three-tier AI evaluation pipeline**:
- **Tier 1 (Static):** Validation of SKILL.md command syntax, flags, templates
- **Tier 2 (E2E):** Full autonomous agent workflows via subprocess, with cost & error tracking
- **Tier 3 (LLM-Judge):** Anthropic API quality assessment of documentation

The system uses **diff-based test selection** to minimize CI cost, **shared infrastructure** (single command registry) for consistency, and **persistent eval storage** with auto-comparison to previous runs. The **browser automation** layer provides Playwright-backed Chromium control with accessibility tree snapshots and ref-based element selection (@e1, @e2, etc.).

All validation is testable without AI (Tier 1), and evals are hermetic and reproducible (Tier 2 & 3).