The chat queue path is gone. The Chrome side panel is now just an
interactive claude PTY in xterm.js. Activity / Refs / Inspector still
exist behind the `debug` toggle in the footer.
Three threads of change, all from dogfood iteration on top of
cc-pty-import:
1. fix(server): cross-port WS auth via Sec-WebSocket-Protocol
- Browsers can't set Authorization on a WebSocket upgrade. We had
been minting an HttpOnly gstack_pty cookie via /pty-session, but
SameSite=Strict cookies don't survive the cross-port jump from
server.ts:34567 to the agent's random port from a chrome-extension
origin. The WS opened then immediately closed → "Session ended."
- /pty-session now also returns ptySessionToken in the JSON body.
- Extension calls `new WebSocket(url, [`gstack-pty.<token>`])`.
Browser sends Sec-WebSocket-Protocol on the upgrade.
- Agent reads the protocol header, validates against validTokens,
and MUST echo the protocol back (Chromium closes the connection
immediately if a server doesn't pick one of the offered protocols).
- Cookie path is kept as a fallback for non-browser callers (curl,
integration tests).
- New integration test exercises the full protocol-auth round-trip
via raw fetch+Upgrade so a future regression of this exact class
fails in CI.
2. fix(extension): UX polish on the Terminal pane
- Eager auto-connect when the sidebar opens — no "Press any key to
start" friction every reload.
- Always-visible ↻ Restart button in the terminal toolbar (not
gated on the ENDED state) so the user can force a fresh claude
mid-session.
- MutationObserver on #tab-terminal's class attribute drives a
fitAddon.fit() + term.refresh() when the pane becomes visible
again — xterm doesn't auto-redraw after display:none → display:flex.
3. feat(extension): rip the chat tab + sidebar-agent.ts
- Sidebar is Terminal-only. No more Terminal | Chat primary nav.
- sidebar-agent.ts deleted. /sidebar-command, /sidebar-chat,
/sidebar-agent/event, /sidebar-tabs* and friends all deleted.
- The pickSidebarModel router (sonnet vs opus) is gone — the live
PTY uses whatever model the user's `claude` CLI is configured with.
- Quick-actions (🧹 Cleanup / 📸 Screenshot / 🍪 Cookies) survive
in the Terminal toolbar. Cleanup now injects its prompt into the
live PTY via window.gstackInjectToTerminal — no more
/sidebar-command POST. The Inspector "Send to Code" action uses
the same injection path.
- clear-chat button removed from the footer.
- sidepanel.js shed ~900 lines of chat polling, optimistic UI,
stop-agent, etc.
Net diff: -3.4k lines across 16 files. CLAUDE.md, TODOS.md, and
docs/designs/SIDEBAR_MESSAGE_FLOW.md rewritten to match. The sidebar
regression test (browse/test/sidebar-tabs.test.ts) is rewritten as 27
structural assertions locking the new layout — Terminal sole pane,
no chat input, quick-actions in toolbar, eager-connect, MutationObserver
repaint, restart helper.
10 KiB
Sidebar Flow
How the GStack Browser sidebar actually works. Read this before touching
sidepanel.js, background.js, content.js, terminal-agent.ts, or
sidebar-related server endpoints.
The sidebar has one primary surface — the Terminal pane, an interactive
claude PTY. Activity / Refs / Inspector survive as debug overlays behind
the debug toggle in the footer. The chat queue path (one-shot claude -p,
sidebar-agent.ts) was ripped once the PTY proved out — the Terminal pane is
strictly more capable.
Components
┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐
│ sidepanel.js + │────▶│ server.ts │────▶│terminal-agent.ts │
│ -terminal.js │ │ (compiled) │ │ (non-compiled) │
│ (xterm.js) │ │ │ │ PTY listener │
└─────────────────┘ └──────────────┘ └──────────────────┘
▲ │ │
│ ws://127.0.0.1:<termPort>/ws (Sec-WebSocket-Protocol auth)
└───────────────────────┼──────────────────────▶│ Bun.spawn(claude)
│ │ terminal: {data}
│ ▼
│ ┌──────────────────┐
│ │ claude PTY │
│ └──────────────────┘
POST /pty-session │
(Bearer AUTH_TOKEN) │
▼
┌──────────────────┐
│ pty-session- │
│ cookie.ts │
│ (in-memory token │
│ registry) │
└──────────────────┘
│
│ POST /internal/grant (loopback)
▼
┌──────────────────┐
│ validTokens Set │
│ in agent memory │
└──────────────────┘
The compiled browse server can't posix_spawn external executables —
terminal-agent.ts runs as a separate non-compiled bun run process and
owns the claude subprocess.
Startup + first-keystroke timeline
T+0ms CLI runs `$B connect`
├── Server starts (compiled)
└── Spawns terminal-agent.ts via `bun run`
T+500ms terminal-agent.ts boots
├── Bun.serve on 127.0.0.1:0 (random port)
├── Writes <stateDir>/terminal-port (server reads it for /health)
├── Writes <stateDir>/terminal-internal-token (loopback handshake)
└── Probes claude → writes claude-available.json
T+1-3s Extension loads, sidebar opens
├── sidepanel-terminal.js: setState(IDLE), shows "Starting Claude Code..."
└── tryAutoConnect() polls until window.gstackServerPort + token are set
T+ready tryAutoConnect calls connect()
├── POST /pty-session (Authorization: Bearer AUTH_TOKEN)
│ └── server mints session token, posts /internal/grant to agent
│ └── responds with {terminalPort, ptySessionToken}
├── GET /claude-available (preflight)
├── new WebSocket(`ws://127.0.0.1:<terminalPort>/ws`,
│ [`gstack-pty.<token>`])
│ └── Browser sends Sec-WebSocket-Protocol + Origin
│ └── Agent validates Origin AND token BEFORE upgrading
│ └── Agent echoes the protocol back (REQUIRED — browser
│ closes the connection without it)
├── On open: send {type:"resize"} then a single \n byte
└── Agent message handler sees the byte → spawnClaude()
Auth: WebSocket can't send Authorization headers
Browser WebSocket clients can't set Authorization. They CAN set
Sec-WebSocket-Protocol via the second arg of new WebSocket(url, protocols). We exploit that:
POST /pty-session(auth: Bearer AUTH_TOKEN) → server mints a short-lived session token, pushes it to the agent over loopback, returns it in the JSON body.- Extension calls
new WebSocket(url, ['gstack-pty.<token>']). - Agent reads
Sec-WebSocket-Protocol, stripsgstack-pty., validates againstvalidTokens, echoes the protocol back. Echo is mandatory — without it Chromium closes the connection on receipt of the upgrade response.
A Set-Cookie: gstack_pty=... header is also returned for non-browser
callers (curl, integration tests). The cookie path was the original v1
design but SameSite=Strict cookies don't survive the cross-port jump
from server.ts:34567 → agent: from a chrome-extension origin.
The protocol-token path is what the browser actually uses.
Dual-token model
| Token | Lives in | Used for | Lifetime |
|---|---|---|---|
AUTH_TOKEN |
<stateDir>/browse.json; in-memory in server.ts |
/pty-session POST (mint cookie + token) |
server lifetime |
gstack-pty.<...> (Sec-WebSocket-Protocol) |
Browser memory only; agent validTokens Set |
/ws upgrade auth |
30 min, auto-revoked on WS close |
INTERNAL_TOKEN |
<stateDir>/terminal-internal-token; in agent memory |
server → agent loopback /internal/grant |
agent lifetime |
AUTH_TOKEN is never valid for /ws directly. The session token is
never valid for /pty-session or /command. Strict separation
prevents an SSE or page-content token leak from escalating into shell
access.
Threat model
The Terminal pane bypasses the prompt-injection security stack on purpose — the user is typing directly to claude, there's no untrusted page content in the loop. Trust source is the keyboard, same as any local terminal.
That trust assumption is load-bearing on three transport guarantees:
- Local-only listener. terminal-agent.ts binds
127.0.0.1only. The dual-listener tunnel surface (server.tsTUNNEL_PATHS) does not include/pty-sessionor/terminal/*, so the tunnel returns 404 by default-deny. - Origin gate.
/wsupgrades requireOrigin: chrome-extension://<id>. A localhost web page can't mount a cross-site WebSocket hijack against the shell because its Origin is a regularhttp(s)://.... - Session token auth. Minted only by an authenticated
/pty-sessionPOST, scoped to one WS, auto-revoked on close.
Drop any one of those three and the whole tab becomes unsafe.
Lifecycle
- Eager auto-connect. Sidebar opens → tryAutoConnect polls for the bootstrap globals and connects as soon as they're set. No keypress required.
- One PTY per WS. Closing the WebSocket SIGINTs claude, then SIGKILLs after 3s. The session token is revoked so a stolen token can't be replayed.
- No auto-reconnect on close. The user sees "Session ended, click to start a new session." Auto-reconnect would burn a fresh claude session on every reload. v1.1 may add session resumption keyed on tab/session id (see TODOS).
- Manual restart anytime. A
↻ Restartbutton lives in the always- visible terminal toolbar — works mid-session, not just from the ENDED state.
Quick-action toolbar
Three browser-action buttons live next to the Restart button at the top of the Terminal pane:
| Button | Behavior |
|---|---|
| 🧹 Cleanup | window.gstackInjectToTerminal(prompt) — pipes a "remove ads/banners" instruction into the live PTY. claude in the terminal sees it and acts. |
| 📸 Screenshot | POST /command screenshot — direct browse-server call, no PTY involvement. |
| 🍪 Cookies | Navigates to the /cookie-picker page. |
The Inspector's "Send to Code" button uses the same gstackInjectToTerminal
path to forward CSS inspector data into claude.
Debug surfaces (Activity / Refs / Inspector)
Behind the debug toggle in the footer. SSE-driven, independent of the
Terminal pane:
- Activity — streams every browse command via
/activity/streamSSE. - Refs — REST:
GET /refs— current page's@refelement labels. - Inspector — CDP-based element picker; SSE on
/inspector/events.
When the debug strip closes, the Terminal pane re-becomes visible.
xterm.js doesn't auto-redraw when its container flips from display:none
to display:flex, so sidepanel-terminal.js runs a MutationObserver on
#tab-terminal's class attribute and forces a fit + refresh when
.active returns.
Files
| Component | File | Runs in |
|---|---|---|
| Sidebar UI shell | extension/sidepanel.html + sidepanel.js + sidepanel.css |
Chrome side panel |
| Terminal UI | extension/sidepanel-terminal.js + extension/lib/xterm.js |
Chrome side panel |
| Service worker | extension/background.js |
Chrome background |
| Content script | extension/content.js |
Page context |
| HTTP server | browse/src/server.ts |
Bun (compiled binary) |
| PTY agent | browse/src/terminal-agent.ts |
Bun (non-compiled) |
| PTY token store | browse/src/pty-session-cookie.ts |
Bun (compiled, in server.ts) |
| CLI entry | browse/src/cli.ts |
Bun (compiled binary) |
| State file | <stateDir>/browse.json |
Filesystem |
| Terminal port | <stateDir>/terminal-port |
Filesystem |
| Internal token | <stateDir>/terminal-internal-token |
Filesystem |
| Claude probe | <stateDir>/claude-available.json |
Filesystem |
| Active tab | <stateDir>/active-tab.json |
Filesystem (claude reads) |