-
Garry Tan authored
* fix(browse): identity-based terminal-agent kill replaces pkill regex Commit 0 of the v1.44 long-lived-sidebar PR — foundation for the watchdog and removes a latent cross-session footgun. `pkill -f terminal-agent\.ts` (cli.ts spawn site + server.ts shutdown) matched by argv regex and would kill ANY process whose argv contained the string — sibling gstack sessions on the same host, an editor with the file open, a second `$B connect` run. Identity-based PID kill via a new helper module removes that whole class of bug. * New `browse/src/terminal-agent-control.ts`: `readAgentRecord`, `writeAgentRecord`, `clearAgentRecord`, `killAgentByRecord`. Validates PID liveness via `isProcessAlive` before signaling (PID-reuse defense). * `terminal-agent.ts` writes `<stateDir>/terminal-agent-pid` (JSON `{pid, gen, startedAt}`) at boot; clears on SIGTERM/SIGINT. * New per-boot `CURRENT_GEN` (16-byte random); `/internal/*` callers can include `X-Browse-Gen` to defend against split-brain in the upcoming watchdog. Absent header is accepted (backward compat); mismatch returns 409. New `checkInternalAuth` helper centralizes bearer + gen checks. * New `/internal/healthz` route — agent liveness probe used by the upcoming watchdog (returns pid/gen/sessions, no claude-binary lookup). * `cli.ts` and `server.ts` both call `killAgentByRecord` instead of pkill. * `ServerConfig.ownsTerminalAgent` JSDoc updated; the gated teardown now runs 4 side effects (was 3) — adds the new agent-record unlink. Test changes: * New `browse/test/terminal-agent-pid-identity.test.ts` — static-grep tripwire that fails CI if any source file re-introduces `pkill ... terminal-agent` or `spawnSync('pkill', ...)`; round-trips write/read/clear; verifies killAgentByRecord no-ops on dead PIDs. * `browse/test/server-embedder-terminal-port.test.ts` rewritten to intercept `process.kill` (not `child_process.spawnSync`); writes a sentinel agent-record with a guaranteed-dead PID; asserts probe-only (signal 0) calls, no termination signals; verifies all 3 discovery files including the new terminal-agent-pid. Closes TODOS.md P3 ("Identity-based terminal-agent kill"). Co-Authored-By:Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): repair 7 pre-existing failures (env pollution + stale markers) All 7 failures existed on main before this branch — verified via `git stash` round-trip. Bundling them into the long-lived-sidebar PR because we kept tripping over them while running `bun test` to verify Commit 0. * Global afterEach restores `process.env.PATH` (new bunfig.toml + test-setup.ts). browser-skill-commands.test.ts sets `PATH = '/test/bin:/usr/bin'` to exercise a scrubbed-env fixture and used the broken `process.env = origEnv` reassignment pattern that swaps the proxy reference; the underlying env stayed mutated and leaked downstream. Fixed three call sites in that file and added a narrow PATH-only global guardrail so a future polluter can't bring the bug back. Killed: pair-agent-tunnel-eval (bun ENOENT), security.test.ts > resolveBashBinary (Bun.which('bash') null), server-no-import-side-effects (bun ENOENT). * server-auth.test.ts: two `sliceBetween` markers referenced strings deleted when sidebar-agent.ts was ripped — `'Sidebar agent started'` → `'Terminal agent started'`, `'Sidebar endpoints'` → `'Batch endpoint'`. Also fixed the pair-agent BROWSE_PARENT_PID assertion (the literal `serverEnv.BROWSE_PARENT_PID` never existed in source; the actual contract is the object-literal `BROWSE_PARENT_PID: '0'` inside the `const serverEnv` declaration). * test/upgrade-migration-v1.test.ts: also overrides HOME in the spawn env. The migration shells out to `${HOME}/.claude/skills/gstack/bin/gstack-config` and a developer's real config with `explain_level` set causes the script to take the "user already decided" branch and skip writing the pending-prompt flag the test asserts on. * test/setup-codesign.test.ts: replaced fragile `bun run build` string-match (which hit a comment 700 lines later) with the actual invocation `bun_cmd run build` used in the setup script. Net: full suite is now green; CI no longer trips on bash/bun-ENOENT from PATH pollution or on test markers that drifted with the codebase. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(terminal-agent): extract internalHandler<T> helper for /internal/* routes Replaces the copy-pasted bearer-auth + X-Browse-Gen + req.json().then().catch() boilerplate on /internal/grant and /internal/revoke with a single internalHandler<T>(req, fn) wrapper. Future /internal/* routes added by the v1.44 long-lived-sidebar work (/internal/lease-refresh, /internal/restart) land as one-liners using the same helper. Pure refactor; no behavior change. /internal/healthz stays on the bare checkInternalAuth gate because it's a GET with no JSON body to parse — the helper's body-parse path would 400 it. * browse/src/terminal-agent.ts — new internalHandler<T>; /internal/grant + /internal/revoke routed through it. * browse/test/terminal-agent-internal-handler.test.ts — static-grep tripwire that fails CI if the helper goes away or either of the two refactored routes regresses to the old inline pattern. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(terminal-agent): 25s WS keepalive ping/pong + client keepalive frames PTY connections were dying silently after NAT idle timeouts (30-60s on most home routers, even shorter on some carrier-grade NAT) and Chrome MV3 panel suspension. Neither side noticed until the user's next keystroke produced no output. Both sides now drive a 25s keepalive cycle. Server side (browse/src/terminal-agent.ts): * New ws.open handler constructs the PtySession eagerly and starts a setInterval that sends `{type:"ping",ts:Date.now()}` every 25s. Interval handle stored on session.pingInterval so close() can clear it. * PtySession.pingInterval field added; cleared in ws.close before disposeSession runs. Prevents timer leak across reconnects. * Message handler accepts `{type:"ping"|"pong"|"keepalive"}` silently — keepalive frames are a liveness signal at the TCP layer, no state to update. Existing resize/tabSwitch/tabState handling unchanged. * GSTACK_PTY_KEEPALIVE_INTERVAL_MS env knob (default 25000) lets the upcoming e2e tests compress idle assertions without 30s waits. Client side (extension/sidepanel-terminal.js): * Belt-and-suspenders: client also runs a 25s setInterval that sends `{type:"keepalive"}`. Defends against Chrome pausing our timers if the server-side ping ever gets dropped (rare but possible in MV3). * Ping reply: on `{type:"ping",ts}` from the server, immediately send `{type:"pong",ts}`. Lets the agent observe round-trip latency for free and confirms the channel is bidirectional. * Interval cleared in three teardown paths: ws.close handler, teardown(), forceRestart(). Three paths exist because the sidebar can exit the LIVE state through any of them; all three must clean up or we leak timers across reconnects. Test (browse/test/terminal-agent-keepalive.test.ts): * Static-grep tripwires for the 7-point protocol contract: agent has a configurable interval, open() starts the ping, close() clears it, message handler accepts keepalive vocabulary, client sends keepalive + replies pong, and all three client teardown paths clear the timer. * Wire-level tests (actually observe a ping after 25s) belong in the e2e tier — adding them here would either flake on slow CI or require a real Bun.serve listener per test which we don't want to pay for in the free tier. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sidebar): patient tryAutoConnect — poll forever with ascending status, abort only on 401 The 15s give-up message ("Browse server not ready. Reload sidebar to retry.") fired on every cold start where the daemon took >15s to bind — common on Conductor workspaces, CI runners, and any system under load. The user already opened the sidebar; telling them to give up is the wrong default. Now polls every 2s indefinitely with ascending status messages: * 0 - 15s : silent (handles the happy path on a warm laptop) * 15 - 60s : "Waiting for browse server..." * 60s - 5m : "Still waiting — browse server may be slow to start." * > 5m : "Browse server still not responding after 5 min. Try `$B status`." Loop aborts on three signals only: * state transitions out of IDLE (connect succeeded or user navigated) * autoConnectAborted sticky flag set on unrecoverable error * the panel itself unloading (browser handles this; pagehide cleanup arrives with T8 of the larger plan) 401 from /pty-session sets the sticky flag with a clear "Auth invalid — reload the sidebar or restart your gstack session." message. Without the flag, the loop would re-call connect() every 2s and spam the same error; with it, the user sees the message once and the loop holds. forceRestart() clears the flag so clicking Restart is the explicit "try again" escape hatch. Bumped poll interval 200ms → 2000ms — the legacy tight loop burned CPU for no reason. 2s is plenty fast for a "did the daemon come up yet" check. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(browse): terminal-agent watchdog with PID liveness + crash-loop guard terminal-agent could die independently of the server — SIGKILL from the OS OOM killer, an uncaught exception under PTY churn, an external `pkill` from a sibling debugging session. Pre-v1.44 the sidebar would observe the broken connection and stay broken until the user reloaded the sidebar. Now a 60s ticker checks the recorded agent PID and respawns via the shared spawnTerminalAgent helper when dead. Identity-based liveness (T4 from the eng review): * Uses readAgentRecord + isProcessAlive (signal 0 probe), not a name match. * Slow-but-alive agents intentionally fall through — respawning around a living agent would create split-brain (two agents writing the port file, tokens diverging between them, mystery upgrade 401s). * Pairs with the v1.44 generation counter in /internal/* loopback calls: if a stale agent does come back to life mid-cycle, its X-Browse-Gen no longer matches and the parent's calls 409 cleanly. Crash-loop guard: * 3 respawn attempts inside a rolling 60s window → stop trying. A daemon up for a week with one crash a day shouldn't trip the guard. * On trip: one-line error to console (`respawn guard tripped`) and the watchdog goes dormant. Manual restart via the sidebar Restart button is the explicit signal to re-arm (added in Commit 2 of the larger PR). Shared spawn path (refactor): * New spawnTerminalAgent(opts) in terminal-agent-control.ts handles: prior-PID cleanup → spawn → record stash. Both the CLI cold-start path in cli.ts and the new server.ts watchdog route through it. Removes the copy-paste between them; future env wiring lands in one place. Gated on cfg.ownsTerminalAgent — embedders that pre-launch their own PTY server (gbrowser phoenix overlay) still own the full lifecycle. GSTACK_AGENT_WATCHDOG_TICK_MS env knob compresses the 60s tick for e2e tests without 60s waits per assertion. Tests: * browse/test/terminal-agent-watchdog.test.ts — 7 static-grep tripwires for the load-bearing invariants (ownsTerminalAgent gate, PID-based liveness, crash-loop guard with window pruning, shutdown cleanup, CLI cold-start uses the same helper, env knob exists). * Live process-kill tests belong in the e2e tier; cheaper invariants here catch refactor regressions in ~1ms each. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): opt-in outer supervisor — respawn browse server on crash Pre-v1.44 `$B connect` was fire-and-forget: spawn server detached, CLI exits, server runs unsupervised. If the server crashed (OOM, uncaught exception, signal kill from a runaway debugger), the user had to notice, re-run `$B connect`, and resume work. The v1.44 terminal-agent watchdog recovers from one layer of failure; this commit closes the outer loop. Opt-in via `--supervise` flag or `BROWSE_SUPERVISE=1` env. Default behavior is unchanged — every existing caller (Claude Code's Bash tool, scripts, CI) still gets a prompt return. When the flag is set: * CLI stays attached, polls server PID every 30s via readState() + isProcessAlive (same identity primitive as the terminal-agent watchdog). * On unexpected exit: respawn via the same headed-mode startServer path used initially, then re-spawn the terminal-agent so the PTY recovers too (otherwise sidebar Restart is the only path back). * Crash-loop guard: 5 respawns in a rolling 5-min window → exit 1 with a clear error. Window pruning means a long-lived daemon with sporadic crashes does NOT trip the guard (otherwise we punish the user for the supervisor doing its job). * Backoff: 1s, 2s, 4s, 8s, 30s capped. Env-overridable via GSTACK_SUPERVISOR_BACKOFF for tests. * SIGINT / SIGTERM: clean teardown — signals the supervised server before exiting itself. Without this, Ctrl-C leaves an orphaned server. Out of scope (deferred follow-up): routing the Chromium-disconnect exit-code-1 path back through this supervisor. The terminal-agent watchdog already covers the highest-frequency restart case; Chromium crash recovery joins the queue as its own commit. Test (browse/test/cli-supervisor.test.ts): * 6 static-grep tripwires: opt-in default, signal wiring, crash-loop guard with window pruning, backoff schedule env knob, tick interval env knob, terminal-agent re-spawn after server respawn. * Live respawn tests belong in the e2e tier (real spawn cycles take 3-8s each; spamming these in the free tier would balloon CI time). Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(browse): pty-session-lease registry — stable sessionId + lease lifecycle Foundation for Commit 2 of the long-lived-sidebar PR. Separates two concerns that pre-v1.44 were conflated under one token: * sessionId — stable, non-secret identifier for a single PTY session. Safe to log, safe in URLs, safe in DevTools. Identifies "this terminal," not "you're allowed to use this terminal." * lease — server-side bookkeeping that maps sessionId → expiresAt. Re-attach within the lease window resumes the same PTY; expiry tears it down. The companion attach-token primitive (short-lived 30s bearer) reuses the existing browse/src/pty-session-cookie.ts module unchanged — the lease adds a name-space alongside, it doesn't replace anything. Codex outside-voice (T1 of the eng review) flagged the original D4 "token IS sessionId" design as conflating identity with auth. The fix is this lease registry: re-attach URLs carry the stable sessionId (loggable), the short-lived attachToken stays out of logs. API: * mintLease() → { sessionId, expiresAt } * validateLease(sessionId) → { ok: true, expiresAt } | { ok: false } * refreshLease(sessionId) — validate-first, never resurrects expired leases. Security-critical: the 30-min TTL is what bounds blast radius for a leaked attachToken whose lease should have GC'd. * revokeLease(sessionId) — explicit dispose path. * leaseCount() — observability helper. * __resetLeases() — test-only. TTL env knob (GSTACK_PTY_LEASE_TTL_MS) lets v1.44 e2e tests compress the detach window to 1s instead of waiting 30 minutes per assertion. Server.ts wiring + /pty-session shape change + /pty-restart + /pty-dispose + /pty-session/reattach all land in subsequent commits in this branch. Test (browse/test/pty-session-lease.test.ts): * 8 cases pinning mint uniqueness, validate-first refresh contract, revoke idempotency, null/undefined tolerance, and the negative case that refresh never resurrects a revoked lease (same code path as expired-and-pruned). Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(terminal-agent): sessionId-aware grant + scoped restart + eager spawn Wires the pty-session-lease primitive (3aada48b) into terminal-agent so the Commit 2 work in server.ts (next commit) can route /pty-restart and re-attach by session identity rather than by single-use token. Changes: * validTokens: Set<string> → Map<string, string|null>. Each grant carries its bound sessionId (or null for legacy single-grant callers). On WS upgrade, the agent surfaces the bound sessionId via ws.data so open() can register the session in the new reverse index. * sessionsById: Map<sessionId, PtySession> — populated in open(), cleared in close(). Required so /internal/restart can find and dispose one specific session by id rather than enumerating all live sessions. * /internal/restart: scoped to one sessionId. Codex T2 of the eng review caught the gap — pre-spec the route would have disposed every PTY on the agent, breaking pair-agent and any future multi-sidebar setup. The body now requires `{sessionId}`; missing or unknown id returns `{killed: 0}` and leaves siblings alone. * maybeSpawnPty(ws, session): hoisted from the inline binary-frame spawn block so both the legacy "spawn on first keystroke" trigger AND the new `{type:"start"}` text-frame trigger land in the same code path. Idempotent on session.spawned. * `{type:"start"}` text frame: explicit spawn trigger. forceRestart (extension side, lands in Commit 2C) sends this immediately on every fresh WS so claude boots without requiring a keystroke. Pre-v1.44 the lazy-binary-spawn pattern made the restart feel stuck. * close(ws): drops the sessionsById entry alongside the existing sessions WeakMap + validTokens cleanup. Commit 3 will revisit this to keep the session alive for a 60s detach window before disposing. Test (browse/test/terminal-agent-session-routing.test.ts): * 8 static-grep tripwires pinning the load-bearing properties: validTokens is a Map (not Set), sessionsById exists, /internal/restart is scoped (negative-assert against enumerate-all patterns), WS upgrade plumbs sessionId, maybeSpawnPty is the single spawn entry, close() drops the index. Live spawn cycles belong in the e2e tier. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server): /pty-session 4-tuple + /pty-restart + /pty-dispose + lease-refresh Wires the lease + attachToken model end-to-end on the server side. The client side (extension) lands in the next commit; agent side already shipped in 449144cd. Routes: * POST /pty-session — mints sessionId (stable, loggable) + lease (server-side bookkeeping) + attachToken (short-lived bearer for the WS upgrade). Returns the 4-tuple in one round trip. Legacy ptySessionToken / expiresAt aliases kept for one minor release so extensions on the v1.43 wire shape keep working. * POST /pty-session/reattach — validates a sessionId's lease and mints a FRESH attachToken bound to the same sessionId. Used by Commit 3's re-attach loop; 410 Gone when the lease has expired so the client knows to fall back to a brand-new /pty-session. * POST /pty-restart — one transaction: dispose the caller's existing PtySession on the agent (via /internal/restart, scoped to one sessionId — codex T2), revoke the old lease, mint a fresh sessionId + lease + attachToken, return the 4-tuple. Zero race window between kill and mint (codex T2 + D8 of the eng review). * POST /pty-dispose — explicit teardown. sendBeacon-compatible: accepts auth token in the body so the extension's pagehide handler (Commit 2C) can fire it without setting custom headers (sendBeacon doesn't support those). Without this route, every clean browser quit leaves a zombie PTY alive for the 60s detach window — codex T3 caught it. * POST /internal/lease-refresh — loopback from terminal-agent on its 25s keepalive cycle (lazy: only when lease is within 5 min of expiry). Refreshes the lease AND resets the daemon idle timer. T6 of the eng review: PTY activity (not arbitrary SSE consumers) is what keeps the daemon alive when the sidebar is in use. Helpers: * grantPtyToken now accepts optional sessionId and passes it through to the agent's /internal/grant body. The agent binds token → sessionId in its validTokens Map so /ws upgrades carry the sessionId for /internal/restart and Commit 3 re-attach lookups. * restartPtySession() — new loopback helper that POSTs the agent's scoped /internal/restart with a sessionId body. Used by /pty-restart and /pty-dispose. Auth contract on /pty-dispose deliberately accepts the auth token in EITHER the Authorization header OR the request body. The body path is required for sendBeacon (which can't set custom headers); the header path stays available for non-beacon callers and tests. Test (browse/test/server-pty-lease-routes.test.ts): * 7 static-grep tripwires pinning the 4-tuple shape, validate-first re-attach with 410 fallback, one-transaction restart semantics, sendBeacon-compatible dispose auth, and the T6 PTY-only idle reset. * Live route exercises (full mint + grant + WS upgrade cycle) belong in the e2e tier — they require a real terminal-agent loopback and take seconds per assertion. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sidebar): forceRestart via /pty-restart + pagehide /pty-dispose Closes the Commit 2 loop: server-side lease + restart routes shipped in 25ef24e9; this commit wires the extension client to use them. End-to-end result — clicking Restart now actually kills the server's PTY before opening a new WS (zero race window), and closing the sidebar / quitting the browser disposes the PTY immediately instead of letting it linger for the upcoming 60s detach window. sidepanel-terminal.js: * mintSession callers read the v1.44 4-tuple (sessionId + attachToken) from /pty-session, with a backward-compat fallback to ptySessionToken so a partially-updated extension still works against a fresh server for one minor release. * Eager spawn via {type:"start"} text frame replaces the legacy `TextEncoder().encode("\n")` newline hack. Pre-v1.44, the lazy-binary- spawn pattern made forceRestart look stuck until the user typed — now claude boots before the prompt renders. * forceRestart() rewritten as an async one-transaction handler: 1. close current WS with code 4001 (intentional-restart) 2. POST /pty-restart with priorSessionId so the server can scope the dispose, then mint fresh sessionId + lease + attachToken in the same response 3. Open new WS with the returned attachToken, send {type:"start"} immediately for eager spawn 4. On 401: sticky-abort the auto-connect loop (no spam) 5. On 503 / network failure: fall back to patient autoconnect * currentSessionId tracked and exposed on window.gstackPtySession so sidepanel.js's pagehide handler can sendBeacon the dispose. sidepanel.js: * New pagehide handler fires navigator.sendBeacon('/pty-dispose', {sessionId, authToken}) on tab close, panel close, browser quit, or extension reload. sendBeacon-compatible: auth token rides in the body since sendBeacon can't set custom headers (server route accepts body-auth per 25ef24e9). * try/catch around the entire body so a sendBeacon failure can't interfere with the browser's unload sequence — the 60s detach window from Commit 3 catches anything we miss. There's bounded duplication between connect() and forceRestart() (~70 lines of WS attach/handler wiring). Extracting a shared helper is a clean follow-up but out of scope for the v1.44 ship — both paths are exercised by the same e2e test. Test (browse/test/sidepanel-restart-dispose.test.ts): * 9 static-grep tripwires pinning the 4-tuple parse, eager spawn, close-code 4001 contract, /pty-restart wire shape, sticky-abort 401 path, sessionId window plumbing, sendBeacon body contract, and the best-effort try/catch around pagehide. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(terminal-agent): scrollback ring buffer + detach state machine + re-attach The agent side of Commit 3 — the "magic" feature. A network blip (wifi hiccup, MV3 panel suspend, brief Chromium pause) now silently reconnects the sidebar to the SAME claude session with scrollback intact. No more "Session ended" message + manual Restart click + losing your tool-call output. Server-side /pty-session/reattach (25ef24e9) and the extension re-attach loop (next commit) close the loop end-to-end. Ring buffer (T10): * Per-session frames: Buffer[] capped at 1 MB (env-overridable via GSTACK_PTY_RING_BUFFER_BYTES). Each PTY write is one frame, so eviction is at frame boundaries and never cuts a UTF-8 sequence or ANSI CSI in half. * appendToRingBuffer eviction loop keeps at least one frame even at extreme caps — a single oversized frame can't empty the buffer. * Alt-screen tracking via canonical xterm CSI ?1049h / CSI ?1049l sequences. lastIndexOf comparison so trailing state wins when both appear in one render frame (quick tool-call open+close). Replay payload (T5 — codex outside-voice): * buildReplayPayload prefixes DECSTR soft reset (\x1b[!p) and conditionally re-enters alt-screen if claude was in a tool call at detach. The client writes RIS (\x1bc) FIRST to clear pre-blip xterm content; the server's prelude resets character attributes; the ring buffer replays cleanly on top. * Order is enforced by the {type:"reattach-begin"} text frame the agent sends right before the binary replay — client waits for it, writes RIS, then treats the next binary frame as the replay payload. Detach state machine (T9): * PtySession.liveWs decouples the PTY callback from the original ws closure. On re-attach, swapping session.liveWs is enough — the on-data callback writes to the new ws automatically. * close(ws, code, _reason): codes 4001 (intentional restart), 4404 (no-claude), and 1000 (clean exit) trigger immediate dispose. Anything else (1006 abnormal, 1001 going-away from network blip / panel suspend) starts a 60s detach timer instead. claude keeps running, output keeps accumulating in the ring buffer. * Detach timer is unref'd so the bun process can still exit cleanly on natural shutdown. * Sessions without a sessionId (legacy single-shot grants) can't re-attach by definition — those fall through to immediate dispose. Re-attach lookup (T9): * WS open() checks sessionsById[sessionId] FIRST. If a detached session is sitting there, cancel its detach timer, swap liveWs, rebind the WS-keyed map, restart keepalive, send reattach-begin + replay payload. The PTY process is unchanged. * /internal/restart now cancels any pending detach timer before disposal — otherwise the timer would later try to dispose an already-disposed session. Env knobs for e2e: * GSTACK_PTY_RING_BUFFER_BYTES — compress to 256 for eviction tests. * GSTACK_PTY_DETACH_WINDOW_MS — compress to 1000 for "did the timer fire?" tests without waiting a minute per assertion. Tests: * browse/test/terminal-agent-detach-reattach.test.ts — 10 static-grep tripwires for the load-bearing properties: interface shape, env knobs, eviction floor, alt-screen tracking, replay prelude composition, re-attach lookup, close-code routing, detach timer unref, /internal/restart timer cancellation, on-data through session.liveWs. * browse/test/terminal-agent-session-routing.test.ts test 7 widened to match the new close(ws, code, _reason) signature. * browse/test/terminal-agent-keepalive.test.ts test 3 widened similarly. Both stay regressions for the prior contract. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sidebar): silent re-attach with scrollback replay (Commit 3 client side) Closes the v1.44 long-lived-sidebar loop end-to-end. When the WS dies for a transient reason (wifi blip, MV3 panel suspend, brief Chromium pause), the sidebar now silently re-attaches to the SAME claude session inside the server's 60s detach window. Scrollback replays cleanly; the user keeps typing without noticing anything happened. State machine: * New STATE.RECONNECTING covers the in-flight re-attach window. setState transitions out of this state reset reattachInFlight so a concurrent user action (Restart click, panel navigate) short-circuits cleanly. * Backoff schedule REATTACH_BACKOFF_MS = [1000, 2000, 4000, 8000] then 8s steady until REATTACH_WINDOW_MS (60s) elapses. Past that point the server has disposed our session and /pty-session/reattach returns 410 Gone. startReattachLoop(prevSessionId): * Posts /pty-session/reattach with sessionId. * On 200 with a valid 4-tuple, opens the post-reattach WS directly. * On 410 (lease expired) — short-circuits to ENDED. No retry; the user clicks Restart for a fresh session. * On 401 — sticky-aborts the auto-connect loop. Same defense as 25ef24e9 so we don't spam "Auth invalid" every 2s. * On network failure or other non-OK status — schedules the next backoff tick. openReattachWebSocket(terminalPort, attachToken, sessionId): * Mostly a clone of connect()'s attach wiring. Reuses the live xterm element — RIS clears the buffer cleanly when the agent's {type:"reattach-begin"} arrives, so the visual flash is minimal. * Handshake: on `{type:"reattach-begin"}` text frame → write `\x1bc` (RIS) to xterm + set nextBinaryIsReplay = true. The next binary frame IS the server-built replay payload (DECSTR soft-reset prefix + optional alt-screen re-enter + ring buffer contents). * If THIS reattach WS also dies uncleanly, recurses into another re-attach loop with the same sessionId — the server's detach window may still be open. State guard prevents runaway recursion. connect() + forceRestart() close handlers (existing): * Both updated to call startReattachLoop on transient close codes (anything other than 1000 / 4001 / 4404). Was just setState(ENDED). * Clean codes still bypass — re-attaching to a force-restart's pre-restart session would be the bug we're avoiding. Test (browse/test/sidepanel-reattach.test.ts): * 8 static-grep tripwires for the load-bearing properties: state constant, backoff schedule, /pty-session/reattach wiring, 410 short-circuit (no retry past lease window), 401 sticky-abort, reattach-begin → RIS handshake, all three close handlers route through the loop, clean-code bypass. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v1.44.0.0) Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(terminal-agent): runtime tests for ring buffer + replay + alt-screen tracking Companion to browse/test/terminal-agent-detach-reattach.test.ts (static-grep tripwires) — calls appendToRingBuffer + buildReplayPayload directly to prove behavioral correctness without spinning up a real Bun.serve listener. * 11 runtime cases: append + byte counting, oversize eviction with one-frame floor (the eviction loop guard that prevents an oversized single frame from emptying the buffer), alt-screen tracking via canonical xterm CSI ?1049h / CSI ?1049l, trailing-state-wins for enter+exit pairs inside a single render frame, soft-reset prefix ordering, optional alt-screen re-enter, payload length math. * Exports appendToRingBuffer, buildReplayPayload, and the PtySession interface from terminal-agent.ts (purely for testability — they were module-private; the change is annotation-only). * Lease registry sanity check: mint two sessions, verify distinct sessionIds, both valid simultaneously. Catches future refactors that accidentally couple lease + ring buffer. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): explain_level unset returns the documented default, not empty Pre-existing failure on main — the test expected gstack-config to return "" for an unset explain_level (with the comment "preamble default takes over"), but the script at bin/gstack-config:103 explicitly returns "default" inline for that key. Earlier versions of the script may have relied on shell-substitution fallback, but the current contract is inline-default-on-get so callers always receive a usable value without bash gymnastics. Updated the test to match the actual contract. Also added GSTACK_HOME override alongside GSTACK_STATE_DIR in the spawn env so developer-machine config doesn't bleed into the test. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Garry Tan authored* fix(browse): identity-based terminal-agent kill replaces pkill regex Commit 0 of the v1.44 long-lived-sidebar PR — foundation for the watchdog and removes a latent cross-session footgun. `pkill -f terminal-agent\.ts` (cli.ts spawn site + server.ts shutdown) matched by argv regex and would kill ANY process whose argv contained the string — sibling gstack sessions on the same host, an editor with the file open, a second `$B connect` run. Identity-based PID kill via a new helper module removes that whole class of bug. * New `browse/src/terminal-agent-control.ts`: `readAgentRecord`, `writeAgentRecord`, `clearAgentRecord`, `killAgentByRecord`. Validates PID liveness via `isProcessAlive` before signaling (PID-reuse defense). * `terminal-agent.ts` writes `<stateDir>/terminal-agent-pid` (JSON `{pid, gen, startedAt}`) at boot; clears on SIGTERM/SIGINT. * New per-boot `CURRENT_GEN` (16-byte random); `/internal/*` callers can include `X-Browse-Gen` to defend against split-brain in the upcoming watchdog. Absent header is accepted (backward compat); mismatch returns 409. New `checkInternalAuth` helper centralizes bearer + gen checks. * New `/internal/healthz` route — agent liveness probe used by the upcoming watchdog (returns pid/gen/sessions, no claude-binary lookup). * `cli.ts` and `server.ts` both call `killAgentByRecord` instead of pkill. * `ServerConfig.ownsTerminalAgent` JSDoc updated; the gated teardown now runs 4 side effects (was 3) — adds the new agent-record unlink. Test changes: * New `browse/test/terminal-agent-pid-identity.test.ts` — static-grep tripwire that fails CI if any source file re-introduces `pkill ... terminal-agent` or `spawnSync('pkill', ...)`; round-trips write/read/clear; verifies killAgentByRecord no-ops on dead PIDs. * `browse/test/server-embedder-terminal-port.test.ts` rewritten to intercept `process.kill` (not `child_process.spawnSync`); writes a sentinel agent-record with a guaranteed-dead PID; asserts probe-only (signal 0) calls, no termination signals; verifies all 3 discovery files including the new terminal-agent-pid. Closes TODOS.md P3 ("Identity-based terminal-agent kill"). Co-Authored-By:Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): repair 7 pre-existing failures (env pollution + stale markers) All 7 failures existed on main before this branch — verified via `git stash` round-trip. Bundling them into the long-lived-sidebar PR because we kept tripping over them while running `bun test` to verify Commit 0. * Global afterEach restores `process.env.PATH` (new bunfig.toml + test-setup.ts). browser-skill-commands.test.ts sets `PATH = '/test/bin:/usr/bin'` to exercise a scrubbed-env fixture and used the broken `process.env = origEnv` reassignment pattern that swaps the proxy reference; the underlying env stayed mutated and leaked downstream. Fixed three call sites in that file and added a narrow PATH-only global guardrail so a future polluter can't bring the bug back. Killed: pair-agent-tunnel-eval (bun ENOENT), security.test.ts > resolveBashBinary (Bun.which('bash') null), server-no-import-side-effects (bun ENOENT). * server-auth.test.ts: two `sliceBetween` markers referenced strings deleted when sidebar-agent.ts was ripped — `'Sidebar agent started'` → `'Terminal agent started'`, `'Sidebar endpoints'` → `'Batch endpoint'`. Also fixed the pair-agent BROWSE_PARENT_PID assertion (the literal `serverEnv.BROWSE_PARENT_PID` never existed in source; the actual contract is the object-literal `BROWSE_PARENT_PID: '0'` inside the `const serverEnv` declaration). * test/upgrade-migration-v1.test.ts: also overrides HOME in the spawn env. The migration shells out to `${HOME}/.claude/skills/gstack/bin/gstack-config` and a developer's real config with `explain_level` set causes the script to take the "user already decided" branch and skip writing the pending-prompt flag the test asserts on. * test/setup-codesign.test.ts: replaced fragile `bun run build` string-match (which hit a comment 700 lines later) with the actual invocation `bun_cmd run build` used in the setup script. Net: full suite is now green; CI no longer trips on bash/bun-ENOENT from PATH pollution or on test markers that drifted with the codebase. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(terminal-agent): extract internalHandler<T> helper for /internal/* routes Replaces the copy-pasted bearer-auth + X-Browse-Gen + req.json().then().catch() boilerplate on /internal/grant and /internal/revoke with a single internalHandler<T>(req, fn) wrapper. Future /internal/* routes added by the v1.44 long-lived-sidebar work (/internal/lease-refresh, /internal/restart) land as one-liners using the same helper. Pure refactor; no behavior change. /internal/healthz stays on the bare checkInternalAuth gate because it's a GET with no JSON body to parse — the helper's body-parse path would 400 it. * browse/src/terminal-agent.ts — new internalHandler<T>; /internal/grant + /internal/revoke routed through it. * browse/test/terminal-agent-internal-handler.test.ts — static-grep tripwire that fails CI if the helper goes away or either of the two refactored routes regresses to the old inline pattern. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(terminal-agent): 25s WS keepalive ping/pong + client keepalive frames PTY connections were dying silently after NAT idle timeouts (30-60s on most home routers, even shorter on some carrier-grade NAT) and Chrome MV3 panel suspension. Neither side noticed until the user's next keystroke produced no output. Both sides now drive a 25s keepalive cycle. Server side (browse/src/terminal-agent.ts): * New ws.open handler constructs the PtySession eagerly and starts a setInterval that sends `{type:"ping",ts:Date.now()}` every 25s. Interval handle stored on session.pingInterval so close() can clear it. * PtySession.pingInterval field added; cleared in ws.close before disposeSession runs. Prevents timer leak across reconnects. * Message handler accepts `{type:"ping"|"pong"|"keepalive"}` silently — keepalive frames are a liveness signal at the TCP layer, no state to update. Existing resize/tabSwitch/tabState handling unchanged. * GSTACK_PTY_KEEPALIVE_INTERVAL_MS env knob (default 25000) lets the upcoming e2e tests compress idle assertions without 30s waits. Client side (extension/sidepanel-terminal.js): * Belt-and-suspenders: client also runs a 25s setInterval that sends `{type:"keepalive"}`. Defends against Chrome pausing our timers if the server-side ping ever gets dropped (rare but possible in MV3). * Ping reply: on `{type:"ping",ts}` from the server, immediately send `{type:"pong",ts}`. Lets the agent observe round-trip latency for free and confirms the channel is bidirectional. * Interval cleared in three teardown paths: ws.close handler, teardown(), forceRestart(). Three paths exist because the sidebar can exit the LIVE state through any of them; all three must clean up or we leak timers across reconnects. Test (browse/test/terminal-agent-keepalive.test.ts): * Static-grep tripwires for the 7-point protocol contract: agent has a configurable interval, open() starts the ping, close() clears it, message handler accepts keepalive vocabulary, client sends keepalive + replies pong, and all three client teardown paths clear the timer. * Wire-level tests (actually observe a ping after 25s) belong in the e2e tier — adding them here would either flake on slow CI or require a real Bun.serve listener per test which we don't want to pay for in the free tier. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sidebar): patient tryAutoConnect — poll forever with ascending status, abort only on 401 The 15s give-up message ("Browse server not ready. Reload sidebar to retry.") fired on every cold start where the daemon took >15s to bind — common on Conductor workspaces, CI runners, and any system under load. The user already opened the sidebar; telling them to give up is the wrong default. Now polls every 2s indefinitely with ascending status messages: * 0 - 15s : silent (handles the happy path on a warm laptop) * 15 - 60s : "Waiting for browse server..." * 60s - 5m : "Still waiting — browse server may be slow to start." * > 5m : "Browse server still not responding after 5 min. Try `$B status`." Loop aborts on three signals only: * state transitions out of IDLE (connect succeeded or user navigated) * autoConnectAborted sticky flag set on unrecoverable error * the panel itself unloading (browser handles this; pagehide cleanup arrives with T8 of the larger plan) 401 from /pty-session sets the sticky flag with a clear "Auth invalid — reload the sidebar or restart your gstack session." message. Without the flag, the loop would re-call connect() every 2s and spam the same error; with it, the user sees the message once and the loop holds. forceRestart() clears the flag so clicking Restart is the explicit "try again" escape hatch. Bumped poll interval 200ms → 2000ms — the legacy tight loop burned CPU for no reason. 2s is plenty fast for a "did the daemon come up yet" check. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(browse): terminal-agent watchdog with PID liveness + crash-loop guard terminal-agent could die independently of the server — SIGKILL from the OS OOM killer, an uncaught exception under PTY churn, an external `pkill` from a sibling debugging session. Pre-v1.44 the sidebar would observe the broken connection and stay broken until the user reloaded the sidebar. Now a 60s ticker checks the recorded agent PID and respawns via the shared spawnTerminalAgent helper when dead. Identity-based liveness (T4 from the eng review): * Uses readAgentRecord + isProcessAlive (signal 0 probe), not a name match. * Slow-but-alive agents intentionally fall through — respawning around a living agent would create split-brain (two agents writing the port file, tokens diverging between them, mystery upgrade 401s). * Pairs with the v1.44 generation counter in /internal/* loopback calls: if a stale agent does come back to life mid-cycle, its X-Browse-Gen no longer matches and the parent's calls 409 cleanly. Crash-loop guard: * 3 respawn attempts inside a rolling 60s window → stop trying. A daemon up for a week with one crash a day shouldn't trip the guard. * On trip: one-line error to console (`respawn guard tripped`) and the watchdog goes dormant. Manual restart via the sidebar Restart button is the explicit signal to re-arm (added in Commit 2 of the larger PR). Shared spawn path (refactor): * New spawnTerminalAgent(opts) in terminal-agent-control.ts handles: prior-PID cleanup → spawn → record stash. Both the CLI cold-start path in cli.ts and the new server.ts watchdog route through it. Removes the copy-paste between them; future env wiring lands in one place. Gated on cfg.ownsTerminalAgent — embedders that pre-launch their own PTY server (gbrowser phoenix overlay) still own the full lifecycle. GSTACK_AGENT_WATCHDOG_TICK_MS env knob compresses the 60s tick for e2e tests without 60s waits per assertion. Tests: * browse/test/terminal-agent-watchdog.test.ts — 7 static-grep tripwires for the load-bearing invariants (ownsTerminalAgent gate, PID-based liveness, crash-loop guard with window pruning, shutdown cleanup, CLI cold-start uses the same helper, env knob exists). * Live process-kill tests belong in the e2e tier; cheaper invariants here catch refactor regressions in ~1ms each. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): opt-in outer supervisor — respawn browse server on crash Pre-v1.44 `$B connect` was fire-and-forget: spawn server detached, CLI exits, server runs unsupervised. If the server crashed (OOM, uncaught exception, signal kill from a runaway debugger), the user had to notice, re-run `$B connect`, and resume work. The v1.44 terminal-agent watchdog recovers from one layer of failure; this commit closes the outer loop. Opt-in via `--supervise` flag or `BROWSE_SUPERVISE=1` env. Default behavior is unchanged — every existing caller (Claude Code's Bash tool, scripts, CI) still gets a prompt return. When the flag is set: * CLI stays attached, polls server PID every 30s via readState() + isProcessAlive (same identity primitive as the terminal-agent watchdog). * On unexpected exit: respawn via the same headed-mode startServer path used initially, then re-spawn the terminal-agent so the PTY recovers too (otherwise sidebar Restart is the only path back). * Crash-loop guard: 5 respawns in a rolling 5-min window → exit 1 with a clear error. Window pruning means a long-lived daemon with sporadic crashes does NOT trip the guard (otherwise we punish the user for the supervisor doing its job). * Backoff: 1s, 2s, 4s, 8s, 30s capped. Env-overridable via GSTACK_SUPERVISOR_BACKOFF for tests. * SIGINT / SIGTERM: clean teardown — signals the supervised server before exiting itself. Without this, Ctrl-C leaves an orphaned server. Out of scope (deferred follow-up): routing the Chromium-disconnect exit-code-1 path back through this supervisor. The terminal-agent watchdog already covers the highest-frequency restart case; Chromium crash recovery joins the queue as its own commit. Test (browse/test/cli-supervisor.test.ts): * 6 static-grep tripwires: opt-in default, signal wiring, crash-loop guard with window pruning, backoff schedule env knob, tick interval env knob, terminal-agent re-spawn after server respawn. * Live respawn tests belong in the e2e tier (real spawn cycles take 3-8s each; spamming these in the free tier would balloon CI time). Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(browse): pty-session-lease registry — stable sessionId + lease lifecycle Foundation for Commit 2 of the long-lived-sidebar PR. Separates two concerns that pre-v1.44 were conflated under one token: * sessionId — stable, non-secret identifier for a single PTY session. Safe to log, safe in URLs, safe in DevTools. Identifies "this terminal," not "you're allowed to use this terminal." * lease — server-side bookkeeping that maps sessionId → expiresAt. Re-attach within the lease window resumes the same PTY; expiry tears it down. The companion attach-token primitive (short-lived 30s bearer) reuses the existing browse/src/pty-session-cookie.ts module unchanged — the lease adds a name-space alongside, it doesn't replace anything. Codex outside-voice (T1 of the eng review) flagged the original D4 "token IS sessionId" design as conflating identity with auth. The fix is this lease registry: re-attach URLs carry the stable sessionId (loggable), the short-lived attachToken stays out of logs. API: * mintLease() → { sessionId, expiresAt } * validateLease(sessionId) → { ok: true, expiresAt } | { ok: false } * refreshLease(sessionId) — validate-first, never resurrects expired leases. Security-critical: the 30-min TTL is what bounds blast radius for a leaked attachToken whose lease should have GC'd. * revokeLease(sessionId) — explicit dispose path. * leaseCount() — observability helper. * __resetLeases() — test-only. TTL env knob (GSTACK_PTY_LEASE_TTL_MS) lets v1.44 e2e tests compress the detach window to 1s instead of waiting 30 minutes per assertion. Server.ts wiring + /pty-session shape change + /pty-restart + /pty-dispose + /pty-session/reattach all land in subsequent commits in this branch. Test (browse/test/pty-session-lease.test.ts): * 8 cases pinning mint uniqueness, validate-first refresh contract, revoke idempotency, null/undefined tolerance, and the negative case that refresh never resurrects a revoked lease (same code path as expired-and-pruned). Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(terminal-agent): sessionId-aware grant + scoped restart + eager spawn Wires the pty-session-lease primitive (3aada48b) into terminal-agent so the Commit 2 work in server.ts (next commit) can route /pty-restart and re-attach by session identity rather than by single-use token. Changes: * validTokens: Set<string> → Map<string, string|null>. Each grant carries its bound sessionId (or null for legacy single-grant callers). On WS upgrade, the agent surfaces the bound sessionId via ws.data so open() can register the session in the new reverse index. * sessionsById: Map<sessionId, PtySession> — populated in open(), cleared in close(). Required so /internal/restart can find and dispose one specific session by id rather than enumerating all live sessions. * /internal/restart: scoped to one sessionId. Codex T2 of the eng review caught the gap — pre-spec the route would have disposed every PTY on the agent, breaking pair-agent and any future multi-sidebar setup. The body now requires `{sessionId}`; missing or unknown id returns `{killed: 0}` and leaves siblings alone. * maybeSpawnPty(ws, session): hoisted from the inline binary-frame spawn block so both the legacy "spawn on first keystroke" trigger AND the new `{type:"start"}` text-frame trigger land in the same code path. Idempotent on session.spawned. * `{type:"start"}` text frame: explicit spawn trigger. forceRestart (extension side, lands in Commit 2C) sends this immediately on every fresh WS so claude boots without requiring a keystroke. Pre-v1.44 the lazy-binary-spawn pattern made the restart feel stuck. * close(ws): drops the sessionsById entry alongside the existing sessions WeakMap + validTokens cleanup. Commit 3 will revisit this to keep the session alive for a 60s detach window before disposing. Test (browse/test/terminal-agent-session-routing.test.ts): * 8 static-grep tripwires pinning the load-bearing properties: validTokens is a Map (not Set), sessionsById exists, /internal/restart is scoped (negative-assert against enumerate-all patterns), WS upgrade plumbs sessionId, maybeSpawnPty is the single spawn entry, close() drops the index. Live spawn cycles belong in the e2e tier. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server): /pty-session 4-tuple + /pty-restart + /pty-dispose + lease-refresh Wires the lease + attachToken model end-to-end on the server side. The client side (extension) lands in the next commit; agent side already shipped in 449144cd. Routes: * POST /pty-session — mints sessionId (stable, loggable) + lease (server-side bookkeeping) + attachToken (short-lived bearer for the WS upgrade). Returns the 4-tuple in one round trip. Legacy ptySessionToken / expiresAt aliases kept for one minor release so extensions on the v1.43 wire shape keep working. * POST /pty-session/reattach — validates a sessionId's lease and mints a FRESH attachToken bound to the same sessionId. Used by Commit 3's re-attach loop; 410 Gone when the lease has expired so the client knows to fall back to a brand-new /pty-session. * POST /pty-restart — one transaction: dispose the caller's existing PtySession on the agent (via /internal/restart, scoped to one sessionId — codex T2), revoke the old lease, mint a fresh sessionId + lease + attachToken, return the 4-tuple. Zero race window between kill and mint (codex T2 + D8 of the eng review). * POST /pty-dispose — explicit teardown. sendBeacon-compatible: accepts auth token in the body so the extension's pagehide handler (Commit 2C) can fire it without setting custom headers (sendBeacon doesn't support those). Without this route, every clean browser quit leaves a zombie PTY alive for the 60s detach window — codex T3 caught it. * POST /internal/lease-refresh — loopback from terminal-agent on its 25s keepalive cycle (lazy: only when lease is within 5 min of expiry). Refreshes the lease AND resets the daemon idle timer. T6 of the eng review: PTY activity (not arbitrary SSE consumers) is what keeps the daemon alive when the sidebar is in use. Helpers: * grantPtyToken now accepts optional sessionId and passes it through to the agent's /internal/grant body. The agent binds token → sessionId in its validTokens Map so /ws upgrades carry the sessionId for /internal/restart and Commit 3 re-attach lookups. * restartPtySession() — new loopback helper that POSTs the agent's scoped /internal/restart with a sessionId body. Used by /pty-restart and /pty-dispose. Auth contract on /pty-dispose deliberately accepts the auth token in EITHER the Authorization header OR the request body. The body path is required for sendBeacon (which can't set custom headers); the header path stays available for non-beacon callers and tests. Test (browse/test/server-pty-lease-routes.test.ts): * 7 static-grep tripwires pinning the 4-tuple shape, validate-first re-attach with 410 fallback, one-transaction restart semantics, sendBeacon-compatible dispose auth, and the T6 PTY-only idle reset. * Live route exercises (full mint + grant + WS upgrade cycle) belong in the e2e tier — they require a real terminal-agent loopback and take seconds per assertion. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sidebar): forceRestart via /pty-restart + pagehide /pty-dispose Closes the Commit 2 loop: server-side lease + restart routes shipped in 25ef24e9; this commit wires the extension client to use them. End-to-end result — clicking Restart now actually kills the server's PTY before opening a new WS (zero race window), and closing the sidebar / quitting the browser disposes the PTY immediately instead of letting it linger for the upcoming 60s detach window. sidepanel-terminal.js: * mintSession callers read the v1.44 4-tuple (sessionId + attachToken) from /pty-session, with a backward-compat fallback to ptySessionToken so a partially-updated extension still works against a fresh server for one minor release. * Eager spawn via {type:"start"} text frame replaces the legacy `TextEncoder().encode("\n")` newline hack. Pre-v1.44, the lazy-binary- spawn pattern made forceRestart look stuck until the user typed — now claude boots before the prompt renders. * forceRestart() rewritten as an async one-transaction handler: 1. close current WS with code 4001 (intentional-restart) 2. POST /pty-restart with priorSessionId so the server can scope the dispose, then mint fresh sessionId + lease + attachToken in the same response 3. Open new WS with the returned attachToken, send {type:"start"} immediately for eager spawn 4. On 401: sticky-abort the auto-connect loop (no spam) 5. On 503 / network failure: fall back to patient autoconnect * currentSessionId tracked and exposed on window.gstackPtySession so sidepanel.js's pagehide handler can sendBeacon the dispose. sidepanel.js: * New pagehide handler fires navigator.sendBeacon('/pty-dispose', {sessionId, authToken}) on tab close, panel close, browser quit, or extension reload. sendBeacon-compatible: auth token rides in the body since sendBeacon can't set custom headers (server route accepts body-auth per 25ef24e9). * try/catch around the entire body so a sendBeacon failure can't interfere with the browser's unload sequence — the 60s detach window from Commit 3 catches anything we miss. There's bounded duplication between connect() and forceRestart() (~70 lines of WS attach/handler wiring). Extracting a shared helper is a clean follow-up but out of scope for the v1.44 ship — both paths are exercised by the same e2e test. Test (browse/test/sidepanel-restart-dispose.test.ts): * 9 static-grep tripwires pinning the 4-tuple parse, eager spawn, close-code 4001 contract, /pty-restart wire shape, sticky-abort 401 path, sessionId window plumbing, sendBeacon body contract, and the best-effort try/catch around pagehide. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(terminal-agent): scrollback ring buffer + detach state machine + re-attach The agent side of Commit 3 — the "magic" feature. A network blip (wifi hiccup, MV3 panel suspend, brief Chromium pause) now silently reconnects the sidebar to the SAME claude session with scrollback intact. No more "Session ended" message + manual Restart click + losing your tool-call output. Server-side /pty-session/reattach (25ef24e9) and the extension re-attach loop (next commit) close the loop end-to-end. Ring buffer (T10): * Per-session frames: Buffer[] capped at 1 MB (env-overridable via GSTACK_PTY_RING_BUFFER_BYTES). Each PTY write is one frame, so eviction is at frame boundaries and never cuts a UTF-8 sequence or ANSI CSI in half. * appendToRingBuffer eviction loop keeps at least one frame even at extreme caps — a single oversized frame can't empty the buffer. * Alt-screen tracking via canonical xterm CSI ?1049h / CSI ?1049l sequences. lastIndexOf comparison so trailing state wins when both appear in one render frame (quick tool-call open+close). Replay payload (T5 — codex outside-voice): * buildReplayPayload prefixes DECSTR soft reset (\x1b[!p) and conditionally re-enters alt-screen if claude was in a tool call at detach. The client writes RIS (\x1bc) FIRST to clear pre-blip xterm content; the server's prelude resets character attributes; the ring buffer replays cleanly on top. * Order is enforced by the {type:"reattach-begin"} text frame the agent sends right before the binary replay — client waits for it, writes RIS, then treats the next binary frame as the replay payload. Detach state machine (T9): * PtySession.liveWs decouples the PTY callback from the original ws closure. On re-attach, swapping session.liveWs is enough — the on-data callback writes to the new ws automatically. * close(ws, code, _reason): codes 4001 (intentional restart), 4404 (no-claude), and 1000 (clean exit) trigger immediate dispose. Anything else (1006 abnormal, 1001 going-away from network blip / panel suspend) starts a 60s detach timer instead. claude keeps running, output keeps accumulating in the ring buffer. * Detach timer is unref'd so the bun process can still exit cleanly on natural shutdown. * Sessions without a sessionId (legacy single-shot grants) can't re-attach by definition — those fall through to immediate dispose. Re-attach lookup (T9): * WS open() checks sessionsById[sessionId] FIRST. If a detached session is sitting there, cancel its detach timer, swap liveWs, rebind the WS-keyed map, restart keepalive, send reattach-begin + replay payload. The PTY process is unchanged. * /internal/restart now cancels any pending detach timer before disposal — otherwise the timer would later try to dispose an already-disposed session. Env knobs for e2e: * GSTACK_PTY_RING_BUFFER_BYTES — compress to 256 for eviction tests. * GSTACK_PTY_DETACH_WINDOW_MS — compress to 1000 for "did the timer fire?" tests without waiting a minute per assertion. Tests: * browse/test/terminal-agent-detach-reattach.test.ts — 10 static-grep tripwires for the load-bearing properties: interface shape, env knobs, eviction floor, alt-screen tracking, replay prelude composition, re-attach lookup, close-code routing, detach timer unref, /internal/restart timer cancellation, on-data through session.liveWs. * browse/test/terminal-agent-session-routing.test.ts test 7 widened to match the new close(ws, code, _reason) signature. * browse/test/terminal-agent-keepalive.test.ts test 3 widened similarly. Both stay regressions for the prior contract. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sidebar): silent re-attach with scrollback replay (Commit 3 client side) Closes the v1.44 long-lived-sidebar loop end-to-end. When the WS dies for a transient reason (wifi blip, MV3 panel suspend, brief Chromium pause), the sidebar now silently re-attaches to the SAME claude session inside the server's 60s detach window. Scrollback replays cleanly; the user keeps typing without noticing anything happened. State machine: * New STATE.RECONNECTING covers the in-flight re-attach window. setState transitions out of this state reset reattachInFlight so a concurrent user action (Restart click, panel navigate) short-circuits cleanly. * Backoff schedule REATTACH_BACKOFF_MS = [1000, 2000, 4000, 8000] then 8s steady until REATTACH_WINDOW_MS (60s) elapses. Past that point the server has disposed our session and /pty-session/reattach returns 410 Gone. startReattachLoop(prevSessionId): * Posts /pty-session/reattach with sessionId. * On 200 with a valid 4-tuple, opens the post-reattach WS directly. * On 410 (lease expired) — short-circuits to ENDED. No retry; the user clicks Restart for a fresh session. * On 401 — sticky-aborts the auto-connect loop. Same defense as 25ef24e9 so we don't spam "Auth invalid" every 2s. * On network failure or other non-OK status — schedules the next backoff tick. openReattachWebSocket(terminalPort, attachToken, sessionId): * Mostly a clone of connect()'s attach wiring. Reuses the live xterm element — RIS clears the buffer cleanly when the agent's {type:"reattach-begin"} arrives, so the visual flash is minimal. * Handshake: on `{type:"reattach-begin"}` text frame → write `\x1bc` (RIS) to xterm + set nextBinaryIsReplay = true. The next binary frame IS the server-built replay payload (DECSTR soft-reset prefix + optional alt-screen re-enter + ring buffer contents). * If THIS reattach WS also dies uncleanly, recurses into another re-attach loop with the same sessionId — the server's detach window may still be open. State guard prevents runaway recursion. connect() + forceRestart() close handlers (existing): * Both updated to call startReattachLoop on transient close codes (anything other than 1000 / 4001 / 4404). Was just setState(ENDED). * Clean codes still bypass — re-attaching to a force-restart's pre-restart session would be the bug we're avoiding. Test (browse/test/sidepanel-reattach.test.ts): * 8 static-grep tripwires for the load-bearing properties: state constant, backoff schedule, /pty-session/reattach wiring, 410 short-circuit (no retry past lease window), 401 sticky-abort, reattach-begin → RIS handshake, all three close handlers route through the loop, clean-code bypass. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v1.44.0.0) Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(terminal-agent): runtime tests for ring buffer + replay + alt-screen tracking Companion to browse/test/terminal-agent-detach-reattach.test.ts (static-grep tripwires) — calls appendToRingBuffer + buildReplayPayload directly to prove behavioral correctness without spinning up a real Bun.serve listener. * 11 runtime cases: append + byte counting, oversize eviction with one-frame floor (the eviction loop guard that prevents an oversized single frame from emptying the buffer), alt-screen tracking via canonical xterm CSI ?1049h / CSI ?1049l, trailing-state-wins for enter+exit pairs inside a single render frame, soft-reset prefix ordering, optional alt-screen re-enter, payload length math. * Exports appendToRingBuffer, buildReplayPayload, and the PtySession interface from terminal-agent.ts (purely for testability — they were module-private; the change is annotation-only). * Lease registry sanity check: mint two sessions, verify distinct sessionIds, both valid simultaneously. Catches future refactors that accidentally couple lease + ring buffer. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): explain_level unset returns the documented default, not empty Pre-existing failure on main — the test expected gstack-config to return "" for an unset explain_level (with the comment "preamble default takes over"), but the script at bin/gstack-config:103 explicitly returns "default" inline for that key. Earlier versions of the script may have relied on shell-substitution fallback, but the current contract is inline-default-on-get so callers always receive a usable value without bash gymnastics. Updated the test to match the actual contract. Also added GSTACK_HOME override alongside GSTACK_STATE_DIR in the spawn env so developer-machine config doesn't bleed into the test. Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Loading