~cytrogen/gstack

7665adf4fe8b13ad40b687b53ef66b7bc551147f — Garry Tan 14 days ago 997f7b1
feat: headed mode + sidebar agent + Chrome extension (v0.12.0) (#517)

* feat: CDP connect — control real Chrome/Comet via Playwright

Add `connectCDP()` to BrowserManager: connects to a running browser via
Chrome DevTools Protocol. All existing browse commands work unchanged
through Playwright's abstraction layer.

- chrome-launcher.ts: browser discovery, CDP probe, auto-relaunch with rollback
- browser-manager.ts: connectCDP(), mode guards (close/closeTab/recreateContext/handoff),
  auto-reconnect on browser restart, getRefMap() for extension API
- server.ts: CDP branch in start(), /health gains mode field, /refs endpoint,
  idle timer only resets on /command (not passive endpoints)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: browse connect/disconnect/focus CLI commands

- connect: pre-server command that discovers browser, starts server in CDP mode
- disconnect: drops CDP connection, restarts in headless mode
- focus: brings browser window to foreground via osascript (macOS)
- status: now shows Mode: cdp | launched | headed
- startServer() accepts extra env vars for CDP URL/port passthrough

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: CDP-aware skill templates — skip cookie import in real browser mode

Skills now check `$B status` for CDP mode and skip:
- /qa: cookie import prompt, user-agent override, headless workarounds
- /design-review: cookie import for authenticated pages
- /setup-browser-cookies: returns "not needed" in CDP mode

Regenerated SKILL.md files from updated templates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: activity streaming — SSE endpoint for Chrome extension Side Panel

Real-time browse command feed via Server-Sent Events:
- activity.ts: ActivityEntry type, CircularBuffer (capacity 1000), privacy
  filtering (redacts passwords, auth tokens, sensitive URL params),
  cursor-based gap detection, async subscriber notification
- server.ts: /activity/stream SSE, /activity/history REST, handleCommand
  instrumented with command_start/command_end events
- 18 unit tests for filterArgs privacy, emitActivity, subscribe lifecycle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Chrome extension Side Panel + Conductor API proposal

Chrome extension (Manifest V3, sideload):
- Side Panel with live activity feed, @ref overlays, dark terminal aesthetic
- Background worker: health polling, SSE relay, ref fetching
- Popup: port config, connection status, side panel launcher
- Content script: floating ref panel with @ref badges

Conductor API proposal (docs/designs/CONDUCTOR_SESSION_API.md):
- SSE endpoint for full Claude Code session mirroring in Side Panel
- Discovery via HTTP endpoint (not filesystem — extensions can't read files)

TODOS.md: add $B watch, multi-agent tabs, cross-platform CDP, Web Store publishing.
Mark CDP mode as shipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: detect Conductor runtime, skip osascript quit for sandboxed apps

macOS App Management blocks Electron apps (Conductor) from quitting
other apps via osascript. Now detects the runtime environment:
- terminal/claude-code/codex: can manage apps freely
- conductor: prints manual restart instructions + polls for 60s

detectRuntime() checks env vars and parent process. When Chrome needs
restart but we can't quit it, prints step-by-step instructions and
waits for the user to restart Chrome with --remote-debugging-port.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: detect Conductor via actual env vars (CONDUCTOR_WORKSPACE_NAME)

Previous detection checked CONDUCTOR_WORKSPACE_ID which doesn't exist.
Conductor sets CONDUCTOR_WORKSPACE_NAME, CONDUCTOR_BIN_DIR, CONDUCTOR_PORT,
and __CFBundleIdentifier=com.conductor.app. Check these FIRST because
Conductor sessions also have ANTHROPIC_API_KEY (which was matching claude-code).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: connection status pill — floating indicator when gstack controls Chrome

Small pill in bottom-right corner of every page: "● gstack · 3 refs"
Shows when connected via CDP, fades to 30% opacity after 3s, full on hover.
Disappears entirely when disconnected.

Background worker now notifies content scripts on connect/disconnect state
changes so the pill appears/disappears without polling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Chrome requires --user-data-dir for remote debugging

Chrome refuses --remote-debugging-port without an explicit --user-data-dir.
Add userDataDir to BrowserBinary registry (macOS Application Support paths)
and pass it in both auto-launch and manual restart instructions.

Fix double-quoting in CLI manual restart instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Chrome must be fully quit before launching with --remote-debugging-port

Chrome refuses to enable CDP on its default profile when another instance
is running (even with explicit --user-data-dir). The only reliable path:
fully quit Chrome first, then relaunch with the flag.

Updated instructions to emphasize this clearly with verification step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: bin/chrome-cdp — quit Chrome and relaunch with CDP in one command

Quits Chrome gracefully, waits for full exit, relaunches with
--remote-debugging-port, polls until CDP is ready. Usage: chrome-cdp [port]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use Playwright channel:chrome instead of broken connectOverCDP

Playwright's connectOverCDP hangs with Chrome 146 due to CDP protocol
version mismatch. Switch to channel:'chrome' which uses Playwright's
native pipe protocol to launch the system Chrome binary directly.

This is simpler and more reliable:
- No CDP port discovery needed
- No --remote-debugging-port or --user-data-dir hassles
- $B connect just works — launches real Chrome headed window
- All Playwright APIs (snapshot, click, fill) work unchanged

bin/chrome-cdp updated with symlinked profile approach (kept for
manual CDP use cases, but $B connect no longer needs it).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: green border + gstack label on controlled Chrome window

Injects a 2px green border and small "gstack" label on every page
loaded in the controlled Chrome window via context.addInitScript().
Users can instantly tell which Chrome window Claude controls.

Also fixes close() for channel:chrome mode (uses browser.close()
not browser.disconnect() which doesn't exist).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: cleanup chrome-launcher runtime detection, remove puppeteer-core dep

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style(design): redesign controlled Chrome indicator

Replace crude green border + label with polished indicator:
- 2px shimmer gradient at top edge (green→cyan→green, 3s loop)
- Floating pill bottom-right with frosted glass bg, fades to 25%
  opacity after 4s so it doesn't compete with page content
- prefers-reduced-motion disables shimmer animation
- Much more subtle — looks like a developer tool, not broken CSS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: document real browser mode + Chrome extension in BROWSER.md and README.md

BROWSER.md: new sections for connect/disconnect/focus commands,
Chrome extension Side Panel install, CDP-aware skills, activity streaming.
Updated command reference table, key components, env vars, source map.

README.md: updated /browse description, added "Real browser mode" to
What's New section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: step-by-step Chrome extension install guide in BROWSER.md

Replace terse bullet points with numbered walkthrough covering:
developer mode toggle, load unpacked, macOS file picker tip (Cmd+Shift+G),
pin extension, configure port, open side panel. Added troubleshooting section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add Cmd+Shift+. tip for hidden folders in macOS file picker

macOS hides folders starting with . by default. Added both shortcuts:
Cmd+Shift+G (paste path directly) and Cmd+Shift+. (show hidden files).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: integrate hidden folder tips into the install flow naturally

Move Cmd+Shift+G and Cmd+Shift+. tips inline with the file picker
step instead of as a separate tip block after it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: auto-load Chrome extension when $B connect launches Chrome

Extension auto-loads via --load-extension flag — no manual chrome://extensions
install needed. findExtensionPath() checks repo root, global install, and dev
paths. Also adds bin/gstack-extension helper for manual install in regular
Chrome, and rewrites BROWSER.md install docs with auto-load as primary path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: /connect-chrome skill — one command to launch Chrome with Side Panel

New skill that runs $B connect, verifies the connection, guides the user
to open the Side Panel, and demos the live activity feed. Extension auto-loads
via --load-extension so no manual chrome://extensions install needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use launchPersistentContext for Chrome extension loading

Playwright's chromium.launch() silently ignores --load-extension.
Switch to launchPersistentContext with ignoreDefaultArgs to remove
--disable-extensions flag. Use bundled Chromium (real Chrome blocks
unpacked extensions). Fixed port 34567 for CDP mode so the extension
auto-connects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: sync extension to DESIGN.md — amber accent, zinc neutrals, grain texture

Import design system from gstack-website. Update all extension colors:
green (#4ade80) → amber (#F59E0B/#FBBF24), zinc gray neutrals, grain
texture overlay. Regenerate icons as amber "G" monogram on dark background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: sidebar chat with Claude Code — icon opens side panel directly

Replace popup flyout with direct side panel open on icon click. Primary
UI is now a chat interface that sends messages to Claude Code via file
queue. Activity/Refs tabs moved behind a debug toggle in the footer.
Command bar with history, auto-poll for responses, amber design system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: sidebar agent — Claude-powered chat backend via file queue

Add /sidebar-command, /sidebar-response, and /sidebar-chat endpoints
to the browse server. sidebar-agent.ts watches the command queue file,
spawns claude -p with browse context for each message, and streams
responses back to the sidebar chat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove duplicate gstack pill overlay, hide crash restore bubble

The addInitScript indicator and the extension's content script were both
injecting bottom-right pills, causing duplicates. Remove the pill from
addInitScript (extension handles it). Replace --restore-last-session with
--hide-crash-restore-bubble to suppress the "Chromium didn't shut down
correctly" dialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: state file authority — CDP server cannot be silently replaced

Hardens the connect/disconnect lifecycle:
- ensureServer() refuses to auto-start headless when CDP server is alive
- $B connect does full cleanup: SIGTERM → 2s → SIGKILL, profile locks, state
- shutdown() cleans Chromium SingletonLock/Socket/Cookie files
- uncaughtException/unhandledRejection handlers do emergency cleanup

This prevents the bug where a headless server overwrites the CDP server's
state file, causing $B commands to hit the wrong browser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: sidebar agent streaming events + session state management

Enhance sidebar-agent.ts with:
- Live streaming of claude -p events (tool_use, text, result) to sidebar
- Session state file for BROWSE_STATE_FILE propagation to claude subprocess
- Improved logging (stderr, exit codes, event types)
- stdin.end() to prevent claude waiting for input
- summarizeToolInput() with path shortening for compact sidebar display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: sidebar chat UI — streaming events, agent status, reconnect retry

Sidebar panel improvements:
- Chat tab renders streaming agent events (tool_use, text, result)
- Thinking dots animation while agent processes
- Agent error display with styled error blocks
- tryConnect() with 2s retry loop for initial connection
- Debug tabs (Activity/Refs) hidden behind gear toggle
- Clear chat button
- Compact tool call display with path shortening

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: server-integrated sidebar agent with sessions and message queue

Move the sidebar agent from a separate bun process into server.ts:
- Agent spawns claude -p directly when messages arrive via /sidebar-command
- In-memory chat buffer backed by per-session chat.jsonl on disk
- Session manager: create, load, persist, list sessions
- Message queue (cap 5) with agent status tracking (idle/processing/hung)
- Stop/kill endpoints with queue dismiss support
- /health now returns agent status + session info
- All sidebar endpoints require Bearer auth
- Agent killed on server shutdown
- 120s timeout detects hung claude processes

Eliminates: file-queue polling, separate sidebar-agent.ts process,
stale auth tokens, state file conflicts between processes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: extension auth + token flow for server-integrated agent

Update Chrome extension to use Bearer auth on all sidebar endpoints:
- background.js captures auth token from /health, exposes via getToken msg
- background.js sets openPanelOnActionClick for direct side panel access
- sidepanel.js gets token from background, sends in all fetch headers
- Health broadcasts include token so sidebar auto-authenticates
- Removes popup from manifest — icon click opens side panel directly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: self-healing sidebar — reconnect banner, state machine, copy button

Sidebar UI now handles disconnection gracefully:
- Connection state machine: connected → reconnecting → dead
- Amber pulsing banner during reconnect (2s retry, 30 attempts)
- Red "Server offline" banner with Reconnect + Copy /connect-chrome buttons
- Green "Reconnected" toast that fades after 3s on successful reconnect
- Copy button lets user paste /connect-chrome into any Claude Code session

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: crash handling — save session, kill agent, distinct exit codes

Hardened shutdown/crash behavior:
- Browser disconnect exits with code 2 (distinct from crash code 1)
- emergencyCleanup kills agent subprocess and saves session state
- Clean shutdown saves session before exit (chat history persists)
- Clear user message on browser disconnect: "Run $B connect to reconnect"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: worktree-per-session isolation for sidebar agent

Each sidebar session gets an isolated git worktree so the agent's file
operations don't conflict with the user's working directory:
- createWorktree() creates detached HEAD worktree in ~/.gstack/worktrees/
- Falls back to main cwd for non-git repos or on creation failure
- Handles collision cleanup from prior crashes
- removeWorktree() cleans up on session switch and shutdown
- worktreePath persisted in session.json

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(qa): ISSUE-001 — disconnect blocked by CDP guard in ensureServer

$B disconnect was routed through ensureServer() which refused to start a
headless server when a CDP state file existed. Disconnect is now handled
before ensureServer() (like connect), with force-kill + cleanup fallback
when the CDP server is unresponsive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve claude binary path for daemon-spawned agent

The browse server runs as a daemon and may not inherit the user's shell
PATH. Add findClaudeBin() that checks ~/.local/bin/claude (standard
install location), which claude, and common system paths. Shows a clear
error in the sidebar chat if claude CLI is not found.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve claude symlinks + check Conductor bundled binary

posix_spawn fails on symlinks in compiled bun binaries. Now:
- Checks Conductor app's bundled binary first (not a symlink)
- Scans ~/.local/share/claude/versions/ for direct versioned binaries
- Uses fs.realpathSync() to resolve symlinks before spawning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: compiled bun binary cannot posix_spawn — use external agent process

Compiled bun binaries fail posix_spawn on ALL executables (even /bin/bash).
The server now writes to an agent queue file, and a separate non-compiled
bun process (sidebar-agent.ts) reads the queue, spawns claude, and POSTs
events back via /sidebar-agent/event.

Changes:
- server.ts: spawnClaude writes to queue file instead of spawning directly
- server.ts: new /sidebar-agent/event endpoint for agent → server relay
- server.ts: fix result event field name (event.text vs event.result)
- sidebar-agent.ts: rewritten to poll queue file, relay events via HTTP
- cli.ts: $B connect auto-starts sidebar-agent as non-compiled bun process

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: loading spinner on sidebar open while connecting to server

Shows an amber spinner with "Connecting..." when the sidebar first opens,
replacing the empty state. After the first successful /sidebar-chat poll:
- If chat history exists: renders it immediately
- If no history: shows the welcome message

Prevents the jarring empty-then-populated flash on sidebar open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: zero-friction side panel — auto-open on install, pill is clickable

Three changes to eliminate manual side panel setup:
- Auto-open side panel on extension install/update (onInstalled listener)
- gstack pill (bottom-right) is now clickable — opens the side panel
- Pill has pointer-events: auto so clicks always register (was: none)

User no longer needs to find the puzzle piece icon, pin the extension,
or know the side panel exists. It opens automatically on first launch
and can be re-opened by clicking the floating gstack pill.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: kill CDP naming, delete chrome-launcher.ts dead code

The connectCDP() method and connectionMode: 'cdp' naming was a legacy
artifact — real Chrome was tried but failed (silently blocks
--load-extension), so the implementation already used Playwright's
bundled Chromium via launchPersistentContext(). The naming was
misleading.

Changes:
- Delete chrome-launcher.ts (361 LOC) — only import was in unreachable
  attemptReconnect() method
- Delete dead attemptReconnect() and reconnecting field
- Delete preExistingTabIds (was for protecting real Chrome tabs we
  never connect to)
- Rename connectCDP() → launchHeaded()
- Rename connectionMode: 'cdp' → 'headed' across all files
- Replace BROWSE_CDP_URL/BROWSE_CDP_PORT env vars with BROWSE_HEADED=1
- Regenerate SKILL.md files for updated command descriptions
- Move BrowserManager unit tests to browser-manager-unit.test.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: converge handoff into connect — extension loads on handoff

Handoff now uses launchPersistentContext() with extension auto-loading,
same as the connect/launchHeaded() path. This means when the agent
gets stuck (2FA, CAPTCHA) and hands off to the user, the Chrome
extension + side panel are available automatically.

Before: handoff used chromium.launch() + newContext() — no extension
After: handoff uses chromium.launchPersistentContext() — extension loads

Also sets connectionMode to 'headed' and disables dialog auto-accept
on handoff, matching connect behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: gate sidebar chat behind --chat flag

$B connect (default): headed Chromium + extension with Activity + Refs
tabs only. No separate agent spawned. Clean, no confusion.

$B connect --chat: same + Chat tab with standalone claude -p agent.
Shows experimental banner: "Standalone mode — this is a separate
agent from your workspace."

Implementation:
- cli.ts: parse --chat, set BROWSE_SIDEBAR_CHAT env, conditionally
  spawn sidebar-agent
- server.ts: gate /sidebar-* routes behind chatEnabled, return 403
  when disabled, include chatEnabled in /health response
- sidepanel.js: applyChatEnabled() hides/shows Chat tab + banner
- background.js: forward chatEnabled from health response
- sidepanel.html/css: experimental banner with amber styling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: file drop relay + $B inbox command

Sidebar agent now writes structured messages to .context/sidebar-inbox/
when processing user input. The workspace agent can read these via
$B inbox to see what the user reported from the browser.

File drop format:
  .context/sidebar-inbox/{timestamp}-observation.json
  { type, timestamp, page: {url}, userMessage, sidebarSessionId }

Atomic writes (tmp + rename) prevent partial reads. $B inbox --clear
removes messages after display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: $B watch — passive observation mode

Claude enters read-only mode and captures periodic snapshots (every 5s)
while the user browses. Mutation commands (click, fill, etc.) are
blocked during watch. $B watch stop exits and returns a summary with
the last snapshot.

Requires headed mode ($B connect). This is the inverse of the scout
pattern — the workspace agent watches through the browser instead of
the sidebar relaying to it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add coverage for sidebar-agent, file-drop, and watch mode

33 new tests covering:
- Sidebar agent queue parsing (valid/malformed/empty JSONL)
- writeToInbox file drop (directory creation, atomic writes, JSON format)
- Inbox command (display, sorting, --clear, malformed file handling)
- Watch mode state machine (start/stop cycles, snapshots, duration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: TODOS cleanup + Chrome vs Chromium exploration doc

- Update TODOS.md: mark CDP mode, $B watch, sidebar scout as SHIPPED
- Delete dead "cross-platform CDP browser discovery" TODO
- Rename dependencies from "CDP connect" to "headed mode"
- Add docs/designs/CHROME_VS_CHROMIUM_EXPLORATION.md memorializing
  the architecture exploration and decision to use Playwright Chromium

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add Conductor Chrome sidebar integration design doc

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: sidebar-agent validates cwd before spawning claude

The queue entry may reference a worktree that was cleaned up between
sessions. Now falls back to process.cwd() if the path doesn't exist,
preventing silent spawn failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: gen-skill-docs resolver merge + preamble tier gate + plan file discovery

The local RESOLVERS record in gen-skill-docs.ts was shadowing the imported
canonical resolvers, causing stale test coverage and preamble generators
to be used instead of the authoritative versions in resolvers/.

Changes:
- Merge imported RESOLVERS with local overrides (spread + override pattern)
- Fix preamble tier gate: tier 1 skills no longer get AskUserQuestion format
- Make plan file discovery host-agnostic (search multiple plan dirs)
- Add missing E2E tier entries for ship/review plan completion tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: ungate sidebar agent + raise timeout to 5 minutes (v0.12.0)

Sidebar chat is now always available in headed mode — no --chat flag needed.
Agent tasks get 5 minutes instead of 2, enabling multi-page workflows like
navigating directories and filling forms across pages.

Changes:
- cli.ts: remove --chat flag, always set BROWSE_SIDEBAR_CHAT=1, always spawn agent
- server.ts: remove chatEnabled gate (403 response), raise AGENT_TIMEOUT_MS to 300s
- sidebar-agent.ts: raise child process timeout from 120s to 300s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: headed mode + sidebar agent documentation (v0.12.0)

- README: sidebar agent section, personal automation example (school parent
  portal), two auth paths (manual login + cookie import), DevTools MCP mention
- BROWSER.md: sidebar agent section with usage, timeout, session isolation,
  authentication, and random delay documentation
- connect-chrome template: add sidebar chat onboarding step
- CHANGELOG: v0.12.0 entry covering headed mode, sidebar agent, extension
- VERSION: bump to 0.12.0.0
- TODOS: Chrome DevTools MCP integration as P0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate SKILL.md files

Generated from updated templates + resolver merge. Key changes:
- Tier 1 skills no longer include AskUserQuestion format section
- Ship/review skills now include coverage gate with thresholds
- Connect-chrome skill includes sidebar chat onboarding step
- Plan file discovery uses host-agnostic paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate Codex connect-chrome skill

Updated preamble with proactive prompt and sidebar chat onboarding step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: network idle, state persistence, iframe support, chain pipe format (v0.12.1.0) (#516)

* feat: network idle detection + chain pipe format

- Upgrade click/fill/select from domcontentloaded to networkidle wait
  (2s timeout, best-effort). Catches XHR/fetch triggered by interactions.
- Add pipe-delimited format to chain as JSON fallback:
  $B chain 'goto url | click @e5 | snapshot -ic'
- Add post-loop networkidle wait in chain when last command was a write.
- Frame-aware: commands use target (getActiveFrameOrPage) for locator ops,
  page-only ops (goto/back/forward/reload) guard against frame context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: $B state save/load + $B frame — new browse commands

- state save/load: persist cookies + URLs to .gstack/browse-states/{name}.json
  File perms 0o600, name sanitized to [a-zA-Z0-9_-]. V1 skips localStorage
  (breaks on load-before-navigate). Load replaces session via closeAllPages().
- frame: switch command context to iframe via CSS selector, @ref, --name, or
  --url. 'frame main' returns to main frame. Execution target abstraction
  (getActiveFrameOrPage) across read-commands, snapshot, and write-commands.
- Frame context cleared on tab switch, navigation, resume, and handoff.
- Snapshot shows [Context: iframe src="..."] header when in frame.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add tests for network idle, chain pipe format, state, and frame

- Network idle: click on fetch button waits for XHR, static click is fast
- Chain pipe: pipe-delimited commands, quoted args, JSON still works
- State: save/load round-trip, name sanitization, missing state error
- Frame: switch to iframe + back, snapshot context header, fill in frame,
  goto-in-frame guard, usage error

New fixtures: network-idle.html (fetch + static buttons), iframe.html (srcdoc)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review fixes — iframe ref scoping, detached frame recovery, state validation

- snapshot.ts: ref locators, cursor-interactive scan, and cursor locator
  now use target (frame-aware) instead of page — fixes @ref clicking in iframes
- browser-manager.ts: getActiveFrameOrPage auto-recovers from detached frames
  via isDetached() check
- meta-commands.ts: state load resets activeFrame, elementHandle disposed after
  contentFrame(), state file schema validation (cookies + pages arrays),
  filter empty pipe segments in chain tokenizer
- write-commands.ts: upload command uses target.locator() for frame support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate SKILL.md files + rebuild binary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.12.1.0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
59 files changed, 7007 insertions(+), 150 deletions(-)

A .agents/skills/gstack-connect-chrome/SKILL.md
M BROWSER.md
M CHANGELOG.md
A DESIGN.md
M README.md
M SKILL.md
M TODOS.md
M VERSION
A bin/chrome-cdp
A bin/gstack-extension
M browse/SKILL.md
A browse/src/activity.ts
M browse/src/browser-manager.ts
M browse/src/cli.ts
M browse/src/commands.ts
M browse/src/meta-commands.ts
M browse/src/read-commands.ts
M browse/src/server.ts
A browse/src/sidebar-agent.ts
M browse/src/snapshot.ts
M browse/src/write-commands.ts
A browse/test/activity.test.ts
A browse/test/browser-manager-unit.test.ts
M browse/test/commands.test.ts
A browse/test/file-drop.test.ts
A browse/test/fixtures/iframe.html
A browse/test/fixtures/network-idle.html
A browse/test/sidebar-agent.test.ts
A browse/test/watch.test.ts
A connect-chrome/SKILL.md
A connect-chrome/SKILL.md.tmpl
M design-review/SKILL.md
M design-review/SKILL.md.tmpl
A docs/designs/CHROME_VS_CHROMIUM_EXPLORATION.md
A docs/designs/CONDUCTOR_CHROME_SIDEBAR_INTEGRATION.md
A docs/designs/CONDUCTOR_SESSION_API.md
A extension/background.js
A extension/content.css
A extension/content.js
A extension/icons/icon-128.png
A extension/icons/icon-16.png
A extension/icons/icon-48.png
A extension/manifest.json
A extension/popup.html
A extension/popup.js
A extension/sidepanel.css
A extension/sidepanel.html
A extension/sidepanel.js
M package.json
M qa/SKILL.md
M qa/SKILL.md.tmpl
M review/SKILL.md
M scripts/gen-skill-docs.ts
M scripts/resolvers/review.ts
M setup-browser-cookies/SKILL.md
M setup-browser-cookies/SKILL.md.tmpl
M ship/SKILL.md
M test/helpers/touchfiles.ts
M test/skill-validation.test.ts
A .agents/skills/gstack-connect-chrome/SKILL.md => .agents/skills/gstack-connect-chrome/SKILL.md +411 -0
@@ 0,0 1,411 @@
---
name: connect-chrome
description: |
  Launch real Chrome controlled by gstack with the Side Panel extension auto-loaded.
  One command: connects Claude to a visible Chrome window where you can watch every
  action in real time. The extension shows a live activity feed in the Side Panel.
  Use when asked to "connect chrome", "open chrome", "real browser", "launch chrome",
  "side panel", or "control my browser".
---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->

## Preamble (run first)

```bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
GSTACK_ROOT="$HOME/.codex/skills/gstack"
[ -n "$_ROOT" ] && [ -d "$_ROOT/.agents/skills/gstack" ] && GSTACK_ROOT="$_ROOT/.agents/skills/gstack"
GSTACK_BIN="$GSTACK_ROOT/bin"
GSTACK_BROWSE="$GSTACK_ROOT/browse/dist"
_UPD=$($GSTACK_BIN/gstack-update-check 2>/dev/null || .agents/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
[ -n "$_UPD" ] && echo "$_UPD" || true
mkdir -p ~/.gstack/sessions
touch ~/.gstack/sessions/"$PPID"
_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
find ~/.gstack/sessions -mmin +120 -type f -delete 2>/dev/null || true
_CONTRIB=$($GSTACK_BIN/gstack-config get gstack_contributor 2>/dev/null || true)
_PROACTIVE=$($GSTACK_BIN/gstack-config get proactive 2>/dev/null || echo "true")
_PROACTIVE_PROMPTED=$([ -f ~/.gstack/.proactive-prompted ] && echo "yes" || echo "no")
_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
echo "BRANCH: $_BRANCH"
echo "PROACTIVE: $_PROACTIVE"
echo "PROACTIVE_PROMPTED: $_PROACTIVE_PROMPTED"
source <($GSTACK_BIN/gstack-repo-mode 2>/dev/null) || true
REPO_MODE=${REPO_MODE:-unknown}
echo "REPO_MODE: $REPO_MODE"
_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no")
echo "LAKE_INTRO: $_LAKE_SEEN"
_TEL=$($GSTACK_BIN/gstack-config get telemetry 2>/dev/null || true)
_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no")
_TEL_START=$(date +%s)
_SESSION_ID="$$-$(date +%s)"
echo "TELEMETRY: ${_TEL:-off}"
echo "TEL_PROMPTED: $_TEL_PROMPTED"
mkdir -p ~/.gstack/analytics
echo '{"skill":"connect-chrome","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}'  >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
# zsh-compatible: use find instead of glob to avoid NOMATCH error
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do [ -f "$_PF" ] && $GSTACK_BIN/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done
```

If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills AND do not
auto-invoke skills based on conversation context. Only run skills the user explicitly
types (e.g., /qa, /ship). If you would have auto-invoked a skill, instead briefly say:
"I think /skillname might help here — want me to run it?" and wait for confirmation.
The user opted out of proactive behavior.

If output shows `UPGRADE_AVAILABLE <old> <new>`: read `$GSTACK_ROOT/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.

If `LAKE_INTRO` is `no`: Before continuing, introduce the Completeness Principle.
Tell the user: "gstack follows the **Boil the Lake** principle — always do the complete
thing when AI makes the marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean"
Then offer to open the essay in their default browser:

```bash
open https://garryslist.org/posts/boil-the-ocean
touch ~/.gstack/.completeness-intro-seen
```

Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once.

If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled,
ask the user about telemetry. Use AskUserQuestion:

> Help gstack get better! Community mode shares usage data (which skills you use, how long
> they take, crash info) with a stable device ID so we can track trends and fix bugs faster.
> No code, file paths, or repo names are ever sent.
> Change anytime with `gstack-config set telemetry off`.

Options:
- A) Help gstack get better! (recommended)
- B) No thanks

If A: run `$GSTACK_BIN/gstack-config set telemetry community`

If B: ask a follow-up AskUserQuestion:

> How about anonymous mode? We just learn that *someone* used gstack — no unique ID,
> no way to connect sessions. Just a counter that helps us know if anyone's out there.

Options:
- A) Sure, anonymous is fine
- B) No thanks, fully off

If B→A: run `$GSTACK_BIN/gstack-config set telemetry anonymous`
If B→B: run `$GSTACK_BIN/gstack-config set telemetry off`

Always run:
```bash
touch ~/.gstack/.telemetry-prompted
```

This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely.

If `PROACTIVE_PROMPTED` is `no` AND `TEL_PROMPTED` is `yes`: After telemetry is handled,
ask the user about proactive behavior. Use AskUserQuestion:

> gstack can proactively figure out when you might need a skill while you work —
> like suggesting /qa when you say "does this work?" or /investigate when you hit
> a bug. We recommend keeping this on — it speeds up every part of your workflow.

Options:
- A) Keep it on (recommended)
- B) Turn it off — I'll type /commands myself

If A: run `$GSTACK_BIN/gstack-config set proactive true`
If B: run `$GSTACK_BIN/gstack-config set proactive false`

Always run:
```bash
touch ~/.gstack/.proactive-prompted
```

This only happens once. If `PROACTIVE_PROMPTED` is `yes`, skip this entirely.

## AskUserQuestion Format

**ALWAYS follow this structure for every AskUserQuestion call:**
1. **Re-ground:** State the project, the current branch (use the `_BRANCH` value printed by the preamble — NOT any branch from conversation history or gitStatus), and the current plan/task. (1-2 sentences)
2. **Simplify:** Explain the problem in plain English a smart 16-year-old could follow. No raw function names, no internal jargon, no implementation details. Use concrete examples and analogies. Say what it DOES, not what it's called.
3. **Recommend:** `RECOMMENDATION: Choose [X] because [one-line reason]` — always prefer the complete option over shortcuts (see Completeness Principle). Include `Completeness: X/10` for each option. Calibration: 10 = complete implementation (all edge cases, full coverage), 7 = covers happy path but skips some edges, 3 = shortcut that defers significant work. If both options are 8+, pick the higher; if one is ≤5, flag it.
4. **Options:** Lettered options: `A) ... B) ... C) ...` — when an option involves effort, show both scales: `(human: ~X / CC: ~Y)`

Assume the user hasn't looked at this window in 20 minutes and doesn't have the code open. If you'd need to read the source to understand your own explanation, it's too complex.

Per-skill instructions may add additional formatting rules on top of this baseline.

## Completeness Principle — Boil the Lake

AI makes completeness near-free. Always recommend the complete option over shortcuts — the delta is minutes with CC+gstack. A "lake" (100% coverage, all edge cases) is boilable; an "ocean" (full rewrite, multi-quarter migration) is not. Boil lakes, flag oceans.

**Effort reference** — always show both scales:

| Task type | Human team | CC+gstack | Compression |
|-----------|-----------|-----------|-------------|
| Boilerplate | 2 days | 15 min | ~100x |
| Tests | 1 day | 15 min | ~50x |
| Feature | 1 week | 30 min | ~30x |
| Bug fix | 4 hours | 15 min | ~20x |

Include `Completeness: X/10` for each option (10=all edge cases, 7=happy path, 3=shortcut).

## Repo Ownership — See Something, Say Something

`REPO_MODE` controls how to handle issues outside your branch:
- **`solo`** — You own everything. Investigate and offer to fix proactively.
- **`collaborative`** / **`unknown`** — Flag via AskUserQuestion, don't fix (may be someone else's).

Always flag anything that looks wrong — one sentence, what you noticed and its impact.

## Search Before Building

Before building anything unfamiliar, **search first.** See `$GSTACK_ROOT/ETHOS.md`.
- **Layer 1** (tried and true) — don't reinvent. **Layer 2** (new and popular) — scrutinize. **Layer 3** (first principles) — prize above all.

**Eureka:** When first-principles reasoning contradicts conventional wisdom, name it and log:
```bash
jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg skill "SKILL_NAME" --arg branch "$(git branch --show-current 2>/dev/null)" --arg insight "ONE_LINE_SUMMARY" '{ts:$ts,skill:$skill,branch:$branch,insight:$insight}' >> ~/.gstack/analytics/eureka.jsonl 2>/dev/null || true
```

## Contributor Mode

If `_CONTRIB` is `true`: you are in **contributor mode**. At the end of each major workflow step, rate your gstack experience 0-10. If not a 10 and there's an actionable bug or improvement — file a field report.

**File only:** gstack tooling bugs where the input was reasonable but gstack failed. **Skip:** user app bugs, network errors, auth failures on user's site.

**To file:** write `~/.gstack/contributor-logs/{slug}.md`:
```
# {Title}
**What I tried:** {action} | **What happened:** {result} | **Rating:** {0-10}
## Repro
1. {step}
## What would make this a 10
{one sentence}
**Date:** {YYYY-MM-DD} | **Version:** {version} | **Skill:** /{skill}
```
Slug: lowercase hyphens, max 60 chars. Skip if exists. Max 3/session. File inline, don't stop.

## Completion Status Protocol

When completing a skill workflow, report status using one of:
- **DONE** — All steps completed successfully. Evidence provided for each claim.
- **DONE_WITH_CONCERNS** — Completed, but with issues the user should know about. List each concern.
- **BLOCKED** — Cannot proceed. State what is blocking and what was tried.
- **NEEDS_CONTEXT** — Missing information required to continue. State exactly what you need.

### Escalation

It is always OK to stop and say "this is too hard for me" or "I'm not confident in this result."

Bad work is worse than no work. You will not be penalized for escalating.
- If you have attempted a task 3 times without success, STOP and escalate.
- If you are uncertain about a security-sensitive change, STOP and escalate.
- If the scope of work exceeds what you can verify, STOP and escalate.

Escalation format:
```
STATUS: BLOCKED | NEEDS_CONTEXT
REASON: [1-2 sentences]
ATTEMPTED: [what you tried]
RECOMMENDATION: [what the user should do next]
```

## Telemetry (run last)

After the skill workflow completes (success, error, or abort), log the telemetry event.
Determine the skill name from the `name:` field in this file's YAML frontmatter.
Determine the outcome from the workflow result (success if completed normally, error
if it failed, abort if the user interrupted).

**PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes telemetry to
`~/.gstack/analytics/` (user config directory, not project files). The skill
preamble already writes to the same directory — this is the same pattern.
Skipping this command loses session duration and outcome data.

Run this bash:

```bash
_TEL_END=$(date +%s)
_TEL_DUR=$(( _TEL_END - _TEL_START ))
rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true
$GSTACK_ROOT/bin/gstack-telemetry-log \
  --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \
  --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null &
```

Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with
success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used.
If you cannot determine the outcome, use "unknown". This runs in the background and
never blocks the user.

## Plan Status Footer

When you are in plan mode and about to call ExitPlanMode:

1. Check if the plan file already has a `## GSTACK REVIEW REPORT` section.
2. If it DOES — skip (a review skill already wrote a richer report).
3. If it does NOT — run this command:

\`\`\`bash
$GSTACK_ROOT/bin/gstack-review-read
\`\`\`

Then write a `## GSTACK REVIEW REPORT` section to the end of the plan file:

- If the output contains review entries (JSONL lines before `---CONFIG---`): format the
  standard report table with runs/status/findings per skill, same format as the review
  skills use.
- If the output is `NO_REVIEWS` or empty: write this placeholder table:

\`\`\`markdown
## GSTACK REVIEW REPORT

| Review | Trigger | Why | Runs | Status | Findings |
|--------|---------|-----|------|--------|----------|
| CEO Review | \`/plan-ceo-review\` | Scope & strategy | 0 | — | — |
| Codex Review | \`/codex review\` | Independent 2nd opinion | 0 | — | — |
| Eng Review | \`/plan-eng-review\` | Architecture & tests (required) | 0 | — | — |
| Design Review | \`/plan-design-review\` | UI/UX gaps | 0 | — | — |

**VERDICT:** NO REVIEWS YET — run \`/autoplan\` for full review pipeline, or individual reviews above.
\`\`\`

**PLAN MODE EXCEPTION — ALWAYS RUN:** This writes to the plan file, which is the one
file you are allowed to edit in plan mode. The plan file review report is part of the
plan's living status.

# /connect-chrome — Launch Real Chrome with Side Panel

Connect Claude to a visible Chrome window with the gstack extension auto-loaded.
You see every click, every navigation, every action in real time.

## SETUP (run this check BEFORE any browse command)

```bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.agents/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.agents/skills/gstack/browse/dist/browse"
[ -z "$B" ] && B=$GSTACK_BROWSE/browse
if [ -x "$B" ]; then
  echo "READY: $B"
else
  echo "NEEDS_SETUP"
fi
```

If `NEEDS_SETUP`:
1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait.
2. Run: `cd <SKILL_DIR> && ./setup`
3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash`

## Step 1: Connect

```bash
$B connect
```

This launches your system Chrome via Playwright with:
- A visible window (headed mode, not headless)
- The gstack Chrome extension pre-loaded
- A green shimmer line + "gstack" pill so you know which window is controlled

If Chrome is already running, the server restarts in headed mode with a fresh
Chrome instance. Your regular Chrome stays untouched.

After connecting, print the output to the user.

## Step 2: Verify

```bash
$B status
```

Confirm the output shows `Mode: cdp`. Print the port number — the user may need
it for the Side Panel.

## Step 3: Guide the user to the Side Panel

Use AskUserQuestion:

> Chrome is launched with gstack control. You should see a green shimmer line at the
> top of the Chrome window and a small "gstack" pill in the bottom-right corner.
>
> The Side Panel extension is pre-loaded. To open it:
> 1. Look for the **puzzle piece icon** (Extensions) in Chrome's toolbar
> 2. Click it → find **gstack browse** → click the **pin icon** to pin it
> 3. Click the **gstack icon** in the toolbar
> 4. Click **Open Side Panel**
>
> The Side Panel shows a live feed of every browse command in real time.
>
> **Port:** The browse server is on port {PORT} — the extension auto-detects it
> if you're using the Playwright-controlled Chrome. If the badge stays gray, click
> the gstack icon and enter port {PORT} manually.

Options:
- A) I can see the Side Panel — let's go!
- B) I can see Chrome but can't find the extension
- C) Something went wrong

If B: Tell the user:
> The extension should be auto-loaded, but Chrome sometimes doesn't show it
> immediately. Try:
> 1. Type `chrome://extensions` in the address bar
> 2. Look for "gstack browse" — it should be listed and enabled
> 3. If not listed, click "Load unpacked" → navigate to the extension folder
>    (press Cmd+Shift+G in the file picker, paste this path):
>    `{EXTENSION_PATH}`
>
> Then pin it from the puzzle piece icon and open the Side Panel.

If C: Run `$B status` and show the output. Check if the server is healthy.

## Step 4: Demo

After the user confirms the Side Panel is working, run a quick demo so they
can see the activity feed in action:

```bash
$B goto https://news.ycombinator.com
```

Wait 2 seconds, then:

```bash
$B snapshot -i
```

Tell the user: "Check the Side Panel — you should see the `goto` and `snapshot`
commands appear in the activity feed. Every command Claude runs will show up here
in real time."

## Step 5: Sidebar chat

After the activity feed demo, tell the user about the sidebar chat:

> The Side Panel also has a **chat tab**. Try typing a message like "take a
> snapshot and describe this page." A child Claude instance will execute your
> request in the browser — you'll see the commands appear in the activity feed.
>
> The sidebar agent can navigate pages, click buttons, fill forms, and read
> content. Each task gets up to 5 minutes. It runs in an isolated session, so
> it won't interfere with this Claude Code window.

## Step 6: What's next

Tell the user:

> You're all set! Chrome is under Claude's control with the Side Panel showing
> live activity and a chat sidebar for direct commands. Here's what you can do:
>
> - **Chat in the sidebar** — type natural language instructions and Claude
>   executes them in the browser
> - **Run any browse command** — `$B goto`, `$B click`, `$B snapshot` — and
>   watch it happen in Chrome + the Side Panel
> - **Use /qa or /design-review** — they'll run in the visible Chrome window
>   instead of headless. No cookie import needed.
> - **`$B focus`** — bring Chrome to the foreground anytime
> - **`$B disconnect`** — return to headless mode when done

Then proceed with whatever the user asked to do. If they didn't specify a task,
ask what they'd like to test or browse.

M BROWSER.md => BROWSER.md +124 -0
@@ 18,6 18,7 @@ This document covers the command reference and internals of gstack's headless br
| Cookies | `cookie-import`, `cookie-import-browser` | Import cookies from file or real browser |
| Multi-step | `chain` (JSON from stdin) | Batch commands in one call |
| Handoff | `handoff [reason]`, `resume` | Switch to visible Chrome for user takeover |
| Real browser | `connect`, `disconnect`, `focus` | Control real Chrome, visible window |

All selector arguments accept CSS selectors, `@e` refs after `snapshot`, or `@c` refs after `snapshot -C`. 50+ commands total plus cookie import.



@@ 70,6 71,7 @@ browse/
│   ├── cookie-import-browser.ts  # Decrypt + import cookies from real Chromium browsers
│   ├── cookie-picker-routes.ts   # HTTP routes for interactive cookie picker UI
│   ├── cookie-picker-ui.ts       # Self-contained HTML/CSS/JS for cookie picker
│   ├── activity.ts         # Activity streaming (SSE) for Chrome extension
│   └── buffers.ts          # CircularBuffer<T> + console/network/dialog capture
├── test/                   # Integration tests + HTML fixtures
└── dist/


@@ 124,6 126,125 @@ The server hooks into Playwright's `page.on('console')`, `page.on('response')`, 

The `console`, `network`, and `dialog` commands read from the in-memory buffers, not disk.

### Real browser mode (`connect`)

Instead of headless Chromium, `connect` launches your real Chrome as a headed window controlled by Playwright. You see everything Claude does in real time.

```bash
$B connect              # launch real Chrome, headed
$B goto https://app.com # navigates in the visible window
$B snapshot -i          # refs from the real page
$B click @e3            # clicks in the real window
$B focus                # bring Chrome window to foreground (macOS)
$B status               # shows Mode: cdp
$B disconnect           # back to headless mode
```

The window has a subtle green shimmer line at the top edge and a floating "gstack" pill in the bottom-right corner so you always know which Chrome window is being controlled.

**How it works:** Playwright's `channel: 'chrome'` launches your system Chrome binary via a native pipe protocol — not CDP WebSocket. All existing browse commands work unchanged because they go through Playwright's abstraction layer.

**When to use it:**
- QA testing where you want to watch Claude click through your app
- Design review where you need to see exactly what Claude sees
- Debugging where headless behavior differs from real Chrome
- Demos where you're sharing your screen

**Commands:**

| Command | What it does |
|---------|-------------|
| `connect` | Launch real Chrome, restart server in headed mode |
| `disconnect` | Close real Chrome, restart in headless mode |
| `focus` | Bring Chrome to foreground (macOS). `focus @e3` also scrolls element into view |
| `status` | Shows `Mode: cdp` when connected, `Mode: launched` when headless |

**CDP-aware skills:** When in real-browser mode, `/qa` and `/design-review` automatically skip cookie import prompts and headless workarounds.

### Chrome extension (Side Panel)

A Chrome extension that shows a live activity feed of browse commands in a Side Panel, plus @ref overlays on the page.

#### Automatic install (recommended)

When you run `$B connect`, the extension **auto-loads** into the Playwright-controlled Chrome window. No manual steps needed — the Side Panel is immediately available.

```bash
$B connect              # launches Chrome with extension pre-loaded
# Click the gstack icon in toolbar → Open Side Panel
```

The port is auto-configured. You're done.

#### Manual install (for your regular Chrome)

If you want the extension in your everyday Chrome (not the Playwright-controlled one), run:

```bash
bin/gstack-extension    # opens chrome://extensions, copies path to clipboard
```

Or do it manually:

1. **Go to `chrome://extensions`** in Chrome's address bar
2. **Toggle "Developer mode" ON** (top-right corner)
3. **Click "Load unpacked"** — a file picker opens
4. **Navigate to the extension folder:** Press **Cmd+Shift+G** in the file picker to open "Go to folder", then paste one of these paths:
   - Global install: `~/.claude/skills/gstack/extension`
   - Dev/source: `<gstack-repo>/extension`

   Press Enter, then click **Select**.

   (Tip: macOS hides folders starting with `.` — press **Cmd+Shift+.** in the file picker to reveal them if you prefer to navigate manually.)

5. **Pin it:** Click the puzzle piece icon (Extensions) in the toolbar → pin "gstack browse"
6. **Set the port:** Click the gstack icon → enter the port from `$B status` or `.gstack/browse.json`
7. **Open Side Panel:** Click the gstack icon → "Open Side Panel"

#### What you get

| Feature | What it does |
|---------|-------------|
| **Toolbar badge** | Green dot when the browse server is reachable, gray when not |
| **Side Panel** | Live scrolling feed of every browse command — shows command name, args, duration, status (success/error) |
| **Refs tab** | After `$B snapshot`, shows the current @ref list (role + name) |
| **@ref overlays** | Floating panel on the page showing current refs |
| **Connection pill** | Small "gstack" pill in the bottom-right corner of every page when connected |

#### Troubleshooting

- **Badge stays gray:** Check that the port is correct. The browse server may have restarted on a different port — re-run `$B status` and update the port in the popup.
- **Side Panel is empty:** The feed only shows activity after the extension connects. Run a browse command (`$B snapshot`) to see it appear.
- **Extension disappeared after Chrome update:** Sideloaded extensions persist across updates. If it's gone, reload it from Step 3.

### Sidebar agent

The Chrome side panel includes a chat interface. Type a message and a child Claude instance executes it in the browser. The sidebar agent has access to `Bash`, `Read`, `Glob`, and `Grep` tools (same as Claude Code, minus `Edit` and `Write` ... read-only by design).

**How it works:**

1. You type a message in the side panel chat
2. The extension POSTs to the local browse server (`/sidebar-command`)
3. The server queues the message and the sidebar-agent process spawns `claude -p` with your message + the current page context
4. Claude executes browse commands via Bash (`$B snapshot`, `$B click @e3`, etc.)
5. Progress streams back to the side panel in real time

**What you can do:**
- "Take a snapshot and describe what you see"
- "Click the Login button, fill in test@example.com / password123, and submit"
- "Go through every row in this table and extract the names and emails"
- "Navigate to Settings > Account and screenshot it"

**Timeout:** Each task gets up to 5 minutes. Multi-page workflows (navigating a directory, filling forms across pages) work within this window. If a task times out, the side panel shows an error and you can retry or break it into smaller steps.

**Session isolation:** Each sidebar session runs in its own git worktree. The sidebar agent won't interfere with your main Claude Code session.

**Authentication:** The sidebar agent uses the same browser session as headed mode. Two options:
1. Log in manually in the headed browser ... your session persists for the sidebar agent
2. Import cookies from your real Chrome via `/setup-browser-cookies`

**Random delays:** If you need the agent to pause between actions (e.g., to avoid rate limits), use `sleep` in bash or `$B wait <milliseconds>`.

### User handoff

When the headless browser can't proceed (CAPTCHA, MFA, complex auth), `handoff` opens a visible Chrome window at the exact same page with all cookies, localStorage, and tabs preserved. The user solves the problem manually, then `resume` returns control to the agent with a fresh snapshot.


@@ 171,6 292,8 @@ No port collisions. No shared state. Each project is fully isolated.
| `BROWSE_IDLE_TIMEOUT` | 1800000 (30 min) | Idle shutdown timeout in ms |
| `BROWSE_STATE_FILE` | `.gstack/browse.json` | Path to state file (CLI passes to server) |
| `BROWSE_SERVER_SCRIPT` | auto-detected | Path to server.ts |
| `BROWSE_CDP_URL` | (none) | Set to `channel:chrome` for real browser mode |
| `BROWSE_CDP_PORT` | 0 | CDP port (used internally) |

### Performance



@@ 250,6 373,7 @@ Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML fi
| `browse/src/cookie-import-browser.ts` | Decrypt Chromium cookies from macOS and Linux browser profiles using platform-specific safe-storage key lookup. Auto-detects installed browsers. |
| `browse/src/cookie-picker-routes.ts` | HTTP routes for `/cookie-picker/*` — browser list, domain search, import, remove. |
| `browse/src/cookie-picker-ui.ts` | Self-contained HTML generator for the interactive cookie picker (dark theme, no frameworks). |
| `browse/src/activity.ts` | Activity streaming — `ActivityEntry` type, `CircularBuffer`, privacy filtering, SSE subscriber management. |
| `browse/src/buffers.ts` | `CircularBuffer<T>` (O(1) ring buffer) + console/network/dialog capture with async disk flush. |

### Deploying to the active skill

M CHANGELOG.md => CHANGELOG.md +46 -0
@@ 1,5 1,51 @@
# Changelog

## [0.12.1.0] - 2026-03-26 — Smarter Browsing: Network Idle, State Persistence, Iframes

Every click, fill, and select now waits for the page to settle before returning. No more stale snapshots because an XHR was still in-flight. Chain accepts pipe-delimited format for faster multi-step flows. You can save and restore browser sessions (cookies + open tabs). And iframe content is now reachable.

### Added

- **Network idle detection.** `click`, `fill`, and `select` auto-wait up to 2s for network requests to settle before returning. Catches XHR/fetch triggered by interactions. Uses Playwright's built-in `waitForLoadState('networkidle')`, not a custom tracker.

- **`$B state save/load`.** Save your browser session (cookies + open tabs) to a named file, load it back later. Files stored at `.gstack/browse-states/{name}.json` with 0o600 permissions. V1 saves cookies + URLs only (not localStorage, which breaks on load-before-navigate). Load replaces the current session, not merge.

- **`$B frame` command.** Switch command context into an iframe: `$B frame iframe`, `$B frame --name checkout`, `$B frame --url stripe`, or `$B frame @e5`. All subsequent commands (click, fill, snapshot, etc.) operate inside the iframe. `$B frame main` returns to the main page. Snapshot shows `[Context: iframe src="..."]` header. Detached frames auto-recover.

- **Chain pipe format.** Chain now accepts `$B chain 'goto url | click @e5 | snapshot -ic'` as a fallback when JSON parsing fails. Pipe-delimited with quote-aware tokenization.

### Changed

- **Chain post-loop idle wait.** After executing all commands in a chain, if the last was a write command, chain waits for network idle before returning.

### Fixed

- **Iframe ref scoping.** Snapshot ref locators, cursor-interactive scan, and cursor locators now use the frame-aware target instead of always scoping to the main page.
- **Detached frame recovery.** `getActiveFrameOrPage()` checks `isDetached()` and auto-recovers.
- **State load resets frame context.** Loading a saved state clears the active frame reference.
- **elementHandle leak in frame command.** Now properly disposed after getting contentFrame.
- **Upload command frame-aware.** `upload` uses the frame-aware target for file input locators.

## [0.12.0.0] - 2026-03-26 — Headed Mode + Sidebar Agent

You can now watch Claude work in a real Chrome window and direct it from a sidebar chat.

### Added

- **Headed mode with sidebar agent.** `$B connect` launches a visible Chrome window with the gstack extension. The Side Panel shows a live activity feed of every command AND a chat interface where you type natural language instructions. A child Claude instance executes your requests in the browser ... navigate pages, click buttons, fill forms, extract data. Each task gets up to 5 minutes.

- **Personal automation.** The sidebar agent handles repetitive browser tasks beyond dev workflows. Browse your kid's school parent portal and add parent contact info to Google Contacts. Fill out vendor onboarding forms. Extract data from dashboards. Log in once in the headed browser or import cookies from your real Chrome with `/setup-browser-cookies`.

- **Chrome extension.** Toolbar badge (green=connected, gray=not), Side Panel with activity feed + chat + refs tab, @ref overlays on the page, and a connection pill showing which window gstack controls. Auto-loads when you run `$B connect`.

- **`/connect-chrome` skill.** Guided setup: launches Chrome, verifies the extension, demos the activity feed, and introduces the sidebar chat.

### Changed

- **Sidebar agent ungated.** Previously required `--chat` flag. Now always available in headed mode. The sidebar agent has the same security model as Claude Code itself (Bash, Read, Glob, Grep on localhost).

- **Agent timeout raised to 5 minutes.** Multi-page tasks (navigating directories, filling forms across pages) need more than the previous 2-minute limit.

## [0.11.21.0] - 2026-03-26

### Fixed

A DESIGN.md => DESIGN.md +86 -0
@@ 0,0 1,86 @@
# Design System — gstack

## Product Context
- **What this is:** Community website for gstack — a CLI tool that turns Claude Code into a virtual engineering team
- **Who it's for:** Developers discovering gstack, existing community members
- **Space/industry:** Developer tools (peers: Linear, Raycast, Warp, Zed)
- **Project type:** Community dashboard + marketing site

## Aesthetic Direction
- **Direction:** Industrial/Utilitarian — function-first, data-dense, monospace as personality font
- **Decoration level:** Intentional — subtle noise/grain texture on surfaces for materiality
- **Mood:** Serious tool built by someone who cares about craft. Warm, not cold. The CLI heritage IS the brand.
- **Reference sites:** formulae.brew.sh (competitor, but ours is live and interactive), Linear (dark + restrained), Warp (warm accents)

## Typography
- **Display/Hero:** Satoshi (Black 900 / Bold 700) — geometric with warmth, distinctive letterforms (the lowercase 'a' and 'g'). Not Inter, not Geist. Loaded from Fontshare CDN.
- **Body:** DM Sans (Regular 400 / Medium 500 / Semibold 600) — clean, readable, slightly friendlier than geometric display. Loaded from Google Fonts.
- **UI/Labels:** DM Sans (same as body)
- **Data/Tables:** JetBrains Mono (Regular 400 / Medium 500) — the personality font. Supports tabular-nums. Monospace should be prominent, not hidden in code blocks. Loaded from Google Fonts.
- **Code:** JetBrains Mono
- **Loading:** Google Fonts for DM Sans + JetBrains Mono, Fontshare for Satoshi. Use `display=swap`.
- **Scale:**
  - Hero: 72px / clamp(40px, 6vw, 72px)
  - H1: 48px
  - H2: 32px
  - H3: 24px
  - H4: 18px
  - Body: 16px
  - Small: 14px
  - Caption: 13px
  - Micro: 12px
  - Nano: 11px (JetBrains Mono labels)

## Color
- **Approach:** Restrained — amber accent is rare and meaningful. Dashboard data gets the color; chrome stays neutral.
- **Primary (dark mode):** amber-500 #F59E0B — warm, energetic, reads as "terminal cursor"
- **Primary (light mode):** amber-600 #D97706 — darker for contrast against white backgrounds
- **Primary text accent (dark mode):** amber-400 #FBBF24
- **Primary text accent (light mode):** amber-700 #B45309
- **Neutrals:** Cool zinc grays
  - zinc-50: #FAFAFA (lightest)
  - zinc-400: #A1A1AA
  - zinc-600: #52525B
  - zinc-800: #27272A
  - Surface (dark): #141414
  - Base (dark): #0C0C0C
  - Surface (light): #FFFFFF
  - Base (light): #FAFAF9
- **Semantic:** success #22C55E, warning #F59E0B, error #EF4444, info #3B82F6
- **Dark mode:** Default. Near-black base (#0C0C0C), surface cards at #141414, borders at #262626.
- **Light mode:** Warm stone base (#FAFAF9), white surface cards, stone borders (#E7E5E4). Amber accent shifts to amber-600 for contrast.

## Spacing
- **Base unit:** 4px
- **Density:** Comfortable — not cramped (not Bloomberg Terminal), not spacious (not a marketing site)
- **Scale:** 2xs(2px) xs(4px) sm(8px) md(16px) lg(24px) xl(32px) 2xl(48px) 3xl(64px)

## Layout
- **Approach:** Grid-disciplined for dashboard, editorial hero for landing page
- **Grid:** 12 columns at lg+, 1 column at mobile
- **Max content width:** 1200px (6xl)
- **Border radius:** sm:4px, md:8px, lg:12px, full:9999px
  - Cards/panels: lg (12px)
  - Buttons/inputs: md (8px)
  - Badges/pills: full (9999px)
  - Skill bars: sm (4px)

## Motion
- **Approach:** Minimal-functional — only transitions that aid comprehension. The dashboard's live feed IS the motion.
- **Easing:** enter(ease-out / cubic-bezier(0.16,1,0.3,1)) exit(ease-in) move(ease-in-out)
- **Duration:** micro(50-100ms) short(150ms) medium(250ms) long(400ms)
- **Animated elements:** live feed dot pulse (2s infinite), skill bar fill (600ms ease-out), hover states (150ms)

## Grain Texture
Apply a subtle noise overlay to the entire page for materiality:
- Dark mode: opacity 0.03
- Light mode: opacity 0.02
- Use SVG feTurbulence filter as a CSS background-image on body::after
- pointer-events: none, position: fixed, z-index: 9999

## Decisions Log
| Date | Decision | Rationale |
|------|----------|-----------|
| 2026-03-21 | Initial design system | Created by /design-consultation. Industrial aesthetic, warm amber accent, Satoshi + DM Sans + JetBrains Mono. |
| 2026-03-21 | Light mode amber-600 | amber-500 too bright/washed against white; amber-700 too brown/umber. amber-600 is the sweet spot. |
| 2026-03-21 | Grain texture | Adds materiality to flat dark surfaces. Prevents the "generic SaaS template" sameness. |

M README.md => README.md +32 -2
@@ 157,7 157,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan-
| `/benchmark` | **Performance Engineer** | Baseline page load times, Core Web Vitals, and resource sizes. Compare before/after on every PR. |
| `/document-release` | **Technical Writer** | Update all project docs to match what you just shipped. Catches stale READMEs automatically. |
| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini). |
| `/browse` | **QA Engineer** | Real Chromium browser, real clicks, real screenshots. ~100ms per command. |
| `/browse` | **QA Engineer** | Give the agent eyes. Real Chromium browser, real clicks, real screenshots. ~100ms per command. `$B connect` launches your real Chrome as a headed window — watch every action live. |
| `/setup-browser-cookies` | **Session Manager** | Import cookies from your real browser (Chrome, Arc, Brave, Edge) into the headless session. Test authenticated pages. |
| `/autoplan` | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → eng review automatically with encoded decision principles. Surfaces only taste decisions for your approval. |



@@ 179,7 179,37 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan-

gstack works well with one sprint. It gets interesting with ten running at once.

[Conductor](https://conductor.build) runs multiple Claude Code sessions in parallel — each in its own isolated workspace. One session on `/office-hours`, another on `/review`, a third implementing a feature, a fourth running `/qa`. All at the same time. The sprint structure is what makes parallelism work — without a process, ten agents is ten sources of chaos. With a process, each agent knows exactly what to do and when to stop.
**Design is at the heart.** `/design-consultation` doesn't just pick fonts. It researches what's out there in your space, proposes safe choices AND creative risks, generates realistic mockups of your actual product, and writes `DESIGN.md` — and then `/design-review` and `/plan-eng-review` read what you chose. Design decisions flow through the whole system.

**`/qa` was a massive unlock.** It let me go from 6 to 12 parallel workers. Claude Code saying *"I SEE THE ISSUE"* and then actually fixing it, generating a regression test, and verifying the fix — that changed how I work. The agent has eyes now.

**Smart review routing.** Just like at a well-run startup: CEO doesn't have to look at infra bug fixes, design review isn't needed for backend changes. gstack tracks what reviews are run, figures out what's appropriate, and just does the smart thing. The Review Readiness Dashboard tells you where you stand before you ship.

**Test everything.** `/ship` bootstraps test frameworks from scratch if your project doesn't have one. Every `/ship` run produces a coverage audit. Every `/qa` bug fix generates a regression test. 100% test coverage is the goal — tests make vibe coding safe instead of yolo coding.

**`/document-release` is the engineer you never had.** It reads every doc file in your project, cross-references the diff, and updates everything that drifted. README, ARCHITECTURE, CONTRIBUTING, CLAUDE.md, TODOS — all kept current automatically. And now `/ship` auto-invokes it — docs stay current without an extra command.

**Real browser mode.** `$B connect` launches your actual Chrome as a headed window controlled by Playwright. You watch Claude click, fill, and navigate in real time — same window, same screen. A subtle green shimmer at the top edge tells you which Chrome window gstack controls. All existing browse commands work unchanged. `$B disconnect` returns to headless. A Chrome extension Side Panel shows a live activity feed of every command and a chat sidebar where you can direct Claude. This is co-presence — Claude isn't remote-controlling a hidden browser, it's sitting next to you in the same cockpit.

**Sidebar agent — your AI browser assistant.** Type natural language instructions in the Chrome side panel and a child Claude instance executes them. "Navigate to the settings page and screenshot it." "Fill out this form with test data." "Go through every item in this list and extract the prices." Each task gets up to 5 minutes. The sidebar agent runs in an isolated session, so it won't interfere with your main Claude Code window. It's like having a second pair of hands in the browser.

**Personal automation.** The sidebar agent isn't just for dev workflows. Example: "Browse my kid's school parent portal and add all the other parents' names, phone numbers, and photos to my Google Contacts." Two ways to get authenticated: (1) log in once in the headed browser — your session persists, or (2) run `/setup-browser-cookies` to import cookies from your real Chrome. Once authenticated, Claude navigates the directory, extracts the data, and creates the contacts.

**Browser handoff when the AI gets stuck.** Hit a CAPTCHA, auth wall, or MFA prompt? `$B handoff` opens a visible Chrome at the exact same page with all your cookies and tabs intact. Solve the problem, tell Claude you're done, `$B resume` picks up right where it left off. The agent even suggests it automatically after 3 consecutive failures.

**Multi-AI second opinion.** `/codex` gets an independent review from OpenAI's Codex CLI — a completely different AI looking at the same diff. Three modes: code review with a pass/fail gate, adversarial challenge that actively tries to break your code, and open consultation with session continuity. When both `/review` (Claude) and `/codex` (OpenAI) have reviewed the same branch, you get a cross-model analysis showing which findings overlap and which are unique to each.

**Safety guardrails on demand.** Say "be careful" and `/careful` warns before any destructive command — rm -rf, DROP TABLE, force-push, git reset --hard. `/freeze` locks edits to one directory while debugging so Claude can't accidentally "fix" unrelated code. `/guard` activates both. `/investigate` auto-freezes to the module being investigated.

**Proactive skill suggestions.** gstack notices what stage you're in — brainstorming, reviewing, debugging, testing — and suggests the right skill. Don't like it? Say "stop suggesting" and it remembers across sessions.

## 10-15 parallel sprints

gstack is powerful with one sprint. It is transformative with ten running at once.

[Conductor](https://conductor.build) runs multiple Claude Code sessions in parallel — each in its own isolated workspace. One session running `/office-hours` on a new idea, another doing `/review` on a PR, a third implementing a feature, a fourth running `/qa` on staging, and six more on other branches. All at the same time. I regularly run 10-15 parallel sprints — that's the practical max right now.

The sprint structure is what makes parallelism work. Without a process, ten agents is ten sources of chaos. With a process — think, plan, build, review, test, ship — each agent knows exactly what to do and when to stop. You manage them the way a CEO manages a team: check in on the decisions that matter, let the rest run.

---


M SKILL.md => SKILL.md +7 -0
@@ 591,6 591,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 |


@@ 603,9 606,13 @@ 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 |


M TODOS.md => TODOS.md +58 -17
@@ 14,6 14,26 @@
**Priority:** P2
**Depends on:** Blog post about Search Before Building

## Chrome DevTools MCP Integration

### Real Chrome session access

**What:** Integrate Chrome DevTools MCP to connect to the user's real Chrome session with real cookies, real state, no Playwright middleman.

**Why:** Right now, headed mode launches a fresh Chromium profile. Users must log in manually or import cookies. Chrome DevTools MCP connects to the user's actual Chrome ... instant access to every authenticated site. This is the future of browser automation for AI agents.

**Context:** Google shipped Chrome DevTools MCP in Chrome 146+ (June 2025). It provides screenshots, console messages, performance traces, Lighthouse audits, and full page interaction through the user's real browser. gstack should use it for real-session access while keeping Playwright for headless CI/testing workflows.

Potential new skills:
- `/debug-browser`: JS error tracing with source-mapped stack traces
- `/perf-debug`: performance traces, Core Web Vitals, network waterfall

May replace `/setup-browser-cookies` for most use cases since the user's real cookies are already there.

**Effort:** L (human: ~2 weeks / CC: ~2 hours)
**Priority:** P0
**Depends on:** Chrome 146+, DevTools MCP server installed

## Browse

### Bundle server.ts into compiled binary


@@ 60,17 80,14 @@
**Effort:** S
**Priority:** P3

### State persistence
### State persistence — SHIPPED

**What:** Save/load cookies + localStorage to JSON files for reproducible test sessions.
~~**What:** Save/load cookies + localStorage to JSON files for reproducible test sessions.~~

**Why:** Enables "resume where I left off" for QA sessions and repeatable auth states.
`$B state save/load` ships in v0.12.1.0. V1 saves cookies + URLs only (not localStorage, which breaks on load-before-navigate). Files at `.gstack/browse-states/{name}.json` with 0o600 permissions. Load replaces session (closes all pages first). Name sanitized to `[a-zA-Z0-9_-]`.

**Context:** The `saveState()`/`restoreState()` helpers from the handoff feature (browser-manager.ts) already capture cookies + localStorage + sessionStorage + URLs. Adding file I/O on top is ~20 lines.

**Effort:** S
**Priority:** P3
**Depends on:** Sessions
**Remaining:** V2 localStorage support (needs pre-navigation injection strategy).
**Completed:** v0.12.1.0 (2026-03-26)

### Auth vault



@@ 82,14 99,13 @@
**Priority:** P3
**Depends on:** Sessions, state persistence

### Iframe support
### Iframe support — SHIPPED

**What:** `frame <sel>` and `frame main` commands for cross-frame interaction.
~~**What:** `frame <sel>` and `frame main` commands for cross-frame interaction.~~

**Why:** Many web apps use iframes (embeds, payment forms, ads). Currently invisible to browse.
`$B frame` ships in v0.12.1.0. Supports CSS selector, @ref, `--name`, and `--url` pattern matching. Execution target abstraction (`getActiveFrameOrPage()`) across all read/write/snapshot commands. Frame context cleared on navigation, tab switch, resume. Detached frame auto-recovery. Page-only operations (goto, screenshot, viewport) throw clear error when in frame context.

**Effort:** M
**Priority:** P4
**Completed:** v0.12.1.0 (2026-03-26)

### Semantic locators



@@ 145,14 161,39 @@
**Effort:** L
**Priority:** P4

### CDP mode
### Headed mode with Chrome extension — SHIPPED

**What:** Connect to already-running Chrome/Electron apps via Chrome DevTools Protocol.
`$B connect` launches Playwright's bundled Chromium in headed mode with the gstack Chrome extension auto-loaded. `$B handoff` now produces the same result (extension + side panel). Sidebar chat gated behind `--chat` flag.

**Why:** Test production apps, Electron apps, and existing browser sessions without launching new instances.
### `$B watch` — SHIPPED

**Effort:** M
Claude observes user browsing in passive read-only mode with periodic snapshots. `$B watch stop` exits with summary. Mutation commands blocked during watch.

### Sidebar scout / file drop relay — SHIPPED

Sidebar agent writes structured messages to `.context/sidebar-inbox/`. Workspace agent reads via `$B inbox`. Message format: `{type, timestamp, page, userMessage, sidebarSessionId}`.

### Multi-agent tab isolation

**What:** Two Claude sessions connect to the same browser, each operating on different tabs. No cross-contamination.

**Why:** Enables parallel /qa + /design-review on different tabs in the same browser.

**Context:** Requires tab ownership model for concurrent headed connections. Playwright may not cleanly support two persistent contexts. Needs investigation.

**Effort:** L (human: ~2 weeks / CC: ~2 hours)
**Priority:** P3
**Depends on:** Headed mode (shipped)

### Chrome Web Store publishing

**What:** Publish the gstack browse Chrome extension to Chrome Web Store for easier install.

**Why:** Currently sideloaded via chrome://extensions. Web Store makes install one-click.

**Effort:** S
**Priority:** P4
**Depends on:** Chrome extension proving value via sideloading

### Linux cookie decryption — PARTIALLY SHIPPED


M VERSION => VERSION +1 -1
@@ 1,1 1,1 @@
0.11.21.0
0.12.1.0

A bin/chrome-cdp => bin/chrome-cdp +68 -0
@@ 0,0 1,68 @@
#!/bin/bash
# Launch Chrome with CDP (remote debugging) enabled.
# Usage: chrome-cdp [port]
#
# Chrome refuses --remote-debugging-port on its default data directory.
# We create a separate data dir with a symlink to the user's real profile,
# so Chrome thinks it's non-default but uses the same cookies/extensions.

PORT="${1:-9222}"
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
REAL_PROFILE="$HOME/Library/Application Support/Google/Chrome"
CDP_DATA_DIR="$HOME/.gstack/cdp-profile/chrome"

if ! [ -f "$CHROME" ]; then
  echo "Chrome not found at $CHROME" >&2
  exit 1
fi

# Check if Chrome is running
if pgrep -f "Google Chrome" >/dev/null 2>&1; then
  echo "Chrome is still running. Quitting..."
  osascript -e 'tell application "Google Chrome" to quit' 2>/dev/null

  # Wait for it to fully exit
  for i in $(seq 1 20); do
    pgrep -f "Google Chrome" >/dev/null 2>&1 || break
    sleep 0.5
  done

  if pgrep -f "Google Chrome" >/dev/null 2>&1; then
    echo "Chrome won't quit. Force-killing..." >&2
    pkill -f "Google Chrome"
    sleep 1
  fi
fi

# Set up CDP data dir with symlinked profile
# Chrome requires a "non-default" data dir for --remote-debugging-port.
# We symlink the real Default profile so cookies/extensions carry over.
mkdir -p "$CDP_DATA_DIR"
if [ -d "$REAL_PROFILE/Default" ] && ! [ -e "$CDP_DATA_DIR/Default" ]; then
  ln -s "$REAL_PROFILE/Default" "$CDP_DATA_DIR/Default"
  echo "Linked real Chrome profile into CDP data dir"
fi
# Also link Local State (contains crypto keys for cookie decryption, etc.)
if [ -f "$REAL_PROFILE/Local State" ] && ! [ -e "$CDP_DATA_DIR/Local State" ]; then
  ln -s "$REAL_PROFILE/Local State" "$CDP_DATA_DIR/Local State"
fi

echo "Launching Chrome with CDP on port $PORT..."
"$CHROME" \
  --remote-debugging-port="$PORT" \
  --user-data-dir="$CDP_DATA_DIR" \
  --restore-last-session &
disown

# Wait for CDP to be available
for i in $(seq 1 30); do
  if curl -s "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
    echo "CDP ready on port $PORT"
    echo "Run: \$B connect chrome"
    exit 0
  fi
  sleep 1
done

echo "CDP not available after 30s." >&2
exit 1

A bin/gstack-extension => bin/gstack-extension +65 -0
@@ 0,0 1,65 @@
#!/bin/bash
# gstack-extension — helper to install the Chrome extension
#
# When using $B connect, the extension auto-loads. This script is for
# installing it in your regular Chrome (not the Playwright-controlled one).

set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

# Find the extension directory
EXT_DIR=""
if [ -f "$REPO_ROOT/extension/manifest.json" ]; then
  EXT_DIR="$REPO_ROOT/extension"
elif [ -f "$HOME/.claude/skills/gstack/extension/manifest.json" ]; then
  EXT_DIR="$HOME/.claude/skills/gstack/extension"
fi

if [ -z "$EXT_DIR" ]; then
  echo "Error: extension/ directory not found."
  echo "Expected at: $REPO_ROOT/extension/ or ~/.claude/skills/gstack/extension/"
  exit 1
fi

# Copy path to clipboard
echo -n "$EXT_DIR" | pbcopy 2>/dev/null

# Get browse server port
PORT=""
STATE_FILE="$REPO_ROOT/.gstack/browse.json"
if [ -f "$STATE_FILE" ]; then
  PORT=$(grep -o '"port":[0-9]*' "$STATE_FILE" | grep -o '[0-9]*')
fi

echo "gstack Chrome Extension Setup"
echo "=============================="
echo ""
echo "Extension path (copied to clipboard):"
echo "  $EXT_DIR"
echo ""

if [ -n "$PORT" ]; then
  echo "Browse server port: $PORT"
  echo ""
fi

echo "Quick install (if using \$B connect):"
echo "  The extension auto-loads when you run \$B connect."
echo "  No manual installation needed!"
echo ""
echo "Manual install (for your regular Chrome):"
echo ""
echo "  1. Opening chrome://extensions now..."

# Open chrome://extensions
osascript -e 'tell application "Google Chrome" to open location "chrome://extensions"' 2>/dev/null || \
  open "chrome://extensions" 2>/dev/null || \
  echo "     Could not open Chrome. Navigate to chrome://extensions manually."

echo "  2. Toggle 'Developer mode' ON (top-right)"
echo "  3. Click 'Load unpacked'"
echo "  4. In the file picker: Cmd+Shift+G → paste (path is in your clipboard) → Enter → Select"
echo "  5. Click the gstack puzzle icon in toolbar → enter port: ${PORT:-<check \$B status>}"
echo "  6. Click 'Open Side Panel'"

M browse/SKILL.md => browse/SKILL.md +7 -0
@@ 474,6 474,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 |


@@ 486,8 489,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 |

A browse/src/activity.ts => browse/src/activity.ts +208 -0
@@ 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;
}

M browse/src/browser-manager.ts => browse/src/browser-manager.ts +301 -37
@@ 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);

      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.browser = newContext.browser();
      this.pages.clear();
      this.connectionMode = 'headed';

      if (Object.keys(this.extraHeaders).length > 0) {
        await newContext.setExtraHTTPHeaders(this.extraHeaders);
      }

      // 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
      }
    });


M browse/src/cli.ts => browse/src/cli.ts +150 -2
@@ 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();

M browse/src/commands.ts => browse/src/commands.ts +17 -0
@@ 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

M browse/src/meta-commands.ts => browse/src/meta-commands.ts +276 -8
@@ 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}`);
  }

M browse/src/read-commands.ts => browse/src/read-commands.ts +23 -15
@@ 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 },
      }));

M browse/src/server.ts => browse/src/server.ts +758 -8
@@ 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) => {

A browse/src/sidebar-agent.ts => browse/src/sidebar-agent.ts +278 -0
@@ 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);

M browse/src/snapshot.ts => browse/src/snapshot.ts +16 -7
@@ 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');
}

M browse/src/write-commands.ts => browse/src/write-commands.ts +23 -13
@@ 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 => {

A browse/test/activity.test.ts => browse/test/activity.test.ts +120 -0
@@ 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);
  });
});

A browse/test/browser-manager-unit.test.ts => browse/test/browser-manager-unit.test.ts +17 -0
@@ 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([]);
  });
});

M browse/test/commands.test.ts => browse/test/commands.test.ts +235 -7
@@ 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 () => {});
  });
});

A browse/test/file-drop.test.ts => browse/test/file-drop.test.ts +271 -0
@@ 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);
  });
});

A browse/test/fixtures/iframe.html => browse/test/fixtures/iframe.html +30 -0
@@ 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>

A browse/test/fixtures/network-idle.html => browse/test/fixtures/network-idle.html +30 -0
@@ 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>

A browse/test/sidebar-agent.test.ts => browse/test/sidebar-agent.test.ts +199 -0
@@ 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);
  });
});

A browse/test/watch.test.ts => browse/test/watch.test.ts +129 -0
@@ 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);
  });
});

A connect-chrome/SKILL.md => connect-chrome/SKILL.md +412 -0
@@ 0,0 1,412 @@
---
name: connect-chrome
version: 0.1.0
description: |
  Launch real Chrome controlled by gstack with the Side Panel extension auto-loaded.
  One command: connects Claude to a visible Chrome window where you can watch every
  action in real time. The extension shows a live activity feed in the Side Panel.
  Use when asked to "connect chrome", "open chrome", "real browser", "launch chrome",
  "side panel", or "control my browser".
allowed-tools:
  - Bash
  - Read
  - AskUserQuestion

---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->

## Preamble (run first)

```bash
_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
[ -n "$_UPD" ] && echo "$_UPD" || true
mkdir -p ~/.gstack/sessions
touch ~/.gstack/sessions/"$PPID"
_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
find ~/.gstack/sessions -mmin +120 -type f -delete 2>/dev/null || true
_CONTRIB=$(~/.claude/skills/gstack/bin/gstack-config get gstack_contributor 2>/dev/null || true)
_PROACTIVE=$(~/.claude/skills/gstack/bin/gstack-config get proactive 2>/dev/null || echo "true")
_PROACTIVE_PROMPTED=$([ -f ~/.gstack/.proactive-prompted ] && echo "yes" || echo "no")
_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
echo "BRANCH: $_BRANCH"
echo "PROACTIVE: $_PROACTIVE"
echo "PROACTIVE_PROMPTED: $_PROACTIVE_PROMPTED"
source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true
REPO_MODE=${REPO_MODE:-unknown}
echo "REPO_MODE: $REPO_MODE"
_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no")
echo "LAKE_INTRO: $_LAKE_SEEN"
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true)
_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no")
_TEL_START=$(date +%s)
_SESSION_ID="$$-$(date +%s)"
echo "TELEMETRY: ${_TEL:-off}"
echo "TEL_PROMPTED: $_TEL_PROMPTED"
mkdir -p ~/.gstack/analytics
echo '{"skill":"connect-chrome","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}'  >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
# zsh-compatible: use find instead of glob to avoid NOMATCH error
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done
```

If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills AND do not
auto-invoke skills based on conversation context. Only run skills the user explicitly
types (e.g., /qa, /ship). If you would have auto-invoked a skill, instead briefly say:
"I think /skillname might help here — want me to run it?" and wait for confirmation.
The user opted out of proactive behavior.

If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.

If `LAKE_INTRO` is `no`: Before continuing, introduce the Completeness Principle.
Tell the user: "gstack follows the **Boil the Lake** principle — always do the complete
thing when AI makes the marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean"
Then offer to open the essay in their default browser:

```bash
open https://garryslist.org/posts/boil-the-ocean
touch ~/.gstack/.completeness-intro-seen
```

Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once.

If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled,
ask the user about telemetry. Use AskUserQuestion:

> Help gstack get better! Community mode shares usage data (which skills you use, how long
> they take, crash info) with a stable device ID so we can track trends and fix bugs faster.
> No code, file paths, or repo names are ever sent.
> Change anytime with `gstack-config set telemetry off`.

Options:
- A) Help gstack get better! (recommended)
- B) No thanks

If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry community`

If B: ask a follow-up AskUserQuestion:

> How about anonymous mode? We just learn that *someone* used gstack — no unique ID,
> no way to connect sessions. Just a counter that helps us know if anyone's out there.

Options:
- A) Sure, anonymous is fine
- B) No thanks, fully off

If B→A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous`
If B→B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off`

Always run:
```bash
touch ~/.gstack/.telemetry-prompted
```

This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely.

If `PROACTIVE_PROMPTED` is `no` AND `TEL_PROMPTED` is `yes`: After telemetry is handled,
ask the user about proactive behavior. Use AskUserQuestion:

> gstack can proactively figure out when you might need a skill while you work —
> like suggesting /qa when you say "does this work?" or /investigate when you hit
> a bug. We recommend keeping this on — it speeds up every part of your workflow.

Options:
- A) Keep it on (recommended)
- B) Turn it off — I'll type /commands myself

If A: run `~/.claude/skills/gstack/bin/gstack-config set proactive true`
If B: run `~/.claude/skills/gstack/bin/gstack-config set proactive false`

Always run:
```bash
touch ~/.gstack/.proactive-prompted
```

This only happens once. If `PROACTIVE_PROMPTED` is `yes`, skip this entirely.

## AskUserQuestion Format

**ALWAYS follow this structure for every AskUserQuestion call:**
1. **Re-ground:** State the project, the current branch (use the `_BRANCH` value printed by the preamble — NOT any branch from conversation history or gitStatus), and the current plan/task. (1-2 sentences)
2. **Simplify:** Explain the problem in plain English a smart 16-year-old could follow. No raw function names, no internal jargon, no implementation details. Use concrete examples and analogies. Say what it DOES, not what it's called.
3. **Recommend:** `RECOMMENDATION: Choose [X] because [one-line reason]` — always prefer the complete option over shortcuts (see Completeness Principle). Include `Completeness: X/10` for each option. Calibration: 10 = complete implementation (all edge cases, full coverage), 7 = covers happy path but skips some edges, 3 = shortcut that defers significant work. If both options are 8+, pick the higher; if one is ≤5, flag it.
4. **Options:** Lettered options: `A) ... B) ... C) ...` — when an option involves effort, show both scales: `(human: ~X / CC: ~Y)`

Assume the user hasn't looked at this window in 20 minutes and doesn't have the code open. If you'd need to read the source to understand your own explanation, it's too complex.

Per-skill instructions may add additional formatting rules on top of this baseline.

## Completeness Principle — Boil the Lake

AI makes completeness near-free. Always recommend the complete option over shortcuts — the delta is minutes with CC+gstack. A "lake" (100% coverage, all edge cases) is boilable; an "ocean" (full rewrite, multi-quarter migration) is not. Boil lakes, flag oceans.

**Effort reference** — always show both scales:

| Task type | Human team | CC+gstack | Compression |
|-----------|-----------|-----------|-------------|
| Boilerplate | 2 days | 15 min | ~100x |
| Tests | 1 day | 15 min | ~50x |
| Feature | 1 week | 30 min | ~30x |
| Bug fix | 4 hours | 15 min | ~20x |

Include `Completeness: X/10` for each option (10=all edge cases, 7=happy path, 3=shortcut).

## Repo Ownership — See Something, Say Something

`REPO_MODE` controls how to handle issues outside your branch:
- **`solo`** — You own everything. Investigate and offer to fix proactively.
- **`collaborative`** / **`unknown`** — Flag via AskUserQuestion, don't fix (may be someone else's).

Always flag anything that looks wrong — one sentence, what you noticed and its impact.

## Search Before Building

Before building anything unfamiliar, **search first.** See `~/.claude/skills/gstack/ETHOS.md`.
- **Layer 1** (tried and true) — don't reinvent. **Layer 2** (new and popular) — scrutinize. **Layer 3** (first principles) — prize above all.

**Eureka:** When first-principles reasoning contradicts conventional wisdom, name it and log:
```bash
jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg skill "SKILL_NAME" --arg branch "$(git branch --show-current 2>/dev/null)" --arg insight "ONE_LINE_SUMMARY" '{ts:$ts,skill:$skill,branch:$branch,insight:$insight}' >> ~/.gstack/analytics/eureka.jsonl 2>/dev/null || true
```

## Contributor Mode

If `_CONTRIB` is `true`: you are in **contributor mode**. At the end of each major workflow step, rate your gstack experience 0-10. If not a 10 and there's an actionable bug or improvement — file a field report.

**File only:** gstack tooling bugs where the input was reasonable but gstack failed. **Skip:** user app bugs, network errors, auth failures on user's site.

**To file:** write `~/.gstack/contributor-logs/{slug}.md`:
```
# {Title}
**What I tried:** {action} | **What happened:** {result} | **Rating:** {0-10}
## Repro
1. {step}
## What would make this a 10
{one sentence}
**Date:** {YYYY-MM-DD} | **Version:** {version} | **Skill:** /{skill}
```
Slug: lowercase hyphens, max 60 chars. Skip if exists. Max 3/session. File inline, don't stop.

## Completion Status Protocol

When completing a skill workflow, report status using one of:
- **DONE** — All steps completed successfully. Evidence provided for each claim.
- **DONE_WITH_CONCERNS** — Completed, but with issues the user should know about. List each concern.
- **BLOCKED** — Cannot proceed. State what is blocking and what was tried.
- **NEEDS_CONTEXT** — Missing information required to continue. State exactly what you need.

### Escalation

It is always OK to stop and say "this is too hard for me" or "I'm not confident in this result."

Bad work is worse than no work. You will not be penalized for escalating.
- If you have attempted a task 3 times without success, STOP and escalate.
- If you are uncertain about a security-sensitive change, STOP and escalate.
- If the scope of work exceeds what you can verify, STOP and escalate.

Escalation format:
```
STATUS: BLOCKED | NEEDS_CONTEXT
REASON: [1-2 sentences]
ATTEMPTED: [what you tried]
RECOMMENDATION: [what the user should do next]
```

## Telemetry (run last)

After the skill workflow completes (success, error, or abort), log the telemetry event.
Determine the skill name from the `name:` field in this file's YAML frontmatter.
Determine the outcome from the workflow result (success if completed normally, error
if it failed, abort if the user interrupted).

**PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes telemetry to
`~/.gstack/analytics/` (user config directory, not project files). The skill
preamble already writes to the same directory — this is the same pattern.
Skipping this command loses session duration and outcome data.

Run this bash:

```bash
_TEL_END=$(date +%s)
_TEL_DUR=$(( _TEL_END - _TEL_START ))
rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true
~/.claude/skills/gstack/bin/gstack-telemetry-log \
  --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \
  --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null &
```

Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with
success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used.
If you cannot determine the outcome, use "unknown". This runs in the background and
never blocks the user.

## Plan Status Footer

When you are in plan mode and about to call ExitPlanMode:

1. Check if the plan file already has a `## GSTACK REVIEW REPORT` section.
2. If it DOES — skip (a review skill already wrote a richer report).
3. If it does NOT — run this command:

\`\`\`bash
~/.claude/skills/gstack/bin/gstack-review-read
\`\`\`

Then write a `## GSTACK REVIEW REPORT` section to the end of the plan file:

- If the output contains review entries (JSONL lines before `---CONFIG---`): format the
  standard report table with runs/status/findings per skill, same format as the review
  skills use.
- If the output is `NO_REVIEWS` or empty: write this placeholder table:

\`\`\`markdown
## GSTACK REVIEW REPORT

| Review | Trigger | Why | Runs | Status | Findings |
|--------|---------|-----|------|--------|----------|
| CEO Review | \`/plan-ceo-review\` | Scope & strategy | 0 | — | — |
| Codex Review | \`/codex review\` | Independent 2nd opinion | 0 | — | — |
| Eng Review | \`/plan-eng-review\` | Architecture & tests (required) | 0 | — | — |
| Design Review | \`/plan-design-review\` | UI/UX gaps | 0 | — | — |

**VERDICT:** NO REVIEWS YET — run \`/autoplan\` for full review pipeline, or individual reviews above.
\`\`\`

**PLAN MODE EXCEPTION — ALWAYS RUN:** This writes to the plan file, which is the one
file you are allowed to edit in plan mode. The plan file review report is part of the
plan's living status.

# /connect-chrome — Launch Real Chrome with Side Panel

Connect Claude to a visible Chrome window with the gstack extension auto-loaded.
You see every click, every navigation, every action in real time.

## SETUP (run this check BEFORE any browse command)

```bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse
if [ -x "$B" ]; then
  echo "READY: $B"
else
  echo "NEEDS_SETUP"
fi
```

If `NEEDS_SETUP`:
1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait.
2. Run: `cd <SKILL_DIR> && ./setup`
3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash`

## Step 1: Connect

```bash
$B connect
```

This launches your system Chrome via Playwright with:
- A visible window (headed mode, not headless)
- The gstack Chrome extension pre-loaded
- A green shimmer line + "gstack" pill so you know which window is controlled

If Chrome is already running, the server restarts in headed mode with a fresh
Chrome instance. Your regular Chrome stays untouched.

After connecting, print the output to the user.

## Step 2: Verify

```bash
$B status
```

Confirm the output shows `Mode: cdp`. Print the port number — the user may need
it for the Side Panel.

## Step 3: Guide the user to the Side Panel

Use AskUserQuestion:

> Chrome is launched with gstack control. You should see a green shimmer line at the
> top of the Chrome window and a small "gstack" pill in the bottom-right corner.
>
> The Side Panel extension is pre-loaded. To open it:
> 1. Look for the **puzzle piece icon** (Extensions) in Chrome's toolbar
> 2. Click it → find **gstack browse** → click the **pin icon** to pin it
> 3. Click the **gstack icon** in the toolbar
> 4. Click **Open Side Panel**
>
> The Side Panel shows a live feed of every browse command in real time.
>
> **Port:** The browse server is on port {PORT} — the extension auto-detects it
> if you're using the Playwright-controlled Chrome. If the badge stays gray, click
> the gstack icon and enter port {PORT} manually.

Options:
- A) I can see the Side Panel — let's go!
- B) I can see Chrome but can't find the extension
- C) Something went wrong

If B: Tell the user:
> The extension should be auto-loaded, but Chrome sometimes doesn't show it
> immediately. Try:
> 1. Type `chrome://extensions` in the address bar
> 2. Look for "gstack browse" — it should be listed and enabled
> 3. If not listed, click "Load unpacked" → navigate to the extension folder
>    (press Cmd+Shift+G in the file picker, paste this path):
>    `{EXTENSION_PATH}`
>
> Then pin it from the puzzle piece icon and open the Side Panel.

If C: Run `$B status` and show the output. Check if the server is healthy.

## Step 4: Demo

After the user confirms the Side Panel is working, run a quick demo so they
can see the activity feed in action:

```bash
$B goto https://news.ycombinator.com
```

Wait 2 seconds, then:

```bash
$B snapshot -i
```

Tell the user: "Check the Side Panel — you should see the `goto` and `snapshot`
commands appear in the activity feed. Every command Claude runs will show up here
in real time."

## Step 5: Sidebar chat

After the activity feed demo, tell the user about the sidebar chat:

> The Side Panel also has a **chat tab**. Try typing a message like "take a
> snapshot and describe this page." A child Claude instance will execute your
> request in the browser — you'll see the commands appear in the activity feed.
>
> The sidebar agent can navigate pages, click buttons, fill forms, and read
> content. Each task gets up to 5 minutes. It runs in an isolated session, so
> it won't interfere with this Claude Code window.

## Step 6: What's next

Tell the user:

> You're all set! Chrome is under Claude's control with the Side Panel showing
> live activity and a chat sidebar for direct commands. Here's what you can do:
>
> - **Chat in the sidebar** — type natural language instructions and Claude
>   executes them in the browser
> - **Run any browse command** — `$B goto`, `$B click`, `$B snapshot` — and
>   watch it happen in Chrome + the Side Panel
> - **Use /qa or /design-review** — they'll run in the visible Chrome window
>   instead of headless. No cookie import needed.
> - **`$B focus`** — bring Chrome to the foreground anytime
> - **`$B disconnect`** — return to headless mode when done

Then proceed with whatever the user asked to do. If they didn't specify a task,
ask what they'd like to test or browse.

A connect-chrome/SKILL.md.tmpl => connect-chrome/SKILL.md.tmpl +136 -0
@@ 0,0 1,136 @@
---
name: connect-chrome
version: 0.1.0
description: |
  Launch real Chrome controlled by gstack with the Side Panel extension auto-loaded.
  One command: connects Claude to a visible Chrome window where you can watch every
  action in real time. The extension shows a live activity feed in the Side Panel.
  Use when asked to "connect chrome", "open chrome", "real browser", "launch chrome",
  "side panel", or "control my browser".
allowed-tools:
  - Bash
  - Read
  - AskUserQuestion

---

{{PREAMBLE}}

# /connect-chrome — Launch Real Chrome with Side Panel

Connect Claude to a visible Chrome window with the gstack extension auto-loaded.
You see every click, every navigation, every action in real time.

{{BROWSE_SETUP}}

## Step 1: Connect

```bash
$B connect
```

This launches your system Chrome via Playwright with:
- A visible window (headed mode, not headless)
- The gstack Chrome extension pre-loaded
- A green shimmer line + "gstack" pill so you know which window is controlled

If Chrome is already running, the server restarts in headed mode with a fresh
Chrome instance. Your regular Chrome stays untouched.

After connecting, print the output to the user.

## Step 2: Verify

```bash
$B status
```

Confirm the output shows `Mode: cdp`. Print the port number — the user may need
it for the Side Panel.

## Step 3: Guide the user to the Side Panel

Use AskUserQuestion:

> Chrome is launched with gstack control. You should see a green shimmer line at the
> top of the Chrome window and a small "gstack" pill in the bottom-right corner.
>
> The Side Panel extension is pre-loaded. To open it:
> 1. Look for the **puzzle piece icon** (Extensions) in Chrome's toolbar
> 2. Click it → find **gstack browse** → click the **pin icon** to pin it
> 3. Click the **gstack icon** in the toolbar
> 4. Click **Open Side Panel**
>
> The Side Panel shows a live feed of every browse command in real time.
>
> **Port:** The browse server is on port {PORT} — the extension auto-detects it
> if you're using the Playwright-controlled Chrome. If the badge stays gray, click
> the gstack icon and enter port {PORT} manually.

Options:
- A) I can see the Side Panel — let's go!
- B) I can see Chrome but can't find the extension
- C) Something went wrong

If B: Tell the user:
> The extension should be auto-loaded, but Chrome sometimes doesn't show it
> immediately. Try:
> 1. Type `chrome://extensions` in the address bar
> 2. Look for "gstack browse" — it should be listed and enabled
> 3. If not listed, click "Load unpacked" → navigate to the extension folder
>    (press Cmd+Shift+G in the file picker, paste this path):
>    `{EXTENSION_PATH}`
>
> Then pin it from the puzzle piece icon and open the Side Panel.

If C: Run `$B status` and show the output. Check if the server is healthy.

## Step 4: Demo

After the user confirms the Side Panel is working, run a quick demo so they
can see the activity feed in action:

```bash
$B goto https://news.ycombinator.com
```

Wait 2 seconds, then:

```bash
$B snapshot -i
```

Tell the user: "Check the Side Panel — you should see the `goto` and `snapshot`
commands appear in the activity feed. Every command Claude runs will show up here
in real time."

## Step 5: Sidebar chat

After the activity feed demo, tell the user about the sidebar chat:

> The Side Panel also has a **chat tab**. Try typing a message like "take a
> snapshot and describe this page." A child Claude instance will execute your
> request in the browser — you'll see the commands appear in the activity feed.
>
> The sidebar agent can navigate pages, click buttons, fill forms, and read
> content. Each task gets up to 5 minutes. It runs in an isolated session, so
> it won't interfere with this Claude Code window.

## Step 6: What's next

Tell the user:

> You're all set! Chrome is under Claude's control with the Side Panel showing
> live activity and a chat sidebar for direct commands. Here's what you can do:
>
> - **Chat in the sidebar** — type natural language instructions and Claude
>   executes them in the browser
> - **Run any browse command** — `$B goto`, `$B click`, `$B snapshot` — and
>   watch it happen in Chrome + the Side Panel
> - **Use /qa or /design-review** — they'll run in the visible Chrome window
>   instead of headless. No cookie import needed.
> - **`$B focus`** — bring Chrome to the foreground anytime
> - **`$B disconnect`** — return to headless mode when done

Then proceed with whatever the user asked to do. If they didn't specify a task,
ask what they'd like to test or browse.

M design-review/SKILL.md => design-review/SKILL.md +6 -0
@@ 301,6 301,12 @@ You are a senior product designer AND a frontend engineer. Review live sites wit

**If no URL is given and you're on main/master:** Ask the user for a URL.

**CDP mode detection:** Check if browse is connected to the user's real browser:
```bash
$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false"
```
If `CDP_MODE=true`: skip cookie import steps — the real browser already has cookies and auth sessions. Skip headless detection workarounds.

**Check for DESIGN.md:**

Look for `DESIGN.md`, `design-system.md`, or similar in the repo root. If found, read it — all design decisions must be calibrated against it. Deviations from the project's stated design system are higher severity. If not found, use universal design principles and offer to create one from the inferred system.

M design-review/SKILL.md.tmpl => design-review/SKILL.md.tmpl +6 -0
@@ 42,6 42,12 @@ You are a senior product designer AND a frontend engineer. Review live sites wit

**If no URL is given and you're on main/master:** Ask the user for a URL.

**CDP mode detection:** Check if browse is connected to the user's real browser:
```bash
$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false"
```
If `CDP_MODE=true`: skip cookie import steps — the real browser already has cookies and auth sessions. Skip headless detection workarounds.

**Check for DESIGN.md:**

Look for `DESIGN.md`, `design-system.md`, or similar in the repo root. If found, read it — all design decisions must be calibrated against it. Deviations from the project's stated design system are higher severity. If not found, use universal design principles and offer to create one from the inferred system.

A docs/designs/CHROME_VS_CHROMIUM_EXPLORATION.md => docs/designs/CHROME_VS_CHROMIUM_EXPLORATION.md +84 -0
@@ 0,0 1,84 @@
# Chrome vs Chromium: Why We Use Playwright's Bundled Chromium

## The Original Vision

When we built `$B connect`, the plan was to connect to the user's **real Chrome browser** — the one with their cookies, sessions, extensions, and open tabs. No more cookie import. The design called for:

1. `chromium.connectOverCDP(wsUrl)` connecting to a running Chrome via CDP
2. Quit Chrome gracefully, relaunch with `--remote-debugging-port=9222`
3. Access the user's real browsing context

This is why `chrome-launcher.ts` existed (361 LOC of browser binary discovery, CDP port probing, and runtime detection) and why the method was called `connectCDP()`.

## What Actually Happened

Real Chrome silently blocks `--load-extension` when launched via Playwright's `channel: 'chrome'`. The extension wouldn't load. We needed the extension for the side panel (activity feed, refs, chat).

The implementation fell back to `chromium.launchPersistentContext()` with Playwright's bundled Chromium — which reliably loads extensions via `--load-extension` and `--disable-extensions-except`. But the naming stayed: `connectCDP()`, `connectionMode: 'cdp'`, `BROWSE_CDP_URL`, `chrome-launcher.ts`.

The original vision (access user's real browser state) was never implemented. We launched a fresh browser every time — functionally identical to Playwright's Chromium, but with 361 lines of dead code and misleading names.

## The Discovery (2026-03-22)

During a `/office-hours` design session, we traced the architecture and discovered:

1. `connectCDP()` doesn't use CDP — it calls `launchPersistentContext()`
2. `connectionMode: 'cdp'` is misleading — it's just "headed mode"
3. `chrome-launcher.ts` is dead code — its only import was in an unreachable `attemptReconnect()` method
4. `preExistingTabIds` was designed for protecting real Chrome tabs we never connect to
5. `$B handoff` (headless → headed) used a different API (`launch()` + `newContext()`) that couldn't load extensions, creating two different "headed" experiences

## The Fix

### Renamed
- `connectCDP()` → `launchHeaded()`
- `connectionMode: 'cdp'` → `connectionMode: 'headed'`
- `BROWSE_CDP_URL` → `BROWSE_HEADED`

### Deleted
- `chrome-launcher.ts` (361 LOC)
- `attemptReconnect()` (dead method)
- `preExistingTabIds` (dead concept)
- `reconnecting` field (dead state)
- `cdp-connect.test.ts` (tests for deleted code)

### Converged
- `$B handoff` now uses `launchPersistentContext()` + extension loading (same as `$B connect`)
- One headed mode, not two
- Handoff gives you the extension + side panel for free

### Gated
- Sidebar chat behind `--chat` flag
- `$B connect` (default): activity feed + refs only
- `$B connect --chat`: + experimental standalone chat agent

## Architecture (after)

```
Browser States:
  HEADLESS (default) ←→ HEADED ($B connect or $B handoff)
     Playwright            Playwright (same engine)
     launch()              launchPersistentContext()
     invisible             visible + extension + side panel

Sidebar (orthogonal add-on, headed only):
  Activity tab    — always on, shows live browse commands
  Refs tab        — always on, shows @ref overlays
  Chat tab        — opt-in via --chat, experimental standalone agent

Data Bridge (sidebar → workspace):
  Sidebar writes to .context/sidebar-inbox/*.json
  Workspace reads via $B inbox
```

## Why Not Real Chrome?

Real Chrome blocks `--load-extension` when launched by Playwright. This is a Chrome security feature — extensions loaded via command-line args are restricted in Chromium-based browsers to prevent malicious extension injection.

Playwright's bundled Chromium doesn't have this restriction because it's designed for testing and automation. The `ignoreDefaultArgs` option lets us bypass Playwright's own extension-blocking flags.

If we ever want to access the user's real cookies/sessions, the path is:
1. Cookie import (already works via `$B cookie-import`)
2. Conductor session injection (future — sidebar sends messages to workspace agent)

Not reconnecting to real Chrome.

A docs/designs/CONDUCTOR_CHROME_SIDEBAR_INTEGRATION.md => docs/designs/CONDUCTOR_CHROME_SIDEBAR_INTEGRATION.md +57 -0
@@ 0,0 1,57 @@
# Chrome Sidebar + Conductor: What We Need

## What we're building

Right now when Claude is working in a Conductor workspace — editing files, running tests, browsing your app — you can only watch from Conductor's chat window. If Claude is doing QA on your website, you see tool calls scrolling by but you can't actually *see* the browser.

We built a Chrome sidebar that fixes this. When you run `$B connect`, Chrome opens with a side panel that shows everything Claude is doing in real time. You can type messages in the sidebar and Claude acts on them — "click the signup button", "go to the settings page", "summarize what you see."

The problem: the sidebar currently runs its own separate Claude instance. It can't see what the main Conductor session is doing, and the main session can't see what the sidebar is doing. They're two separate agents that don't talk to each other.

The fix is simple: make the sidebar a *window into* the Conductor session, not a separate thing.

## What we need from Conductor (3 things)

### 1. Let us watch what the agent is doing

We need a way to subscribe to the active session's events. Something like an SSE stream or WebSocket that sends us events as they happen:

- "Claude is editing `src/App.tsx`"
- "Claude is running `npm test`"
- "Claude says: I'll fix the CSS issue..."

The sidebar already knows how to render these events — tool calls show as compact badges, text shows as chat bubbles. We just need a pipe from Conductor's session to our extension.

### 2. Let us send messages into the session

When the user types "click the other button" in the Chrome sidebar, that message should appear in the Conductor session as if the user typed it in the workspace chat. The agent picks it up on its next turn and acts on it.

This is the magic moment: user is watching Chrome, sees something wrong, types a correction in the sidebar, and Claude responds — without the user ever switching windows.

### 3. Let us create a workspace from a directory

When `$B connect` launches, it creates a git worktree for file isolation. We want to register that worktree as a Conductor workspace so the user can see the sidebar agent's file changes in Conductor's file tree. This also sets up the foundation for multiple browser sessions, each with their own workspace.

## Why this matters

Today, `/qa` and `/design-review` feel like a black box. Claude says "I found 3 issues" but you can't see what it's looking at. With the sidebar connected to Conductor:

- **You watch Claude test your app** in real time — every click, every navigation, every screenshot appears in Chrome while you watch
- **You can interrupt** — "no, test the mobile view" or "skip that page" — without switching windows
- **One agent, two views** — the same Claude that's editing your code is also controlling the browser. No context duplication, no stale state

## What's already built (gstack side)

Everything on our side is done and shipping:

- Chrome extension that auto-loads when you run `$B connect`
- Side panel that auto-opens (zero setup for the user)
- Streaming event renderer (tool calls, text, results)
- Chat input with message queuing
- Reconnect logic with status banners
- Session management with persistent chat history
- Agent lifecycle (spawn, stop, kill, timeout detection)

The only change on our side: swap the data source from "local `claude -p` subprocess" to "Conductor session stream." The extension code stays the same.

**Estimated effort:** 2-3 days Conductor engineering, 1 day gstack integration.

A docs/designs/CONDUCTOR_SESSION_API.md => docs/designs/CONDUCTOR_SESSION_API.md +108 -0
@@ 0,0 1,108 @@
# Conductor Session Streaming API Proposal

## Problem

When Claude controls your real browser via CDP (gstack `$B connect`), you look at two
windows: **Conductor** (to see Claude's thinking) and **Chrome** (to see Claude's actions).

gstack's Chrome extension Side Panel shows browse activity — every command, result,
and error. But for *full* session mirroring (Claude's thinking, tool calls, code edits),
the Side Panel needs Conductor to expose the conversation stream.

## What this enables

A "Session" tab in the gstack Chrome extension Side Panel that shows:
- Claude's thinking/content (truncated for performance)
- Tool call names + icons (Edit, Bash, Read, etc.)
- Turn boundaries with cost estimates
- Real-time updates as the conversation progresses

The user sees everything in one place — Claude's actions in their browser + Claude's
thinking in the Side Panel — without switching windows.

## Proposed API

### `GET http://127.0.0.1:{PORT}/workspace/{ID}/session/stream`

Server-Sent Events endpoint that re-emits Claude Code's conversation as NDJSON events.

**Event types** (reuse Claude Code's `--output-format stream-json` format):

```
event: assistant
data: {"type":"assistant","content":"Let me check that page...","truncated":true}

event: tool_use
data: {"type":"tool_use","name":"Bash","input":"$B snapshot","truncated_input":true}

event: tool_result
data: {"type":"tool_result","name":"Bash","output":"[snapshot output...]","truncated_output":true}

event: turn_complete
data: {"type":"turn_complete","input_tokens":1234,"output_tokens":567,"cost_usd":0.02}
```

**Content truncation:** Tool inputs/outputs capped at 500 chars in the stream. Full
data stays in Conductor's UI. The Side Panel is a summary view, not a replacement.

### `GET http://127.0.0.1:{PORT}/api/workspaces`

Discovery endpoint listing active workspaces.

```json
{
  "workspaces": [
    {
      "id": "abc123",
      "name": "gstack",
      "branch": "garrytan/chrome-extension-ctrl",
      "directory": "/Users/garry/gstack",
      "pid": 12345,
      "active": true
    }
  ]
}
```

The Chrome extension auto-selects a workspace by matching the browse server's git repo
(from `/health` response) to a workspace's directory or name.

## Security

- **Localhost-only.** Same trust model as Claude Code's own debug output.
- **No auth required.** If Conductor wants auth, include a Bearer token in the
  workspace listing that the extension passes on SSE requests.
- **Content truncation** is a privacy feature — long code outputs, file contents, and
  sensitive tool results never leave Conductor's full UI.

## What gstack builds (extension side)

Already scaffolded in the Side Panel "Session" tab (currently shows placeholder).

When Conductor's API is available:
1. Side Panel discovers Conductor via port probe or manual entry
2. Fetches `/api/workspaces`, matches to browse server's repo
3. Opens `EventSource` to `/workspace/{id}/session/stream`
4. Renders: assistant messages, tool names + icons, turn boundaries, cost
5. Falls back gracefully: "Connect Conductor for full session view"

Estimated effort: ~200 LOC in `sidepanel.js`.

## What Conductor builds (server side)

1. SSE endpoint that re-emits Claude Code's stream-json per workspace
2. `/api/workspaces` discovery endpoint with active workspace list
3. Content truncation (500 char cap on tool inputs/outputs)

Estimated effort: ~100-200 LOC if Conductor already captures the Claude Code stream
internally (which it does for its own UI rendering).

## Design decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Transport | SSE (not WebSocket) | Unidirectional, auto-reconnect, simpler |
| Format | Claude's stream-json | Conductor already parses this; no new schema |
| Discovery | HTTP endpoint (not file) | Chrome extensions can't read filesystem |
| Auth | None (localhost) | Same as browse server, CDP port, Claude Code |
| Truncation | 500 chars | Side Panel is ~300px wide; long content useless |

A extension/background.js => extension/background.js +237 -0
@@ 0,0 1,237 @@
/**
 * gstack browse — background service worker
 *
 * Polls /health every 10s to detect browse server.
 * Fetches /refs on snapshot completion, relays to content script.
 * Proxies commands from sidebar → browse server.
 * Updates badge: amber (connected), gray (disconnected).
 */

const DEFAULT_PORT = 34567;  // Well-known port used by `$B connect`
let serverPort = null;
let authToken = null;
let isConnected = false;
let healthInterval = null;

// ─── Port Discovery ────────────────────────────────────────────

async function loadPort() {
  const data = await chrome.storage.local.get('port');
  serverPort = data.port || DEFAULT_PORT;
  return serverPort;
}

async function savePort(port) {
  serverPort = port;
  await chrome.storage.local.set({ port });
}

function getBaseUrl() {
  return serverPort ? `http://127.0.0.1:${serverPort}` : null;
}

// ─── Health Polling ────────────────────────────────────────────

async function checkHealth() {
  const base = getBaseUrl();
  if (!base) {
    setDisconnected();
    return;
  }

  try {
    const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
    if (!resp.ok) { setDisconnected(); return; }
    const data = await resp.json();
    if (data.status === 'healthy') {
      // Capture auth token from health response
      if (data.token) authToken = data.token;
      // Forward chatEnabled so sidepanel can show/hide chat tab
      setConnected({ ...data, chatEnabled: !!data.chatEnabled });
    } else {
      setDisconnected();
    }
  } catch {
    setDisconnected();
  }
}

function setConnected(healthData) {
  const wasDisconnected = !isConnected;
  isConnected = true;
  chrome.action.setBadgeBackgroundColor({ color: '#F59E0B' });
  chrome.action.setBadgeText({ text: ' ' });

  // Broadcast health to popup and side panel
  chrome.runtime.sendMessage({ type: 'health', data: healthData }).catch(() => {});

  // Notify content scripts on connection change
  if (wasDisconnected) {
    notifyContentScripts('connected');
  }
}

function setDisconnected() {
  const wasConnected = isConnected;
  isConnected = false;
  authToken = null;
  chrome.action.setBadgeText({ text: '' });

  chrome.runtime.sendMessage({ type: 'health', data: null }).catch(() => {});

  // Notify content scripts on disconnection
  if (wasConnected) {
    notifyContentScripts('disconnected');
  }
}

async function notifyContentScripts(type) {
  try {
    const tabs = await chrome.tabs.query({});
    for (const tab of tabs) {
      if (tab.id) {
        chrome.tabs.sendMessage(tab.id, { type }).catch(() => {});
      }
    }
  } catch {}
}

// ─── Command Proxy ─────────────────────────────────────────────

async function executeCommand(command, args) {
  const base = getBaseUrl();
  if (!base || !authToken) {
    return { error: 'Not connected to browse server' };
  }

  try {
    const resp = await fetch(`${base}/command`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${authToken}`,
      },
      body: JSON.stringify({ command, args }),
      signal: AbortSignal.timeout(30000),
    });
    const data = await resp.json();
    return data;
  } catch (err) {
    return { error: err.message || 'Command failed' };
  }
}

// ─── Refs Relay ─────────────────────────────────────────────────

async function fetchAndRelayRefs() {
  const base = getBaseUrl();
  if (!base || !isConnected) return;

  try {
    const resp = await fetch(`${base}/refs`, { signal: AbortSignal.timeout(3000) });
    if (!resp.ok) return;
    const data = await resp.json();

    // Send to all tabs' content scripts
    const tabs = await chrome.tabs.query({});
    for (const tab of tabs) {
      if (tab.id) {
        chrome.tabs.sendMessage(tab.id, { type: 'refs', data }).catch(() => {});
      }
    }
  } catch {}
}

// ─── Message Handling ──────────────────────────────────────────

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'getPort') {
    sendResponse({ port: serverPort, connected: isConnected });
    return true;
  }

  if (msg.type === 'setPort') {
    savePort(msg.port).then(() => {
      checkHealth();
      sendResponse({ ok: true });
    });
    return true;
  }

  if (msg.type === 'getServerUrl') {
    sendResponse({ url: getBaseUrl() });
    return true;
  }

  if (msg.type === 'getToken') {
    sendResponse({ token: authToken });
    return true;
  }

  if (msg.type === 'fetchRefs') {
    fetchAndRelayRefs().then(() => sendResponse({ ok: true }));
    return true;
  }

  // Open side panel from content script pill click
  if (msg.type === 'openSidePanel') {
    if (chrome.sidePanel?.open && sender.tab) {
      chrome.sidePanel.open({ tabId: sender.tab.id }).catch(() => {});
    }
    return;
  }

  // Sidebar → browse server command proxy
  if (msg.type === 'command') {
    executeCommand(msg.command, msg.args).then(result => sendResponse(result));
    return true;
  }

  // Sidebar → Claude Code (file-based message queue)
  if (msg.type === 'sidebar-command') {
    const base = getBaseUrl();
    if (!base || !authToken) {
      sendResponse({ error: 'Not connected' });
      return true;
    }
    fetch(`${base}/sidebar-command`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${authToken}`,
      },
      body: JSON.stringify({ message: msg.message }),
    })
      .then(r => r.json())
      .then(data => sendResponse(data))
      .catch(err => sendResponse({ error: err.message }));
    return true;
  }
});

// ─── Side Panel ─────────────────────────────────────────────────

// Click extension icon → open side panel directly (no popup)
if (chrome.sidePanel && chrome.sidePanel.setPanelBehavior) {
  chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});
}

// Auto-open side panel on install/update — zero friction
chrome.runtime.onInstalled.addListener(async () => {
  // Small delay to let the browser window fully initialize
  setTimeout(async () => {
    try {
      const [win] = await chrome.windows.getAll({ windowTypes: ['normal'] });
      if (win && chrome.sidePanel?.open) {
        await chrome.sidePanel.open({ windowId: win.id });
      }
    } catch {}
  }, 1000);
});

// ─── Startup ────────────────────────────────────────────────────

loadPort().then(() => {
  checkHealth();
  healthInterval = setInterval(checkHealth, 10000);
});

A extension/content.css => extension/content.css +124 -0
@@ 0,0 1,124 @@
/* gstack browse — ref overlay + status pill styles
 * Design system: DESIGN.md (amber accent, zinc neutrals)
 */

#gstack-ref-overlays {
  font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace !important;
}

/* Connection status pill — bottom-right corner */
#gstack-status-pill {
  position: fixed;
  bottom: 16px;
  right: 16px;
  z-index: 2147483646;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  background: rgba(12, 12, 12, 0.85);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px solid rgba(245, 158, 11, 0.25);
  border-radius: 9999px;
  color: #e0e0e0;
  font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.02em;
  pointer-events: auto;
  cursor: pointer;
  transition: opacity 0.5s ease;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}

#gstack-status-pill:hover {
  opacity: 1 !important;
}

.gstack-pill-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: #F59E0B;
  box-shadow: 0 0 6px rgba(245, 158, 11, 0.5);
  flex-shrink: 0;
}

@media (prefers-reduced-motion: reduce) {
  #gstack-status-pill {
    transition: none;
  }
}

.gstack-ref-badge {
  position: absolute;
  background: rgba(220, 38, 38, 0.9);
  color: #fff;
  font-size: 10px;
  font-weight: 700;
  padding: 1px 4px;
  border-radius: 4px;
  line-height: 14px;
  pointer-events: none;
  z-index: 2147483647;
}

/* Floating ref panel (used when positions are unknown) */
.gstack-ref-panel {
  position: fixed;
  bottom: 12px;
  right: 12px;
  width: 220px;
  max-height: 300px;
  background: rgba(12, 12, 12, 0.95);
  border: 1px solid #262626;
  border-radius: 8px;
  overflow: hidden;
  pointer-events: auto;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
  font-size: 11px;
}

.gstack-ref-panel-header {
  padding: 6px 10px;
  background: #141414;
  border-bottom: 1px solid #262626;
  color: #FAFAFA;
  font-weight: 600;
  font-size: 11px;
}

.gstack-ref-panel-list {
  max-height: 260px;
  overflow-y: auto;
}

.gstack-ref-panel-row {
  padding: 3px 10px;
  border-bottom: 1px solid #1f1f1f;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.gstack-ref-panel-id {
  color: #FBBF24;
  font-weight: 600;
  margin-right: 4px;
}

.gstack-ref-panel-role {
  color: #A1A1AA;
  margin-right: 4px;
}

.gstack-ref-panel-name {
  color: #e0e0e0;
}

.gstack-ref-panel-more {
  padding: 4px 10px;
  color: #52525B;
  font-style: italic;
}

A extension/content.js => extension/content.js +150 -0
@@ 0,0 1,150 @@
/**
 * gstack browse — content script
 *
 * Receives ref data from background worker via chrome.runtime.onMessage.
 * Renders @ref overlay badges on the page (CDP mode only — positions are accurate).
 * In headless mode, shows a floating ref panel instead (positions unknown).
 */

let overlayContainer = null;
let statusPill = null;
let pillFadeTimer = null;
let refCount = 0;

// ─── Connection Status Pill ──────────────────────────────────

function showStatusPill(connected, refs) {
  refCount = refs || 0;

  if (!statusPill) {
    statusPill = document.createElement('div');
    statusPill.id = 'gstack-status-pill';
    statusPill.style.cursor = 'pointer';
    statusPill.addEventListener('click', () => {
      // Ask background to open the side panel
      chrome.runtime.sendMessage({ type: 'openSidePanel' });
    });
    document.body.appendChild(statusPill);
  }

  if (!connected) {
    statusPill.style.display = 'none';
    return;
  }

  const refText = refCount > 0 ? ` · ${refCount} refs` : '';
  statusPill.innerHTML = `<span class="gstack-pill-dot"></span> gstack${refText}`;
  statusPill.style.display = 'flex';
  statusPill.style.opacity = '1';

  // Fade to subtle after 3s
  clearTimeout(pillFadeTimer);
  pillFadeTimer = setTimeout(() => {
    statusPill.style.opacity = '0.3';
  }, 3000);
}

function hideStatusPill() {
  if (statusPill) {
    statusPill.style.display = 'none';
  }
}

function ensureContainer() {
  if (overlayContainer) return overlayContainer;
  overlayContainer = document.createElement('div');
  overlayContainer.id = 'gstack-ref-overlays';
  overlayContainer.style.cssText = 'position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647; pointer-events: none;';
  document.body.appendChild(overlayContainer);
  return overlayContainer;
}

function clearOverlays() {
  if (overlayContainer) {
    overlayContainer.innerHTML = '';
  }
}

function renderRefBadges(refs) {
  clearOverlays();
  if (!refs || refs.length === 0) return;

  const container = ensureContainer();

  for (const ref of refs) {
    // Try to find the element using accessible name/role for positioning
    // In CDP mode, we could use bounding boxes from the server
    // For now, use a floating panel approach
    const badge = document.createElement('div');
    badge.className = 'gstack-ref-badge';
    badge.textContent = ref.ref;
    badge.title = `${ref.role}: "${ref.name}"`;
    container.appendChild(badge);
  }
}

function renderRefPanel(refs) {
  clearOverlays();
  if (!refs || refs.length === 0) return;

  const container = ensureContainer();

  const panel = document.createElement('div');
  panel.className = 'gstack-ref-panel';

  const header = document.createElement('div');
  header.className = 'gstack-ref-panel-header';
  header.textContent = `gstack refs (${refs.length})`;
  header.style.cssText = 'pointer-events: auto; cursor: move;';
  panel.appendChild(header);

  const list = document.createElement('div');
  list.className = 'gstack-ref-panel-list';
  for (const ref of refs.slice(0, 30)) { // Show max 30 in panel
    const row = document.createElement('div');
    row.className = 'gstack-ref-panel-row';
    row.innerHTML = `<span class="gstack-ref-panel-id">${ref.ref}</span> <span class="gstack-ref-panel-role">${ref.role}</span> <span class="gstack-ref-panel-name">"${ref.name}"</span>`;
    list.appendChild(row);
  }
  if (refs.length > 30) {
    const more = document.createElement('div');
    more.className = 'gstack-ref-panel-more';
    more.textContent = `+${refs.length - 30} more`;
    list.appendChild(more);
  }
  panel.appendChild(list);
  container.appendChild(panel);
}

// Listen for messages from background worker
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.type === 'refs' && msg.data) {
    const refs = msg.data.refs || [];
    const mode = msg.data.mode;

    if (refs.length === 0) {
      clearOverlays();
      showStatusPill(true, 0);
      return;
    }

    // CDP mode: could use bounding boxes (future)
    // For now: floating panel for all modes
    renderRefPanel(refs);
    showStatusPill(true, refs.length);
  }

  if (msg.type === 'clearRefs') {
    clearOverlays();
    showStatusPill(true, 0);
  }

  if (msg.type === 'connected') {
    showStatusPill(true, refCount);
  }

  if (msg.type === 'disconnected') {
    hideStatusPill();
    clearOverlays();
  }
});

A extension/icons/icon-128.png => extension/icons/icon-128.png +0 -0
A extension/icons/icon-16.png => extension/icons/icon-16.png +0 -0
A extension/icons/icon-48.png => extension/icons/icon-48.png +0 -0
A extension/manifest.json => extension/manifest.json +31 -0
@@ 0,0 1,31 @@
{
  "manifest_version": 3,
  "name": "gstack browse",
  "version": "0.1.0",
  "description": "Live activity feed and @ref overlays for gstack browse",
  "permissions": ["sidePanel", "storage", "activeTab"],
  "host_permissions": ["http://127.0.0.1:*/"],
  "action": {
    "default_icon": {
      "16": "icons/icon-16.png",
      "48": "icons/icon-48.png",
      "128": "icons/icon-128.png"
    }
  },
  "side_panel": {
    "default_path": "sidepanel.html"
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.js"],
    "css": ["content.css"]
  }],
  "icons": {
    "16": "icons/icon-16.png",
    "48": "icons/icon-48.png",
    "128": "icons/icon-128.png"
  }
}

A extension/popup.html => extension/popup.html +98 -0
@@ 0,0 1,98 @@
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      width: 240px;
      background: #0C0C0C;
      color: #e0e0e0;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
      font-size: 13px;
      padding: 16px;
    }
    h1 {
      font-size: 16px;
      font-weight: 700;
      color: #FAFAFA;
      margin-bottom: 16px;
      letter-spacing: -0.3px;
    }
    label {
      display: block;
      font-size: 12px;
      color: #A1A1AA;
      margin-bottom: 4px;
    }
    input {
      width: 100%;
      padding: 8px;
      background: #141414;
      border: 1px solid #262626;
      border-radius: 8px;
      color: #FAFAFA;
      font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
      font-size: 13px;
      outline: none;
      transition: border-color 150ms;
    }
    input:focus { border-color: #F59E0B; }
    .status {
      margin: 12px 0;
      display: flex;
      align-items: center;
      gap: 8px;
    }
    .dot {
      width: 8px;
      height: 8px;
      border-radius: 50%;
      background: #3f3f46;
      flex-shrink: 0;
    }
    .dot.connected { background: #22C55E; }
    .dot.error { background: #EF4444; }
    .dot.reconnecting {
      background: #F59E0B;
      animation: pulse 2s ease-in-out infinite;
    }
    @keyframes pulse {
      0%, 100% { opacity: 0.4; }
      50% { opacity: 1; }
    }
    .status-text { color: #A1A1AA; font-size: 12px; }
    .status-text.connected { color: #22C55E; }
    .details { color: #52525B; font-size: 11px; margin-top: 2px; }
    button {
      width: 100%;
      margin-top: 12px;
      padding: 8px;
      background: rgba(245, 158, 11, 0.1);
      border: 1px solid #F59E0B;
      border-radius: 8px;
      color: #FBBF24;
      font-size: 13px;
      cursor: pointer;
      transition: all 150ms;
    }
    button:hover { background: rgba(245, 158, 11, 0.2); }
  </style>
</head>
<body>
  <h1>gstack</h1>

  <label>Port</label>
  <input type="text" id="port" placeholder="34567" autocomplete="off">

  <div class="status">
    <div class="dot" id="dot"></div>
    <span class="status-text" id="status-text">Disconnected</span>
  </div>
  <div class="details" id="details"></div>

  <button id="side-panel-btn">Open Side Panel</button>

  <script src="popup.js"></script>
</body>
</html>

A extension/popup.js => extension/popup.js +60 -0
@@ 0,0 1,60 @@
const portInput = document.getElementById('port');
const dot = document.getElementById('dot');
const statusText = document.getElementById('status-text');
const details = document.getElementById('details');
const sidePanelBtn = document.getElementById('side-panel-btn');

// Load saved port
chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => {
  if (resp && resp.port) {
    portInput.value = resp.port;
    updateStatus(resp.connected);
  }
});

// Save port on change
let saveTimeout;
portInput.addEventListener('input', () => {
  clearTimeout(saveTimeout);
  saveTimeout = setTimeout(() => {
    const port = parseInt(portInput.value, 10);
    if (port > 0 && port < 65536) {
      chrome.runtime.sendMessage({ type: 'setPort', port });
    }
  }, 500);
});

// Listen for health updates
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.type === 'health') {
    updateStatus(!!msg.data, msg.data);
  }
});

function updateStatus(connected, data) {
  dot.className = `dot ${connected ? 'connected' : ''}`;
  statusText.className = `status-text ${connected ? 'connected' : ''}`;
  statusText.textContent = connected ? 'Connected' : 'Disconnected';

  if (connected && data) {
    const parts = [];
    if (data.tabs) parts.push(`${data.tabs} tabs`);
    if (data.mode) parts.push(`Mode: ${data.mode}`);
    details.textContent = parts.join(' \u00b7 ');
  } else {
    details.textContent = '';
  }
}

// Open side panel
sidePanelBtn.addEventListener('click', async () => {
  try {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    if (tab) {
      await chrome.sidePanel.open({ tabId: tab.id });
      window.close();
    }
  } catch (err) {
    details.textContent = `Side panel error: ${err.message}`;
  }
});

A extension/sidepanel.css => extension/sidepanel.css +704 -0
@@ 0,0 1,704 @@
/* gstack browse — Side Panel
 * Design system: DESIGN.md (Industrial/Utilitarian, amber accent, zinc neutrals)
 */

* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  /* Brand — amber accent, rare and meaningful */
  --amber-400: #FBBF24;
  --amber-500: #F59E0B;
  --amber-600: #D97706;

  /* Neutrals — cool zinc */
  --zinc-50: #FAFAFA;
  --zinc-400: #A1A1AA;
  --zinc-600: #52525B;
  --zinc-800: #27272A;

  /* Surfaces */
  --bg-base: #0C0C0C;
  --bg-surface: #141414;
  --bg-hover: #1a1a1a;
  --border: #262626;
  --border-subtle: #1f1f1f;

  /* Text hierarchy */
  --text-heading: #FAFAFA;
  --text-body: #e0e0e0;
  --text-label: #A1A1AA;
  --text-meta: #52525B;
  --text-disabled: #3f3f46;

  /* Semantic */
  --success: #22C55E;
  --warning: #F59E0B;
  --error: #EF4444;
  --info: #3B82F6;

  /* Typography */
  --font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;

  /* Radius */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-full: 9999px;
}

/* ─── Connection Banner ─────────────────────────────────────────── */

.conn-banner {
  padding: 6px 10px;
  font-size: 10px;
  font-family: var(--font-mono);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.conn-banner.reconnecting {
  background: rgba(245, 158, 11, 0.1);
  border-bottom: 1px solid rgba(245, 158, 11, 0.2);
  color: var(--amber-400);
}

.conn-banner.dead {
  background: rgba(239, 68, 68, 0.1);
  border-bottom: 1px solid rgba(239, 68, 68, 0.2);
  color: var(--error);
}

.conn-banner.reconnected {
  background: rgba(34, 197, 94, 0.1);
  border-bottom: 1px solid rgba(34, 197, 94, 0.2);
  color: var(--success);
  animation: fadeOut 3s ease forwards;
  animation-delay: 2s;
}

@keyframes fadeOut {
  to { opacity: 0; height: 0; padding: 0; overflow: hidden; }
}

.conn-banner-text {
  flex: 1;
}

.conn-btn {
  font-size: 9px;
  font-family: var(--font-mono);
  padding: 2px 8px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  border: 1px solid var(--border);
  background: var(--bg-surface);
  color: var(--text-label);
  transition: all 150ms;
}

.conn-btn:hover {
  background: var(--bg-hover);
  color: var(--text-heading);
}

.conn-copy {
  color: var(--text-meta);
  font-style: italic;
}

body {
  background: var(--bg-base);
  color: var(--text-body);
  font-family: var(--font-system);
  font-size: 12px;
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* Grain texture overlay */
body::after {
  content: '';
  position: fixed;
  top: 0; left: 0; right: 0; bottom: 0;
  pointer-events: none;
  z-index: 9999;
  opacity: 0.03;
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* ─── Status Dot ──────────────────────────────────────── */
.dot {
  width: 8px; height: 8px;
  border-radius: var(--radius-full);
  background: var(--text-disabled);
  flex-shrink: 0;
  transition: background 150ms;
}
.dot.connected { background: var(--success); }
.dot.reconnecting {
  background: var(--amber-500);
  animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
  0%, 100% { opacity: 0.4; }
  50% { opacity: 1; }
}

/* ─── Chat Messages ───────────────────────────────────── */
.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.chat-loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  text-align: center;
  color: var(--text-meta);
  gap: 12px;
  font-size: 13px;
}
.chat-loading-spinner {
  width: 24px;
  height: 24px;
  border: 2px solid var(--border);
  border-top-color: var(--amber-500);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}
.chat-welcome {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  text-align: center;
  color: var(--text-label);
  gap: 8px;
  padding: 24px;
}
.chat-welcome-icon {
  width: 40px;
  height: 40px;
  background: var(--amber-500);
  color: #000;
  font-weight: 800;
  font-size: 22px;
  border-radius: var(--radius-md);
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 8px;
}
.chat-welcome .muted { color: var(--text-meta); font-size: 12px; }

.chat-bubble {
  max-width: 90%;
  padding: 6px 10px;
  border-radius: var(--radius-lg);
  font-size: 11px;
  line-height: 1.4;
  word-break: break-word;
  animation: slideIn 150ms ease-out;
}
.chat-bubble.user {
  align-self: flex-end;
  background: var(--amber-500);
  color: #000;
  border-bottom-right-radius: var(--radius-sm);
}
.chat-bubble.assistant {
  align-self: flex-start;
  background: var(--bg-surface);
  color: var(--text-body);
  border: 1px solid var(--border);
  border-bottom-left-radius: var(--radius-sm);
}
.chat-bubble.assistant pre {
  background: var(--bg-base);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 6px 8px;
  margin: 6px 0;
  overflow-x: auto;
  font-family: var(--font-mono);
  font-size: 12px;
  white-space: pre-wrap;
}
.chat-bubble .chat-time, .agent-response > .chat-time {
  font-size: 9px;
  opacity: 0.4;
  margin-top: 2px;
  display: block;
}

/* ─── Agent Streaming Response ─────────────────────────── */
.agent-response {
  align-self: flex-start;
  max-width: 95%;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  border-bottom-left-radius: var(--radius-sm);
  padding: 6px 8px;
  display: flex;
  flex-direction: column;
  gap: 3px;
  animation: slideIn 150ms ease-out;
}
.agent-tool {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 2px 6px;
  background: var(--bg-base);
  border: 1px solid var(--border-subtle);
  border-radius: 3px;
  font-size: 10px;
  font-family: var(--font-mono);
  overflow: hidden;
}
.tool-name {
  color: var(--amber-500);
  font-weight: 600;
  flex-shrink: 0;
}
.tool-input {
  color: var(--text-disabled);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.agent-text {
  color: var(--text-body);
  font-size: 11px;
  line-height: 1.4;
  word-break: break-word;
}
.agent-text pre {
  background: var(--bg-base);
  border: 1px solid var(--border-subtle);
  border-radius: 3px;
  padding: 4px 6px;
  margin: 4px 0;
  overflow-x: auto;
  font-family: var(--font-mono);
  font-size: 10px;
  white-space: pre-wrap;
}
.agent-error {
  color: var(--error);
  font-size: 12px;
  font-family: var(--font-mono);
}

/* Thinking dots animation */
.agent-thinking {
  display: flex;
  gap: 4px;
  padding: 4px 0;
}
.thinking-dot {
  width: 4px;
  height: 4px;
  background: var(--text-disabled);
  border-radius: 50%;
  animation: thinkingPulse 1.4s ease-in-out infinite;
}
.thinking-dot:nth-child(2) { animation-delay: 0.2s; }
.thinking-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes thinkingPulse {
  0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
  40% { opacity: 1; transform: scale(1); }
}

/* ─── Footer Buttons ──────────────────────────────────── */
.footer-left {
  display: flex;
  gap: 4px;
}
.footer-btn, .debug-toggle {
  background: none;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-meta);
  font-family: var(--font-mono);
  font-size: 10px;
  padding: 2px 6px;
  cursor: pointer;
  transition: all 150ms;
}
.footer-btn:hover, .debug-toggle:hover {
  color: var(--text-label);
  border-color: var(--zinc-600);
}
.debug-toggle.active {
  color: var(--amber-400);
  border-color: var(--amber-500);
}
.debug-tabs {
  border-top: 1px solid var(--border);
}
.close-debug {
  width: 36px;
  flex: none !important;
  font-size: 16px;
  color: var(--text-meta) !important;
}
.close-debug:hover { color: var(--text-label) !important; }

/* ─── Tab Bar ─────────────────────────────────────────── */
.tabs {
  height: 36px;
  background: var(--bg-surface);
  border-bottom: 1px solid var(--border);
  display: flex;
  flex-shrink: 0;
}
.tab {
  flex: 1;
  background: none;
  border: none;
  color: var(--text-label);
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  border-bottom: 2px solid transparent;
  transition: all 150ms;
}
.tab:hover:not(.disabled) { color: var(--zinc-50); }
.tab.active {
  color: var(--text-heading);
  border-bottom-color: var(--amber-500);
}
.tab.disabled {
  color: var(--text-disabled);
  cursor: not-allowed;
}

/* ─── Tab Content ─────────────────────────────────────── */
.tab-content {
  display: none;
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
}
.tab-content.active { display: flex; flex-direction: column; }

/* ─── Activity Feed ───────────────────────────────────── */
#activity-feed { flex: 1; }

.activity-entry {
  padding: 8px 12px;
  border-left: 3px solid var(--border);
  border-bottom: 1px solid var(--border-subtle);
  cursor: pointer;
  transition: background 150ms;
  animation: slideIn 150ms ease-out;
}
.activity-entry:hover { background: var(--bg-hover); }

@media (prefers-reduced-motion: reduce) {
  .activity-entry { animation: none; }
}

@keyframes slideIn {
  from { transform: translateY(8px); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}

/* Left border colors by type */
.activity-entry.nav { border-left-color: var(--info); }
.activity-entry.interaction { border-left-color: var(--success); }
.activity-entry.observe { border-left-color: var(--amber-400); }
.activity-entry.error { border-left-color: var(--error); }
.activity-entry.pending {
  border-left-color: var(--amber-500);
  animation: slideIn 150ms ease-out, borderPulse 2s ease-in-out infinite;
}
@keyframes borderPulse {
  0%, 100% { border-left-color: rgba(245, 158, 11, 0.3); }
  50% { border-left-color: rgba(245, 158, 11, 1); }
}

.entry-header {
  display: flex;
  align-items: baseline;
  gap: 8px;
}
.entry-time {
  color: var(--text-meta);
  font-family: var(--font-mono);
  font-size: 11px;
  flex-shrink: 0;
}
.entry-command {
  color: var(--text-heading);
  font-family: var(--font-mono);
  font-size: 13px;
  font-weight: 600;
}
.entry-args {
  color: var(--text-label);
  font-family: var(--font-mono);
  font-size: 12px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin-top: 2px;
}
.entry-status {
  font-size: 11px;
  margin-top: 2px;
  display: flex;
  align-items: center;
  gap: 4px;
}
.entry-status .ok { color: var(--success); }
.entry-status .err { color: var(--error); }
.entry-status .duration { color: var(--text-meta); }

/* Expanded state */
.entry-detail {
  display: none;
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px dashed var(--border);
}
.activity-entry.expanded .entry-detail { display: block; }
.activity-entry.expanded .entry-args { white-space: normal; }
.entry-result {
  color: var(--zinc-400);
  font-family: var(--font-mono);
  font-size: 12px;
  white-space: pre-wrap;
  word-break: break-word;
}

/* ─── Refs Tab ────────────────────────────────────────── */
.ref-row {
  height: 32px;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 12px;
  border-bottom: 1px solid var(--border-subtle);
  font-size: 12px;
}
.ref-id {
  color: var(--amber-400);
  font-family: var(--font-mono);
  font-weight: 600;
  min-width: 32px;
}
.ref-role {
  color: var(--text-label);
  min-width: 60px;
}
.ref-name {
  color: var(--text-body);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.refs-footer {
  padding: 8px 12px;
  color: var(--text-meta);
  font-size: 11px;
  border-top: 1px solid var(--border);
}

/* ─── Session Placeholder ─────────────────────────────── */
.session-placeholder {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  text-align: center;
  color: var(--text-label);
  padding: 24px;
  gap: 8px;
}
.session-placeholder .muted { color: var(--text-meta); font-size: 12px; }

/* ─── Empty State ─────────────────────────────────────── */
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 40px 24px;
  text-align: center;
  color: var(--text-label);
  gap: 4px;
}
.empty-state .muted { color: var(--text-meta); font-size: 12px; }
.empty-state code {
  background: var(--bg-surface);
  padding: 2px 6px;
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: 12px;
}

/* ─── Gap Banner ──────────────────────────────────────── */
.gap-banner {
  background: rgba(245, 158, 11, 0.08);
  border-bottom: 1px solid var(--amber-500);
  color: var(--amber-400);
  font-size: 11px;
  padding: 6px 12px;
  animation: bannerSlide 250ms ease-out;
}
@keyframes bannerSlide {
  from { transform: translateY(-100%); }
  to { transform: translateY(0); }
}

/* ─── Command Bar ─────────────────────────────────────── */
.command-bar {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 8px;
  background: var(--bg-surface);
  border-top: 1px solid var(--border);
  flex-shrink: 0;
}
.command-prompt {
  color: var(--amber-500);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 700;
  flex-shrink: 0;
  user-select: none;
}
.command-input {
  flex: 1;
  background: var(--bg-base);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 6px 8px;
  color: var(--text-heading);
  font-family: var(--font-system);
  font-size: 11px;
  outline: none;
  transition: border-color 150ms;
}
.command-input:focus { border-color: var(--amber-500); }
.command-input::placeholder { color: var(--text-disabled); font-size: 10px; }
.command-input.sent {
  border-color: var(--success);
  transition: border-color 150ms;
}
.command-input.error {
  border-color: var(--error);
  animation: shake 300ms ease;
}
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-4px); }
  75% { transform: translateX(4px); }
}
.send-btn {
  width: 26px;
  height: 26px;
  background: var(--amber-500);
  border: none;
  border-radius: var(--radius-sm);
  color: #000;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
  flex-shrink: 0;
  transition: all 150ms;
  display: flex;
  align-items: center;
  justify-content: center;
}
.send-btn:hover { background: var(--amber-400); }
.send-btn:active { transform: scale(0.93); }
.send-btn:disabled {
  opacity: 0.3;
  cursor: not-allowed;
}

/* ─── Footer ──────────────────────────────────────────── */
footer {
  height: 28px;
  background: var(--bg-surface);
  border-top: 1px solid var(--border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 8px;
  font-size: 10px;
  color: var(--text-meta);
  flex-shrink: 0;
}
#footer-url {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 50%;
}
.footer-right {
  display: flex;
  align-items: center;
  gap: 6px;
}
.footer-port {
  color: var(--text-meta);
  font-family: var(--font-mono);
  font-size: 11px;
  cursor: pointer;
  transition: color 150ms;
}
.footer-port:hover { color: var(--text-label); }
.port-input {
  width: 56px;
  padding: 2px 6px;
  background: var(--bg-base);
  border: 1px solid var(--zinc-600);
  border-radius: var(--radius-sm);
  color: var(--text-heading);
  font-family: var(--font-mono);
  font-size: 11px;
  outline: none;
  transition: border-color 150ms;
}
.port-input:focus { border-color: var(--amber-500); }

/* ─── Experimental Banner ─────────────────────────────── */
.experimental-banner {
  background: rgba(245, 158, 11, 0.15);
  border: 1px solid rgba(245, 158, 11, 0.3);
  color: #F59E0B;
  padding: 8px 12px;
  border-radius: 6px;
  font-size: 12px;
  margin: 8px 12px;
  text-align: center;
  flex-shrink: 0;
}

/* ─── Accessibility ───────────────────────────────────── */
:focus-visible {
  outline: 2px solid var(--amber-500);
  outline-offset: 1px;
}

A extension/sidepanel.html => extension/sidepanel.html +84 -0
@@ 0,0 1,84 @@
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="sidepanel.css">
</head>
<body>
  <!-- Connection status banner -->
  <div class="conn-banner" id="conn-banner" style="display:none">
    <span class="conn-banner-text" id="conn-banner-text">Reconnecting...</span>
    <div class="conn-banner-actions" id="conn-banner-actions" style="display:none">
      <button class="conn-btn" id="conn-reconnect">Reconnect</button>
      <button class="conn-btn conn-copy" id="conn-copy" title="Copy command">/connect-chrome</button>
    </div>
  </div>

  <!-- Chat Tab (default, full height) -->
  <main id="tab-chat" class="tab-content active">
    <div class="chat-messages" id="chat-messages">
      <div class="chat-loading" id="chat-loading">
        <div class="chat-loading-spinner"></div>
        <p>Connecting...</p>
      </div>
      <div class="chat-welcome" id="chat-welcome" style="display:none">
        <div class="chat-welcome-icon">G</div>
        <p>Send a message to Claude Code.</p>
        <p class="muted">Your agent will see it and act on it.</p>
      </div>
    </div>
  </main>

  <!-- Debug: Activity Tab (hidden by default) -->
  <main id="tab-activity" class="tab-content" role="log" aria-live="polite">
    <div class="empty-state" id="empty-state">
      <p>Waiting for commands...</p>
      <p class="muted">Run a browse command to see activity here.</p>
    </div>
    <div id="activity-feed"></div>
  </main>

  <!-- Debug: Refs Tab (hidden by default) -->
  <main id="tab-refs" class="tab-content">
    <div class="empty-state" id="refs-empty">
      <p>No refs yet</p>
      <p class="muted">Run <code>snapshot</code> to see element refs.</p>
    </div>
    <div id="refs-list"></div>
    <div class="refs-footer" id="refs-footer"></div>
  </main>

  <!-- Experimental chat banner (shown when chatEnabled) -->
  <div id="experimental-banner" class="experimental-banner" style="display: none;">
    &#x26A0; Standalone mode &mdash; this is a separate agent from your workspace
  </div>

  <!-- Command Bar -->
  <div class="command-bar">
    <input type="text" class="command-input" id="command-input" placeholder="Message Claude Code..." autocomplete="off" spellcheck="false">
    <button class="send-btn" id="send-btn" title="Send">&#x2191;</button>
  </div>

  <!-- Footer with connection + debug toggle -->
  <footer>
    <div class="footer-left">
      <button class="debug-toggle" id="debug-toggle" title="Toggle debug panels">debug</button>
      <button class="footer-btn" id="clear-chat" title="Clear chat">clear</button>
    </div>
    <div class="footer-right">
      <span class="dot" id="footer-dot"></span>
      <span class="footer-port" id="footer-port" title="Click to change port"></span>
      <input type="text" class="port-input" id="port-input" placeholder="34567" autocomplete="off" style="display:none">
    </div>
  </footer>

  <!-- Debug tab bar (hidden by default) -->
  <nav class="tabs debug-tabs" id="debug-tabs" role="tablist" style="display:none">
    <button class="tab" role="tab" data-tab="activity">Activity</button>
    <button class="tab" role="tab" data-tab="refs">Refs</button>
    <button class="tab close-debug" id="close-debug" title="Close debug">&times;</button>
  </nav>

  <script src="sidepanel.js"></script>
</body>
</html>

A extension/sidepanel.js => extension/sidepanel.js +661 -0
@@ 0,0 1,661 @@
/**
 * gstack browse — Side Panel
 *
 * Chat tab: two-way messaging with Claude Code via file queue.
 * Debug tabs: activity feed (SSE) + refs (REST).
 * Polls /sidebar-chat for new messages every 1s.
 */

const NAV_COMMANDS = new Set(['goto', 'back', 'forward', 'reload']);
const INTERACTION_COMMANDS = new Set(['click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'upload']);
const OBSERVE_COMMANDS = new Set(['snapshot', 'screenshot', 'diff', 'console', 'network', 'text', 'html', 'links', 'forms', 'accessibility', 'cookies', 'storage', 'perf']);

let lastId = 0;
let eventSource = null;
let serverUrl = null;
let serverToken = null;
let chatLineCount = 0;
let chatPollInterval = null;
let connState = 'disconnected'; // disconnected | connected | reconnecting | dead
let reconnectAttempts = 0;
let reconnectTimer = null;
const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead"

// Auth headers for sidebar endpoints
function authHeaders() {
  const h = { 'Content-Type': 'application/json' };
  if (serverToken) h['Authorization'] = `Bearer ${serverToken}`;
  return h;
}

// ─── Connection State Machine ─────────────────────────────────────

function setConnState(state) {
  const prev = connState;
  connState = state;
  const banner = document.getElementById('conn-banner');
  const bannerText = document.getElementById('conn-banner-text');
  const bannerActions = document.getElementById('conn-banner-actions');

  if (state === 'connected') {
    if (prev === 'reconnecting' || prev === 'dead') {
      // Show "reconnected" toast that fades
      banner.style.display = '';
      banner.className = 'conn-banner reconnected';
      bannerText.textContent = 'Reconnected';
      bannerActions.style.display = 'none';
      setTimeout(() => { banner.style.display = 'none'; }, 5000);
    } else {
      banner.style.display = 'none';
    }
    reconnectAttempts = 0;
    if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
  } else if (state === 'reconnecting') {
    banner.style.display = '';
    banner.className = 'conn-banner reconnecting';
    bannerText.textContent = `Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`;
    bannerActions.style.display = 'none';
  } else if (state === 'dead') {
    banner.style.display = '';
    banner.className = 'conn-banner dead';
    bannerText.textContent = 'Server offline';
    bannerActions.style.display = '';
    if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
  } else {
    banner.style.display = 'none';
  }
}

function startReconnect() {
  if (reconnectTimer) return;
  setConnState('reconnecting');
  reconnectTimer = setInterval(() => {
    reconnectAttempts++;
    if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
      setConnState('dead');
      return;
    }
    setConnState('reconnecting');
    tryConnect();
  }, 2000);
}

// ─── Chat ───────────────────────────────────────────────────────

const chatMessages = document.getElementById('chat-messages');
const commandInput = document.getElementById('command-input');
const sendBtn = document.getElementById('send-btn');
const commandHistory = [];
let historyIndex = -1;

function formatChatTime(ts) {
  const d = new Date(ts);
  return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
}

// Current streaming state
let agentContainer = null; // The container for the current agent response
let agentTextEl = null;    // The text accumulator element
let agentText = '';        // Accumulated text

function addChatEntry(entry) {
  // Remove welcome message on first real message
  const welcome = chatMessages.querySelector('.chat-welcome');
  if (welcome) welcome.remove();

  // User messages → chat bubble
  if (entry.role === 'user') {
    const bubble = document.createElement('div');
    bubble.className = 'chat-bubble user';
    bubble.innerHTML = `${escapeHtml(entry.message)}<span class="chat-time">${formatChatTime(entry.ts)}</span>`;
    chatMessages.appendChild(bubble);
    bubble.scrollIntoView({ behavior: 'smooth', block: 'end' });
    return;
  }

  // Legacy assistant messages (from /sidebar-response)
  if (entry.role === 'assistant') {
    const bubble = document.createElement('div');
    bubble.className = 'chat-bubble assistant';
    let content = escapeHtml(entry.message);
    content = content.replace(/```([\s\S]*?)```/g, '<pre>$1</pre>');
    content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
    content = content.replace(/\n/g, '<br>');
    bubble.innerHTML = `${content}<span class="chat-time">${formatChatTime(entry.ts)}</span>`;
    chatMessages.appendChild(bubble);
    bubble.scrollIntoView({ behavior: 'smooth', block: 'end' });
    return;
  }

  // Agent streaming events
  if (entry.role === 'agent') {
    handleAgentEvent(entry);
    return;
  }
}

function handleAgentEvent(entry) {
  if (entry.type === 'agent_start') {
    // Create a new agent response container
    agentText = '';
    agentContainer = document.createElement('div');
    agentContainer.className = 'agent-response';
    agentTextEl = null;
    chatMessages.appendChild(agentContainer);

    // Add thinking indicator
    const thinking = document.createElement('div');
    thinking.className = 'agent-thinking';
    thinking.id = 'agent-thinking';
    thinking.innerHTML = '<span class="thinking-dot"></span><span class="thinking-dot"></span><span class="thinking-dot"></span>';
    agentContainer.appendChild(thinking);
    agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' });
    return;
  }

  if (entry.type === 'agent_done') {
    // Remove thinking indicator
    const thinking = document.getElementById('agent-thinking');
    if (thinking) thinking.remove();
    // Add timestamp
    if (agentContainer) {
      const ts = document.createElement('span');
      ts.className = 'chat-time';
      ts.textContent = formatChatTime(entry.ts);
      agentContainer.appendChild(ts);
    }
    agentContainer = null;
    agentTextEl = null;
    return;
  }

  if (entry.type === 'agent_error') {
    const thinking = document.getElementById('agent-thinking');
    if (thinking) thinking.remove();
    if (!agentContainer) {
      agentContainer = document.createElement('div');
      agentContainer.className = 'agent-response';
      chatMessages.appendChild(agentContainer);
    }
    const err = document.createElement('div');
    err.className = 'agent-error';
    err.textContent = entry.error || 'Unknown error';
    agentContainer.appendChild(err);
    agentContainer = null;
    return;
  }

  if (!agentContainer) {
    agentContainer = document.createElement('div');
    agentContainer.className = 'agent-response';
    chatMessages.appendChild(agentContainer);
  }

  // Remove thinking indicator on first real content
  const thinking = document.getElementById('agent-thinking');
  if (thinking) thinking.remove();

  if (entry.type === 'tool_use') {
    const toolEl = document.createElement('div');
    toolEl.className = 'agent-tool';
    const toolName = entry.tool || 'Tool';
    const toolInput = entry.input || '';
    toolEl.innerHTML = `<span class="tool-name">${escapeHtml(toolName)}</span> <span class="tool-input">${escapeHtml(toolInput)}</span>`;
    agentContainer.appendChild(toolEl);
    agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' });
    return;
  }

  if (entry.type === 'text' || entry.type === 'result') {
    // Full text replacement
    agentText = entry.text || '';
    if (!agentTextEl) {
      agentTextEl = document.createElement('div');
      agentTextEl.className = 'agent-text';
      agentContainer.appendChild(agentTextEl);
    }
    let content = escapeHtml(agentText);
    content = content.replace(/```([\s\S]*?)```/g, '<pre>$1</pre>');
    content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
    content = content.replace(/\n/g, '<br>');
    agentTextEl.innerHTML = content;
    agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' });
    return;
  }

  if (entry.type === 'text_delta') {
    // Incremental text append
    agentText += entry.text || '';
    if (!agentTextEl) {
      agentTextEl = document.createElement('div');
      agentTextEl.className = 'agent-text';
      agentContainer.appendChild(agentTextEl);
    }
    let content = escapeHtml(agentText);
    content = content.replace(/```([\s\S]*?)```/g, '<pre>$1</pre>');
    content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
    content = content.replace(/\n/g, '<br>');
    agentTextEl.innerHTML = content;
    agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' });
    return;
  }
}

async function sendMessage() {
  const msg = commandInput.value.trim();
  if (!msg) return;

  commandHistory.push(msg);
  historyIndex = commandHistory.length;
  commandInput.value = '';
  commandInput.disabled = true;
  sendBtn.disabled = true;

  const result = await new Promise((resolve) => {
    chrome.runtime.sendMessage({ type: 'sidebar-command', message: msg }, resolve);
  });

  commandInput.disabled = false;
  sendBtn.disabled = false;
  commandInput.focus();

  if (result?.ok) {
    // Immediately poll to show the user's own message
    pollChat();
  } else {
    commandInput.classList.add('error');
    commandInput.placeholder = result?.error || 'Failed to send';
    setTimeout(() => {
      commandInput.classList.remove('error');
      commandInput.placeholder = 'Message Claude Code...';
    }, 2000);
  }
}

commandInput.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') { e.preventDefault(); sendMessage(); }
  if (e.key === 'ArrowUp') {
    e.preventDefault();
    if (historyIndex > 0) { historyIndex--; commandInput.value = commandHistory[historyIndex]; }
  }
  if (e.key === 'ArrowDown') {
    e.preventDefault();
    if (historyIndex < commandHistory.length - 1) { historyIndex++; commandInput.value = commandHistory[historyIndex]; }
    else { historyIndex = commandHistory.length; commandInput.value = ''; }
  }
});

sendBtn.addEventListener('click', sendMessage);

// Poll for new chat messages
let initialLoadDone = false;

async function pollChat() {
  if (!serverUrl || !serverToken) return;
  try {
    const resp = await fetch(`${serverUrl}/sidebar-chat?after=${chatLineCount}`, {
      headers: authHeaders(),
      signal: AbortSignal.timeout(3000),
    });
    if (!resp.ok) return;
    const data = await resp.json();

    // First successful poll — hide loading spinner
    if (!initialLoadDone) {
      initialLoadDone = true;
      const loading = document.getElementById('chat-loading');
      const welcome = document.getElementById('chat-welcome');
      if (loading) loading.style.display = 'none';
      // Show welcome only if no chat history
      if (data.total === 0 && welcome) welcome.style.display = '';
    }

    if (data.entries && data.entries.length > 0) {
      // Hide welcome on first real entry
      const welcome = document.getElementById('chat-welcome');
      if (welcome) welcome.style.display = 'none';
      for (const entry of data.entries) {
        addChatEntry(entry);
      }
      chatLineCount = data.total;
    }
  } catch {}
}

// ─── Clear Chat ─────────────────────────────────────────────────

document.getElementById('clear-chat').addEventListener('click', async () => {
  if (!serverUrl) return;
  try {
    await fetch(`${serverUrl}/sidebar-chat/clear`, { method: 'POST', headers: authHeaders() });
  } catch {}
  // Reset local state
  chatLineCount = 0;
  agentContainer = null;
  agentTextEl = null;
  agentText = '';
  chatMessages.innerHTML = `
    <div class="chat-welcome" id="chat-welcome">
      <div class="chat-welcome-icon">G</div>
      <p>Send a message to Claude Code.</p>
      <p class="muted">Your agent will see it and act on it.</p>
    </div>`;
});

// ─── Debug Tabs ─────────────────────────────────────────────────

const debugToggle = document.getElementById('debug-toggle');
const debugTabs = document.getElementById('debug-tabs');
const closeDebug = document.getElementById('close-debug');
let debugOpen = false;

debugToggle.addEventListener('click', () => {
  debugOpen = !debugOpen;
  debugToggle.classList.toggle('active', debugOpen);
  debugTabs.style.display = debugOpen ? 'flex' : 'none';
  if (!debugOpen) {
    // Close debug panels, show chat
    document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
    document.getElementById('tab-chat').classList.add('active');
    document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
  }
});

closeDebug.addEventListener('click', () => {
  debugOpen = false;
  debugToggle.classList.remove('active');
  debugTabs.style.display = 'none';
  document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
  document.getElementById('tab-chat').classList.add('active');
});

document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => {
  tab.addEventListener('click', () => {
    document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
    document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
    tab.classList.add('active');
    document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');

    if (tab.dataset.tab === 'refs') fetchRefs();
  });
});

// ─── Activity Feed ──────────────────────────────────────────────

function getEntryClass(entry) {
  if (entry.status === 'error') return 'error';
  if (entry.type === 'command_start') return 'pending';
  const cmd = entry.command || '';
  if (NAV_COMMANDS.has(cmd)) return 'nav';
  if (INTERACTION_COMMANDS.has(cmd)) return 'interaction';
  if (OBSERVE_COMMANDS.has(cmd)) return 'observe';
  return '';
}

function formatTime(ts) {
  const d = new Date(ts);
  return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}

let pendingEntries = new Map();

function createEntryElement(entry) {
  const div = document.createElement('div');
  div.className = `activity-entry ${getEntryClass(entry)}`;
  div.setAttribute('role', 'article');
  div.tabIndex = 0;

  const argsText = entry.args ? entry.args.join(' ') : '';
  const statusIcon = entry.status === 'ok' ? '\u2713' : entry.status === 'error' ? '\u2717' : '';
  const statusClass = entry.status === 'ok' ? 'ok' : entry.status === 'error' ? 'err' : '';
  const duration = entry.duration ? `${entry.duration}ms` : '';

  div.innerHTML = `
    <div class="entry-header">
      <span class="entry-time">${formatTime(entry.timestamp)}</span>
      <span class="entry-command">${entry.command || entry.type}</span>
    </div>
    ${argsText ? `<div class="entry-args">${escapeHtml(argsText)}</div>` : ''}
    ${entry.type === 'command_end' ? `
      <div class="entry-status">
        <span class="${statusClass}">${statusIcon}</span>
        <span class="duration">${duration}</span>
      </div>
    ` : ''}
    ${entry.result ? `
      <div class="entry-detail">
        <div class="entry-result">${escapeHtml(entry.result)}</div>
      </div>
    ` : ''}
  `;

  div.addEventListener('click', () => div.classList.toggle('expanded'));
  return div;
}

function addEntry(entry) {
  const feed = document.getElementById('activity-feed');
  const empty = document.getElementById('empty-state');
  if (empty) empty.style.display = 'none';

  if (entry.type === 'command_end') {
    for (const [id, el] of pendingEntries) {
      if (el.querySelector('.entry-command')?.textContent === entry.command) {
        el.remove();
        pendingEntries.delete(id);
        break;
      }
    }
  }

  const el = createEntryElement(entry);
  feed.appendChild(el);
  if (entry.type === 'command_start') pendingEntries.set(entry.id, el);
  el.scrollIntoView({ behavior: 'smooth', block: 'end' });

  if (entry.url) document.getElementById('footer-url')?.textContent && (document.getElementById('footer-url').textContent = new URL(entry.url).hostname);
  lastId = Math.max(lastId, entry.id);
}

function escapeHtml(str) {
  const div = document.createElement('div');
  div.textContent = str;
  return div.innerHTML;
}

// ─── SSE Connection ─────────────────────────────────────────────

function connectSSE() {
  if (!serverUrl) return;
  if (eventSource) { eventSource.close(); eventSource = null; }

  const url = `${serverUrl}/activity/stream?after=${lastId}`;
  eventSource = new EventSource(url);

  eventSource.addEventListener('activity', (e) => {
    try { addEntry(JSON.parse(e.data)); } catch {}
  });

  eventSource.addEventListener('gap', (e) => {
    try {
      const data = JSON.parse(e.data);
      const feed = document.getElementById('activity-feed');
      const banner = document.createElement('div');
      banner.className = 'gap-banner';
      banner.textContent = `Missed ${data.availableFrom - data.gapFrom} events`;
      feed.appendChild(banner);
    } catch {}
  });
}

// ─── Refs Tab ───────────────────────────────────────────────────

async function fetchRefs() {
  if (!serverUrl) return;
  try {
    const resp = await fetch(`${serverUrl}/refs`, { signal: AbortSignal.timeout(3000) });
    if (!resp.ok) return;
    const data = await resp.json();

    const list = document.getElementById('refs-list');
    const empty = document.getElementById('refs-empty');
    const footer = document.getElementById('refs-footer');

    if (!data.refs || data.refs.length === 0) {
      empty.style.display = '';
      list.innerHTML = '';
      footer.textContent = '';
      return;
    }

    empty.style.display = 'none';
    list.innerHTML = data.refs.map(r => `
      <div class="ref-row">
        <span class="ref-id">${escapeHtml(r.ref)}</span>
        <span class="ref-role">${escapeHtml(r.role)}</span>
        <span class="ref-name">"${escapeHtml(r.name)}"</span>
      </div>
    `).join('');
    footer.textContent = `${data.refs.length} refs`;
  } catch {}
}

// ─── Server Discovery ───────────────────────────────────────────

function updateConnection(url, token) {
  const wasConnected = !!serverUrl;
  serverUrl = url;
  serverToken = token || null;
  if (url) {
    document.getElementById('footer-dot').className = 'dot connected';
    const port = new URL(url).port;
    document.getElementById('footer-port').textContent = `:${port}`;
    setConnState('connected');
    connectSSE();
    if (chatPollInterval) clearInterval(chatPollInterval);
    chatPollInterval = setInterval(pollChat, 1000);
    pollChat();
  } else {
    document.getElementById('footer-dot').className = 'dot';
    document.getElementById('footer-port').textContent = '';
    if (chatPollInterval) { clearInterval(chatPollInterval); chatPollInterval = null; }
    if (wasConnected) {
      startReconnect();
    }
  }
}

// ─── Port Configuration ─────────────────────────────────────────

const portLabel = document.getElementById('footer-port');
const portInput = document.getElementById('port-input');

portLabel.addEventListener('click', () => {
  portLabel.style.display = 'none';
  portInput.style.display = '';
  chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => {
    portInput.value = resp?.port || '';
    portInput.focus();
    portInput.select();
  });
});

function savePort() {
  const port = parseInt(portInput.value, 10);
  if (port > 0 && port < 65536) {
    chrome.runtime.sendMessage({ type: 'setPort', port });
  }
  portInput.style.display = 'none';
  portLabel.style.display = '';
}
portInput.addEventListener('blur', savePort);
portInput.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') savePort();
  if (e.key === 'Escape') { portInput.style.display = 'none'; portLabel.style.display = ''; }
});

// ─── Reconnect / Copy Buttons ────────────────────────────────────

document.getElementById('conn-reconnect').addEventListener('click', () => {
  reconnectAttempts = 0;
  startReconnect();
});

document.getElementById('conn-copy').addEventListener('click', () => {
  navigator.clipboard.writeText('/connect-chrome').then(() => {
    const btn = document.getElementById('conn-copy');
    btn.textContent = 'copied!';
    setTimeout(() => { btn.textContent = '/connect-chrome'; }, 2000);
  });
});

// Try to connect immediately, retry every 2s until connected
function tryConnect() {
  chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => {
    if (resp && resp.port && resp.connected) {
      const url = `http://127.0.0.1:${resp.port}`;
      // Get the token from background
      chrome.runtime.sendMessage({ type: 'getToken' }, (tokenResp) => {
        updateConnection(url, tokenResp?.token);
      });
    } else {
      setTimeout(tryConnect, 2000);
    }
  });
}
tryConnect();

// ─── Message Listener ───────────────────────────────────────────

chrome.runtime.onMessage.addListener((msg) => {
  if (msg.type === 'health') {
    if (msg.data) {
      const url = `http://127.0.0.1:${msg.data.port || 34567}`;
      updateConnection(url, msg.data.token);
      applyChatEnabled(!!msg.data.chatEnabled);
    } else {
      updateConnection(null);
    }
  }
  if (msg.type === 'refs') {
    if (document.querySelector('.tab[data-tab="refs"].active')) {
      fetchRefs();
    }
  }
});

// ─── Chat Gate ──────────────────────────────────────────────────
// Show/hide Chat tab + command bar based on chatEnabled from server

function applyChatEnabled(enabled) {
  const commandBar = document.querySelector('.command-bar');
  const chatTab = document.getElementById('tab-chat');
  const banner = document.getElementById('experimental-banner');
  const clearBtn = document.getElementById('clear-chat');

  if (enabled) {
    // Chat is enabled: show command bar, chat tab, experimental banner
    if (commandBar) commandBar.style.display = '';
    if (chatTab) chatTab.style.display = '';
    if (banner) banner.style.display = '';
    if (clearBtn) clearBtn.style.display = '';
  } else {
    // Chat disabled: hide command bar, chat content, clear button
    if (commandBar) commandBar.style.display = 'none';
    if (banner) banner.style.display = 'none';
    if (clearBtn) clearBtn.style.display = 'none';
    // If currently on chat tab, switch to activity
    if (chatTab && chatTab.classList.contains('active')) {
      chatTab.classList.remove('active');
      // Open debug tabs and show activity
      const debugToggle = document.getElementById('debug-toggle');
      const debugTabs = document.getElementById('debug-tabs');
      if (debugToggle) debugToggle.classList.add('active');
      if (debugTabs) debugTabs.style.display = 'flex';
      const activityTab = document.getElementById('tab-activity');
      if (activityTab) activityTab.classList.add('active');
      const activityBtn = document.querySelector('.tab[data-tab="activity"]');
      if (activityBtn) activityBtn.classList.add('active');
    }
  }
}

M package.json => package.json +3 -2
@@ 1,6 1,6 @@
{
  "name": "gstack",
  "version": "0.11.20.0",
  "version": "0.12.0.0",
  "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
  "license": "MIT",
  "type": "module",


@@ 34,8 34,9 @@
    "analytics": "bun run scripts/analytics.ts"
  },
  "dependencies": {
    "diff": "^7.0.0",
    "playwright": "^1.58.2",
    "diff": "^7.0.0"
    "puppeteer-core": "^24.40.0"
  },
  "engines": {
    "bun": ">=1.0.0"

M qa/SKILL.md => qa/SKILL.md +6 -0
@@ 346,6 346,12 @@ You are a QA engineer AND a bug-fix engineer. Test web applications like a real 

**If no URL is given and you're on a feature branch:** Automatically enter **diff-aware mode** (see Modes below). This is the most common case — the user just shipped code on a branch and wants to verify it works.

**CDP mode detection:** Before starting, check if the browse server is connected to the user's real browser:
```bash
$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false"
```
If `CDP_MODE=true`: skip cookie import prompts (the real browser already has cookies), skip user-agent overrides (real browser has real user-agent), and skip headless detection workarounds. The user's real auth sessions are already available.

**Check for clean working tree:**

```bash

M qa/SKILL.md.tmpl => qa/SKILL.md.tmpl +6 -0
@@ 50,6 50,12 @@ You are a QA engineer AND a bug-fix engineer. Test web applications like a real 

**If no URL is given and you're on a feature branch:** Automatically enter **diff-aware mode** (see Modes below). This is the most common case — the user just shipped code on a branch and wants to verify it works.

**CDP mode detection:** Before starting, check if the browse server is connected to the user's real browser:
```bash
$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false"
```
If `CDP_MODE=true`: skip cookie import prompts (the real browser already has cookies), skip user-agent overrides (real browser has real user-agent), and skip headless detection workarounds. The user's real auth sessions are already available.

**Check for clean working tree:**

```bash

M review/SKILL.md => review/SKILL.md +9 -7
@@ 345,19 345,21 @@ Before reviewing code quality, check: **did they build what was requested — no

### Plan File Discovery

1. **Conversation context (primary):** Check if there is an active plan file in this conversation — Claude Code system messages include plan file paths when in plan mode. Look for references like `~/.claude/plans/*.md` in system messages. If found, use it directly — this is the most reliable signal.
1. **Conversation context (primary):** Check if there is an active plan file in this conversation. The host agent's system messages include plan file paths when in plan mode. If found, use it directly — this is the most reliable signal.

2. **Content-based search (fallback):** If no plan file is referenced in conversation context, search by content:

```bash
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-')
REPO=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)")
# Try branch name match first (most specific)
PLAN=$(ls -t ~/.claude/plans/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1)
# Fall back to repo name match
[ -z "$PLAN" ] && PLAN=$(ls -t ~/.claude/plans/*.md 2>/dev/null | xargs grep -l "$REPO" 2>/dev/null | head -1)
# Last resort: most recent plan modified in the last 24 hours
[ -z "$PLAN" ] && PLAN=$(find ~/.claude/plans -name '*.md' -mmin -1440 -maxdepth 1 2>/dev/null | xargs ls -t 2>/dev/null | head -1)
# Search common plan file locations
for PLAN_DIR in "$HOME/.claude/plans" "$HOME/.codex/plans" ".gstack/plans"; do
  [ -d "$PLAN_DIR" ] || continue
  PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1)
  [ -z "$PLAN" ] && PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$REPO" 2>/dev/null | head -1)
  [ -z "$PLAN" ] && PLAN=$(find "$PLAN_DIR" -name '*.md' -mmin -1440 -maxdepth 1 2>/dev/null | xargs ls -t 2>/dev/null | head -1)
  [ -n "$PLAN" ] && break
done
[ -n "$PLAN" ] && echo "PLAN_FILE: $PLAN" || echo "NO_PLAN_FILE"
```


M scripts/gen-skill-docs.ts => scripts/gen-skill-docs.ts +4 -5
@@ 445,7 445,7 @@ Hey gstack team — ran into this while using /{skill-name}:

**What I was trying to do:** {what the user/agent was attempting}
**What happened instead:** {what actually happened}
**My rating:** {0-10} — {one sentence on why it wasn't a 10}
**My Rating:** {0-10} — {one sentence on why it wasn't a 10}

## Steps to reproduce
1. {step}


@@ 556,15 556,14 @@ plan's living status.`;
}

function generatePreamble(ctx: TemplateContext): string {
  const tier = ctx.preambleTier ?? 4;
  return [
    generatePreambleBash(ctx),
    generateUpgradeCheck(ctx),
    generateLakeIntro(),
    generateTelemetryPrompt(ctx),
    generateAskUserFormat(ctx),
    generateCompletenessSection(),
    generateRepoModeSection(),
    generateSearchBeforeBuildingSection(ctx),
    ...(tier >= 2 ? [generateAskUserFormat(ctx), generateCompletenessSection()] : []),
    ...(tier >= 3 ? [generateRepoModeSection(), generateSearchBeforeBuildingSection(ctx)] : []),
    generateContributorMode(),
    generateCompletionStatus(),
  ].join('\n\n');

M scripts/resolvers/review.ts => scripts/resolvers/review.ts +9 -7
@@ 604,19 604,21 @@ SOURCE = "codex" if Codex ran, "claude" if subagent ran.
function generatePlanFileDiscovery(): string {
  return `### Plan File Discovery

1. **Conversation context (primary):** Check if there is an active plan file in this conversation — Claude Code system messages include plan file paths when in plan mode. Look for references like \`~/.claude/plans/*.md\` in system messages. If found, use it directly — this is the most reliable signal.
1. **Conversation context (primary):** Check if there is an active plan file in this conversation. The host agent's system messages include plan file paths when in plan mode. If found, use it directly — this is the most reliable signal.

2. **Content-based search (fallback):** If no plan file is referenced in conversation context, search by content:

\`\`\`bash
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-')
REPO=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)")
# Try branch name match first (most specific)
PLAN=$(ls -t ~/.claude/plans/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1)
# Fall back to repo name match
[ -z "$PLAN" ] && PLAN=$(ls -t ~/.claude/plans/*.md 2>/dev/null | xargs grep -l "$REPO" 2>/dev/null | head -1)
# Last resort: most recent plan modified in the last 24 hours
[ -z "$PLAN" ] && PLAN=$(find ~/.claude/plans -name '*.md' -mmin -1440 -maxdepth 1 2>/dev/null | xargs ls -t 2>/dev/null | head -1)
# Search common plan file locations
for PLAN_DIR in "$HOME/.claude/plans" "$HOME/.codex/plans" ".gstack/plans"; do
  [ -d "$PLAN_DIR" ] || continue
  PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1)
  [ -z "$PLAN" ] && PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$REPO" 2>/dev/null | head -1)
  [ -z "$PLAN" ] && PLAN=$(find "$PLAN_DIR" -name '*.md' -mmin -1440 -maxdepth 1 2>/dev/null | xargs ls -t 2>/dev/null | head -1)
  [ -n "$PLAN" ] && break
done
[ -n "$PLAN" ] && echo "PLAN_FILE: $PLAN" || echo "NO_PLAN_FILE"
\`\`\`


M setup-browser-cookies/SKILL.md => setup-browser-cookies/SKILL.md +8 -0
@@ 233,6 233,14 @@ plan's living status.

Import logged-in sessions from your real Chromium browser into the headless browse session.

## CDP mode check

First, check if browse is already connected to the user's real browser:
```bash
$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false"
```
If `CDP_MODE=true`: tell the user "Not needed — you're connected to your real browser via CDP. Your cookies and sessions are already available." and stop. No cookie import needed.

## How it works

1. Find the browse binary

M setup-browser-cookies/SKILL.md.tmpl => setup-browser-cookies/SKILL.md.tmpl +8 -0
@@ 19,6 19,14 @@ allowed-tools:

Import logged-in sessions from your real Chromium browser into the headless browse session.

## CDP mode check

First, check if browse is already connected to the user's real browser:
```bash
$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false"
```
If `CDP_MODE=true`: tell the user "Not needed — you're connected to your real browser via CDP. Your cookies and sessions are already available." and stop. No cookie import needed.

## How it works

1. Find the browse binary

M ship/SKILL.md => ship/SKILL.md +9 -7
@@ 1075,19 1075,21 @@ Repo: {owner/repo}

### Plan File Discovery

1. **Conversation context (primary):** Check if there is an active plan file in this conversation — Claude Code system messages include plan file paths when in plan mode. Look for references like `~/.claude/plans/*.md` in system messages. If found, use it directly — this is the most reliable signal.
1. **Conversation context (primary):** Check if there is an active plan file in this conversation. The host agent's system messages include plan file paths when in plan mode. If found, use it directly — this is the most reliable signal.

2. **Content-based search (fallback):** If no plan file is referenced in conversation context, search by content:

```bash
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-')
REPO=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)")
# Try branch name match first (most specific)
PLAN=$(ls -t ~/.claude/plans/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1)
# Fall back to repo name match
[ -z "$PLAN" ] && PLAN=$(ls -t ~/.claude/plans/*.md 2>/dev/null | xargs grep -l "$REPO" 2>/dev/null | head -1)
# Last resort: most recent plan modified in the last 24 hours
[ -z "$PLAN" ] && PLAN=$(find ~/.claude/plans -name '*.md' -mmin -1440 -maxdepth 1 2>/dev/null | xargs ls -t 2>/dev/null | head -1)
# Search common plan file locations
for PLAN_DIR in "$HOME/.claude/plans" "$HOME/.codex/plans" ".gstack/plans"; do
  [ -d "$PLAN_DIR" ] || continue
  PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1)
  [ -z "$PLAN" ] && PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$REPO" 2>/dev/null | head -1)
  [ -z "$PLAN" ] && PLAN=$(find "$PLAN_DIR" -name '*.md' -mmin -1440 -maxdepth 1 2>/dev/null | xargs ls -t 2>/dev/null | head -1)
  [ -n "$PLAN" ] && break
done
[ -n "$PLAN" ] && echo "PLAN_FILE: $PLAN" || echo "NO_PLAN_FILE"
```


M test/helpers/touchfiles.ts => test/helpers/touchfiles.ts +1 -0
@@ 188,6 188,7 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
  'review-design-lite': 'periodic',   // 4/7 threshold is subjective
  'review-coverage-audit': 'gate',
  'review-plan-completion': 'gate',
  'review-dashboard-via': 'gate',

  // Office Hours
  'office-hours-spec-review': 'gate',

M test/skill-validation.test.ts => test/skill-validation.test.ts +0 -5
@@ 1369,11 1369,6 @@ describe('Codex skill', () => {
    expect(content).toContain('Persist Eng Review result');
  });

  test('/ship gate suggests /review or /plan-eng-review when Eng Review is missing', () => {
    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
    expect(content).toContain('Abort — run /review or /plan-eng-review first');
  });

  test('Review Readiness Dashboard includes Adversarial Review row', () => {
    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
    expect(content).toContain('Adversarial');