mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-05 17:46:37 +02:00
920a13a17f
* 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>
1012 lines
42 KiB
TypeScript
1012 lines
42 KiB
TypeScript
/**
|
|
* Terminal Agent — PTY-backed Claude Code terminal for the gstack browser
|
|
* sidebar. Translates the phoenix gbrowser PTY (cmd/gbd/terminal.go) into
|
|
* Bun, with a few changes informed by codex's outside-voice review:
|
|
*
|
|
* - Lives in a separate non-compiled bun process from sidebar-agent.ts so
|
|
* a bug in WS framing or PTY cleanup can't take down the chat path.
|
|
* - Binds 127.0.0.1 only — never on the dual-listener tunnel surface.
|
|
* - Origin validation on the WS upgrade is REQUIRED (not defense-in-depth)
|
|
* because a localhost shell WS is a real cross-site WebSocket-hijacking
|
|
* target.
|
|
* - Cookie-based auth via /internal/grant from the parent server, not a
|
|
* token in /health.
|
|
* - Lazy spawn: claude PTY is not spawned until the WS receives its first
|
|
* data frame. Sidebar opens that never type don't burn a claude session.
|
|
* - PTY dies with WS close (one PTY per WS). v1.1 may add session
|
|
* survival; for v1 we match phoenix's lifecycle.
|
|
*
|
|
* The PTY uses Bun's `terminal:` spawn option (verified at impl time on
|
|
* Bun 1.3.10): pass cols/rows + a data callback; write input via
|
|
* `proc.terminal.write(buf)`; resize via `proc.terminal.resize(cols, rows)`.
|
|
*/
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as crypto from 'crypto';
|
|
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
|
import { safeUnlink } from './error-handling';
|
|
import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control';
|
|
|
|
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
|
|
const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port');
|
|
const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10);
|
|
const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check
|
|
const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn
|
|
/**
|
|
* Per-boot generation identifier. Loopback /internal/* callers include
|
|
* `X-Browse-Gen: <CURRENT_GEN>` so a slow agent the watchdog respawned
|
|
* around can't service a stale grant from the prior generation. Absent
|
|
* header means "legacy caller" and is accepted (backward compat); a
|
|
* present-but-mismatched header returns 409 stale generation.
|
|
*/
|
|
const CURRENT_GEN = crypto.randomBytes(16).toString('base64url');
|
|
|
|
// In-memory attach-token registry. Parent posts /internal/grant after
|
|
// /pty-session; we validate WS upgrades against this map.
|
|
//
|
|
// v1.44+: each token is bound to a v1.44 sessionId (the stable, non-secret
|
|
// identifier from browse/src/pty-session-lease.ts). The token grants ONE
|
|
// attach for ONE session — re-attach within the lease window comes through
|
|
// /pty-session/reattach, which mints a fresh token for the same sessionId.
|
|
//
|
|
// Legacy callers can still pass `{token}` without sessionId (the value
|
|
// stays null and the WS upgrade still works); those callers don't get
|
|
// re-attach because there's no stable identifier to match against.
|
|
const validTokens = new Map<string, string | null>(); // token → sessionId
|
|
|
|
/**
|
|
* Reverse index for re-attach lookups: sessionId → live PtySession.
|
|
* Populated when a WS first attaches with a known sessionId; cleared when
|
|
* the session is disposed or the lease expires. Used by:
|
|
* - /ws upgrade: if the incoming attachToken maps to a sessionId that
|
|
* already has a live session, REPLACE its ws ref instead of spawning.
|
|
* - /internal/restart: enumerate by sessionId, dispose that one session.
|
|
*
|
|
* Kept separate from the WeakMap<ws,PtySession> so re-attach can find the
|
|
* session by id even after the original ws has gone.
|
|
*/
|
|
const sessionsById = new Map<string, PtySession>();
|
|
|
|
// Active PTY session per WS. One terminal per connection. Codex finding #4:
|
|
// uncaught handlers below catch bugs in framing/cleanup so they don't kill
|
|
// the listener loop.
|
|
process.on('uncaughtException', (err) => {
|
|
console.error('[terminal-agent] uncaughtException:', err);
|
|
});
|
|
process.on('unhandledRejection', (reason) => {
|
|
console.error('[terminal-agent] unhandledRejection:', reason);
|
|
});
|
|
|
|
export interface PtySession {
|
|
proc: any | null; // Bun.Subprocess once spawned
|
|
cols: number;
|
|
rows: number;
|
|
cookie: string;
|
|
/**
|
|
* Current attached websocket. Swapped on re-attach (Commit 3): when a new
|
|
* WS upgrade matches this session's sessionId, the old liveWs is gone
|
|
* and the new ws takes its place. The PTY on-data callback closes over
|
|
* `session`, not the original `ws`, so it always writes to the current
|
|
* liveWs (or skips the write when detached and liveWs is null).
|
|
*/
|
|
liveWs: any | null;
|
|
/**
|
|
* v1.44+ stable session identifier (from pty-session-lease). Null for
|
|
* legacy /internal/grant callers that didn't pass one. Used for
|
|
* targeted /internal/restart and Commit 3 re-attach lookups.
|
|
*/
|
|
sessionId: string | null;
|
|
spawned: boolean;
|
|
/**
|
|
* 25s server-side WS keepalive interval (v1.44+). Set in the WS `open`
|
|
* handler, cleared in `close`. We send `{type:"ping",ts}` text frames so
|
|
* NAT boxes, proxies, and Chrome's MV3 panel-suspend heuristics see the
|
|
* connection as active; the client either replies with `{type:"pong"}`
|
|
* or fires its own 25s `{type:"keepalive"}` cycle. Either path keeps
|
|
* the underlying TCP from being silently dropped.
|
|
*/
|
|
pingInterval: ReturnType<typeof setInterval> | null;
|
|
/**
|
|
* Commit 3 scrollback ring buffer. Each PTY write appends a frame; the
|
|
* total byte count is capped at RING_BUFFER_MAX_BYTES with oldest frames
|
|
* evicted first. On re-attach, the surviving frames are replayed as a
|
|
* single binary frame (prefixed with the v1.44 reset sequence) so the
|
|
* user sees their last screen of output. Frame boundaries preserve UTF-8
|
|
* + ANSI-CSI boundaries because each frame is the exact buffer that
|
|
* spawnClaude's on-data callback emitted.
|
|
*/
|
|
ringBuffer: Buffer[];
|
|
ringBufferBytes: number;
|
|
/**
|
|
* Tracks whether the PTY is currently in xterm alt-screen mode. claude's
|
|
* TUI enters alt-screen (CSI ?1049h) during tool calls and exits (CSI
|
|
* ?1049l) when returning to the main prompt. On re-attach, the replay
|
|
* prelude must re-enter alt-screen if the original PTY left it active,
|
|
* otherwise the replay renders against the main screen and the cursor
|
|
* + colors end up in the wrong place.
|
|
*/
|
|
altScreenActive: boolean;
|
|
/**
|
|
* Detach state machine (Commit 3). When the WS closes for a reason OTHER
|
|
* than the v1.44 intentional-restart code (4001), we keep the PtySession
|
|
* alive for the detach window (default 60s) so a re-attach within the
|
|
* window can resume the same PTY and replay the ring buffer. The timer
|
|
* disposes the session if no re-attach arrives in time.
|
|
*/
|
|
detached: boolean;
|
|
detachTimer: ReturnType<typeof setTimeout> | null;
|
|
}
|
|
|
|
/**
|
|
* WS keepalive interval. 25s is comfortably under the lowest common NAT
|
|
* idle timeout (typically 30-60s) and shorter than Chromium's WebSocket
|
|
* dead-peer threshold. Test-overridable via env so the v1.44 e2e tests
|
|
* can compress idle-window assertions to <1s without waiting half a
|
|
* minute per assertion.
|
|
*/
|
|
const KEEPALIVE_INTERVAL_MS = parseInt(
|
|
process.env.GSTACK_PTY_KEEPALIVE_INTERVAL_MS || '25000',
|
|
10,
|
|
);
|
|
|
|
/**
|
|
* Commit 3 scrollback ring buffer cap. 1 MB is enough for a full screen
|
|
* of dense claude output (including a recent tool result), small enough
|
|
* that a worst-case 10 detached sessions only cost ~10 MB of RSS.
|
|
* Env-overridable so e2e tests can verify eviction without writing 1 MB
|
|
* of fixture data per assertion.
|
|
*/
|
|
const RING_BUFFER_MAX_BYTES = parseInt(
|
|
process.env.GSTACK_PTY_RING_BUFFER_BYTES || `${1024 * 1024}`,
|
|
10,
|
|
);
|
|
|
|
/**
|
|
* Commit 3 detach window — how long to keep a session alive after WS
|
|
* close (with any code other than 4001 intentional-restart) so a
|
|
* re-attach can resume the same PTY. 60s is long enough to cover a
|
|
* Chrome MV3 service-worker suspend cycle, a wifi blip, or a brief
|
|
* laptop sleep; short enough that genuinely-closed sessions don't
|
|
* stack up unbounded.
|
|
*/
|
|
const DETACH_WINDOW_MS = parseInt(
|
|
process.env.GSTACK_PTY_DETACH_WINDOW_MS || '60000',
|
|
10,
|
|
);
|
|
|
|
/**
|
|
* Append a frame to a session's ring buffer, evicting oldest frames if
|
|
* the total byte count exceeds RING_BUFFER_MAX_BYTES. Eviction is at
|
|
* frame boundaries (one PTY write = one frame), so we never cut a
|
|
* multi-byte UTF-8 sequence or a partial ANSI CSI in half — claude's
|
|
* on-data callback emits coherent frames.
|
|
*
|
|
* Side effect: scans the appended chunk for alt-screen enter/exit
|
|
* sequences (CSI ?1049h / CSI ?1049l) and updates session.altScreenActive
|
|
* so the re-attach prelude knows whether to re-enter alt-screen.
|
|
*/
|
|
export function appendToRingBuffer(session: PtySession, frame: Buffer): void {
|
|
session.ringBuffer.push(frame);
|
|
session.ringBufferBytes += frame.length;
|
|
while (session.ringBufferBytes > RING_BUFFER_MAX_BYTES && session.ringBuffer.length > 1) {
|
|
const evicted = session.ringBuffer.shift()!;
|
|
session.ringBufferBytes -= evicted.length;
|
|
}
|
|
// Alt-screen tracking. Scan for the canonical xterm enter/exit pairs.
|
|
// We do this on every append (not just on attach) so the state is
|
|
// correct even if many frames have flowed since the last attach.
|
|
const ascii = frame.toString('latin1'); // single-byte view is enough — the codes are 7-bit ASCII
|
|
// Use lastIndexOf so trailing state wins when both appear in one frame
|
|
// (e.g., a quick tool-call open+close inside one render pass).
|
|
const enterIdx = ascii.lastIndexOf('\x1b[?1049h');
|
|
const exitIdx = ascii.lastIndexOf('\x1b[?1049l');
|
|
if (enterIdx >= 0 && enterIdx > exitIdx) session.altScreenActive = true;
|
|
else if (exitIdx >= 0 && exitIdx > enterIdx) session.altScreenActive = false;
|
|
}
|
|
|
|
/**
|
|
* Build the re-attach replay payload: server-side reset prelude + the
|
|
* accumulated ring buffer. The client side writes RIS (`\x1bc`) to xterm
|
|
* BEFORE feeding this payload in, so the layout is:
|
|
*
|
|
* 1. Client: `\x1bc` (RIS — full reset, clears pre-blip xterm content)
|
|
* 2. Server: `\x1b[!p` (DECSTR soft reset — re-defaults char attributes)
|
|
* 3. Server: optional `\x1b[?1049h` if we were in alt-screen at detach
|
|
* 4. Server: ring buffer contents, in append order
|
|
*
|
|
* The client coordinates the order by waiting for a `{type:"reattach-begin"}`
|
|
* text frame before treating the next binary frame as replay. That separation
|
|
* is what lets us prepend reset codes without clobbering the live stream
|
|
* that resumes immediately after.
|
|
*/
|
|
export function buildReplayPayload(session: PtySession): Buffer {
|
|
const parts: Buffer[] = [];
|
|
parts.push(Buffer.from('\x1b[!p'));
|
|
if (session.altScreenActive) parts.push(Buffer.from('\x1b[?1049h'));
|
|
for (const frame of session.ringBuffer) parts.push(frame);
|
|
return Buffer.concat(parts);
|
|
}
|
|
|
|
const sessions = new WeakMap<any, PtySession>(); // ws -> session
|
|
|
|
/** Find claude on PATH. */
|
|
function findClaude(): string | null {
|
|
// Test-only override. Lets the integration tests spawn /bin/bash instead
|
|
// of requiring claude to be installed on every CI runner. NEVER read in
|
|
// production (sidebar UI). Documented in browse/test/terminal-agent-integration.test.ts.
|
|
const override = process.env.BROWSE_TERMINAL_BINARY;
|
|
if (override && fs.existsSync(override)) return override;
|
|
// Bun.which is sync and respects PATH. Falls back to a small list of
|
|
// common install locations if PATH is stripped (e.g., launched from
|
|
// Conductor with a minimal env).
|
|
const which = (Bun as any).which?.('claude');
|
|
if (which) return which;
|
|
const candidates = [
|
|
'/opt/homebrew/bin/claude',
|
|
'/usr/local/bin/claude',
|
|
`${process.env.HOME}/.local/bin/claude`,
|
|
`${process.env.HOME}/.bun/bin/claude`,
|
|
`${process.env.HOME}/.npm-global/bin/claude`,
|
|
];
|
|
for (const c of candidates) {
|
|
try { fs.accessSync(c, fs.constants.X_OK); return c; } catch {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Probe + persist claude availability for the bootstrap card. */
|
|
function writeClaudeAvailable(): void {
|
|
const stateDir = path.dirname(STATE_FILE);
|
|
try { mkdirSecure(stateDir); } catch {}
|
|
const found = findClaude();
|
|
const status = {
|
|
available: !!found,
|
|
path: found || undefined,
|
|
install_url: 'https://docs.anthropic.com/en/docs/claude-code',
|
|
checked_at: new Date().toISOString(),
|
|
};
|
|
const target = path.join(stateDir, 'claude-available.json');
|
|
const tmp = path.join(stateDir, `.tmp-claude-${process.pid}`);
|
|
try {
|
|
writeSecureFile(tmp, JSON.stringify(status, null, 2));
|
|
fs.renameSync(tmp, target);
|
|
} catch {
|
|
safeUnlink(tmp);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* System-prompt hint passed to claude via --append-system-prompt. Tells
|
|
* claude what tab-awareness affordances exist in this session so it
|
|
* doesn't have to discover them by trial. The user can override anything
|
|
* here just by saying so — system prompt is a soft hint, not a contract.
|
|
*
|
|
* Two paths claude has:
|
|
* 1. Read live state from <stateDir>/tabs.json + active-tab.json
|
|
* (updated continuously by the gstack browser extension).
|
|
* 2. Run $B tab, $B tabs, $B tab-each <command> to act on tabs. The
|
|
* tab-each helper fans a single command across every open tab and
|
|
* returns per-tab results as JSON.
|
|
*/
|
|
function buildTabAwarenessHint(stateDir: string): string {
|
|
const tabsFile = path.join(stateDir, 'tabs.json');
|
|
const activeFile = path.join(stateDir, 'active-tab.json');
|
|
return [
|
|
'You are running inside the gstack browser sidebar with live access to the user\'s browser tabs.',
|
|
'',
|
|
'Tab state files (kept fresh automatically by the extension):',
|
|
` ${tabsFile} — all open tabs (id, url, title, active, pinned)`,
|
|
` ${activeFile} — the currently active tab`,
|
|
'Read these any time the user asks about "tabs", "the current page", or anything multi-tab. Do NOT shell out to $B tabs just to learn what\'s open — read the file.',
|
|
'',
|
|
'Tab manipulation commands (via $B):',
|
|
' $B tab <id> — switch to a tab',
|
|
' $B newtab [url] — open a new tab',
|
|
' $B closetab [id] — close a tab (current if no id)',
|
|
' $B tab-each <command> — fan out a command across every tab; returns JSON results',
|
|
'',
|
|
'When the user asks for multi-tab work, prefer $B tab-each. Examples:',
|
|
' $B tab-each snapshot -i — grab a snapshot from every tab',
|
|
' $B tab-each text — pull clean text from every tab',
|
|
' $B tab-each title — list every tab\'s title',
|
|
'',
|
|
'You\'re in a real terminal with a real PTY — slash commands, /resume, ANSI colors all work as in a normal claude session.',
|
|
].join('\n');
|
|
}
|
|
|
|
/** Spawn claude in a PTY. Returns null if claude not on PATH. */
|
|
function spawnClaude(cols: number, rows: number, onData: (chunk: Buffer) => void) {
|
|
const claudePath = findClaude();
|
|
if (!claudePath) return null;
|
|
|
|
// Match phoenix env so claude knows which browse server to talk to and
|
|
// doesn't try to autostart its own. BROWSE_HEADED=1 keeps the existing
|
|
// headed-mode browser; BROWSE_NO_AUTOSTART prevents claude's gstack
|
|
// tooling from racing to spawn another server.
|
|
const env: Record<string, string> = {
|
|
...process.env as any,
|
|
BROWSE_PORT: String(BROWSE_SERVER_PORT),
|
|
BROWSE_STATE_FILE: STATE_FILE,
|
|
BROWSE_NO_AUTOSTART: '1',
|
|
BROWSE_HEADED: '1',
|
|
TERM: 'xterm-256color',
|
|
COLORTERM: 'truecolor',
|
|
};
|
|
|
|
// --append-system-prompt is the right injection surface (per `claude --help`):
|
|
// it gets appended to the model's system prompt, so claude treats this as
|
|
// contextual guidance, not a user message. Don't use a leading PTY write
|
|
// for this — that would show up as if the user typed the hint, polluting
|
|
// the visible transcript.
|
|
const stateDir = path.dirname(STATE_FILE);
|
|
const tabHint = buildTabAwarenessHint(stateDir);
|
|
|
|
const proc = (Bun as any).spawn([claudePath, '--append-system-prompt', tabHint], {
|
|
terminal: {
|
|
rows,
|
|
cols,
|
|
data(_terminal: any, chunk: Buffer) { onData(chunk); },
|
|
},
|
|
env,
|
|
});
|
|
return proc;
|
|
}
|
|
|
|
/** Cleanup a PTY session: SIGINT, then SIGKILL after 3s. */
|
|
function disposeSession(session: PtySession): void {
|
|
try { session.proc?.terminal?.close?.(); } catch {}
|
|
if (session.proc?.pid) {
|
|
try { session.proc.kill?.('SIGINT'); } catch {}
|
|
setTimeout(() => {
|
|
try {
|
|
if (session.proc && !session.proc.killed) session.proc.kill?.('SIGKILL');
|
|
} catch {}
|
|
}, 3000);
|
|
}
|
|
session.proc = null;
|
|
session.spawned = false;
|
|
}
|
|
|
|
/**
|
|
* Build the HTTP server. Two routes:
|
|
* POST /internal/grant — parent server pushes a fresh cookie token
|
|
* GET /ws — extension upgrades to WebSocket (PTY transport)
|
|
*
|
|
* Everything else returns 404. The listener binds 127.0.0.1 only.
|
|
*/
|
|
/**
|
|
* Validate a loopback /internal/* request. Returns null when the request
|
|
* is allowed; otherwise returns the Response to send back. Centralizes
|
|
* bearer auth + the v1.44 X-Browse-Gen generation check so adding a new
|
|
* /internal/* route is a one-liner.
|
|
*/
|
|
function checkInternalAuth(req: Request): Response | null {
|
|
const auth = req.headers.get('authorization');
|
|
if (auth !== `Bearer ${INTERNAL_TOKEN}`) {
|
|
return new Response('forbidden', { status: 403 });
|
|
}
|
|
const headerGen = req.headers.get('x-browse-gen');
|
|
if (headerGen && headerGen !== CURRENT_GEN) {
|
|
return new Response('stale generation', { status: 409 });
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Wrap a JSON-bodied /internal/* handler with the standard bearer-auth +
|
|
* generation-check + json-parse + error-response boilerplate. The handler
|
|
* `fn` is called with the parsed body; whatever it returns is JSON-stringified
|
|
* into a 200 Response, or the handler can return a Response directly to
|
|
* customize status / headers. Throwing from `fn` collapses to a 400 "bad".
|
|
*
|
|
* Centralizing the dance kills the copy-paste pattern of bearer + gen check
|
|
* + req.json().then(...).catch(...) that every /internal/* route needs.
|
|
* New routes become a single call to internalHandler.
|
|
*/
|
|
async function internalHandler<T>(
|
|
req: Request,
|
|
fn: (body: any) => T | Promise<T> | Response | Promise<Response>,
|
|
): Promise<Response> {
|
|
const denied = checkInternalAuth(req);
|
|
if (denied) return denied;
|
|
let body: any;
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return new Response('bad', { status: 400 });
|
|
}
|
|
try {
|
|
const result = await fn(body);
|
|
if (result instanceof Response) return result;
|
|
if (result === undefined || result === null) return new Response('ok');
|
|
return new Response(JSON.stringify(result), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
} catch {
|
|
return new Response('bad', { status: 400 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Spawn the claude PTY for a session if it hasn't been spawned yet.
|
|
* Used by both the legacy binary-frame spawn trigger and the v1.44 explicit
|
|
* `{type:"start"}` text-frame trigger. Idempotent on `session.spawned`.
|
|
*
|
|
* Returns true if claude is now running, false if spawn failed (e.g. claude
|
|
* binary not on PATH). On failure, the caller is expected to have already
|
|
* surfaced the error to the client (or will via the next frame).
|
|
*/
|
|
function maybeSpawnPty(ws: any, session: PtySession): boolean {
|
|
if (session.spawned) return true;
|
|
session.spawned = true;
|
|
let leftover = Buffer.alloc(0);
|
|
const proc = spawnClaude(session.cols, session.rows, (chunk) => {
|
|
const combined = Buffer.concat([leftover, Buffer.from(chunk)]);
|
|
// UTF-8 boundary detection (issue #1272). Look back at most 3 bytes
|
|
// for the start of an incomplete multibyte sequence and defer it.
|
|
let safeEnd = combined.length;
|
|
for (let i = combined.length - 1; i >= Math.max(0, combined.length - 3); i--) {
|
|
const b = combined[i];
|
|
if ((b & 0x80) === 0) { safeEnd = i + 1; break; }
|
|
if ((b & 0xC0) === 0x80) continue;
|
|
const expected = (b & 0xE0) === 0xC0 ? 2 : (b & 0xF0) === 0xE0 ? 3 : 4;
|
|
safeEnd = (combined.length - i >= expected) ? combined.length : i;
|
|
break;
|
|
}
|
|
const flush = combined.slice(0, safeEnd);
|
|
leftover = combined.slice(safeEnd);
|
|
if (flush.length) {
|
|
// Always record into the ring buffer (Commit 3) so re-attach can
|
|
// replay. session.liveWs is what changes across re-attaches — we
|
|
// close over `session`, not the original `ws`, so the write always
|
|
// goes to whichever ws is currently attached (or is skipped when
|
|
// detached and liveWs is null).
|
|
appendToRingBuffer(session, flush);
|
|
if (session.liveWs) {
|
|
try { session.liveWs.sendBinary(flush); } catch {}
|
|
}
|
|
}
|
|
});
|
|
if (!proc) {
|
|
try {
|
|
ws.send(JSON.stringify({
|
|
type: 'error',
|
|
code: 'CLAUDE_NOT_FOUND',
|
|
message: 'claude CLI not on PATH. Install: https://docs.anthropic.com/en/docs/claude-code',
|
|
}));
|
|
ws.close(4404, 'claude not found');
|
|
} catch {}
|
|
return false;
|
|
}
|
|
session.proc = proc;
|
|
proc.exited?.then?.(() => {
|
|
try { session.liveWs?.close(1000, 'pty exited'); } catch {}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function buildServer() {
|
|
return Bun.serve({
|
|
hostname: '127.0.0.1',
|
|
port: 0,
|
|
idleTimeout: 0, // PTY connections are long-lived; default idleTimeout would kill them
|
|
|
|
fetch(req, server) {
|
|
const url = new URL(req.url);
|
|
|
|
// /internal/grant — loopback-only handshake from parent server.
|
|
// v1.44+: accepts `{token, sessionId?}`. The sessionId binding lets
|
|
// the agent route re-attach attempts (same sessionId, fresh token)
|
|
// back to the same PtySession. Legacy callers passing just `{token}`
|
|
// still work — sessionId becomes null and re-attach is unavailable
|
|
// for that grant.
|
|
if (url.pathname === '/internal/grant' && req.method === 'POST') {
|
|
return internalHandler(req, (body) => {
|
|
if (typeof body?.token === 'string' && body.token.length > 16) {
|
|
const sid = typeof body?.sessionId === 'string' && body.sessionId.length > 0
|
|
? body.sessionId
|
|
: null;
|
|
validTokens.set(body.token, sid);
|
|
}
|
|
});
|
|
}
|
|
|
|
// /internal/revoke — drop a token (called on WS close or bootstrap reload)
|
|
if (url.pathname === '/internal/revoke' && req.method === 'POST') {
|
|
return internalHandler(req, (body) => {
|
|
if (typeof body?.token === 'string') validTokens.delete(body.token);
|
|
});
|
|
}
|
|
|
|
// /internal/restart — dispose the PtySession for a specific sessionId.
|
|
// Scoped to one caller (not enumerate-all). Server.ts /pty-restart
|
|
// posts here with the caller's sessionId; we kill ONLY that PTY,
|
|
// leaving any other live sidebar tabs untouched. Codex T2 of the
|
|
// eng review caught this gap — pre-spec the route would have
|
|
// disposed all sessions.
|
|
if (url.pathname === '/internal/restart' && req.method === 'POST') {
|
|
return internalHandler(req, (body) => {
|
|
const sid = typeof body?.sessionId === 'string' ? body.sessionId : null;
|
|
if (!sid) return { killed: 0 };
|
|
const session = sessionsById.get(sid);
|
|
if (!session) return { killed: 0 };
|
|
// Cancel any pending detach timer before disposal — otherwise it
|
|
// would fire later against an already-disposed session.
|
|
if (session.detachTimer) {
|
|
clearTimeout(session.detachTimer);
|
|
session.detachTimer = null;
|
|
}
|
|
disposeSession(session);
|
|
sessionsById.delete(sid);
|
|
return { killed: 1 };
|
|
});
|
|
}
|
|
|
|
// /internal/healthz — liveness probe used by the v1.44 watchdog.
|
|
// Returns this agent's pid + gen + active session count without
|
|
// touching claude binary lookup (which can fail for non-process
|
|
// reasons and isn't a useful liveness signal). GET — no body to parse,
|
|
// so it stays on the bare checkInternalAuth gate.
|
|
if (url.pathname === '/internal/healthz' && req.method === 'GET') {
|
|
const denied = checkInternalAuth(req);
|
|
if (denied) return denied;
|
|
return new Response(JSON.stringify({
|
|
pid: process.pid,
|
|
gen: CURRENT_GEN,
|
|
sessions: validTokens.size,
|
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// /claude-available — bootstrap card hits this when user clicks "I installed it".
|
|
if (url.pathname === '/claude-available' && req.method === 'GET') {
|
|
writeClaudeAvailable();
|
|
const found = findClaude();
|
|
return new Response(JSON.stringify({ available: !!found, path: found }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// /ws — WebSocket upgrade. CRITICAL gates:
|
|
// (1) Origin must be chrome-extension://<id>. Cross-site WS hijacking
|
|
// defense — required, not optional.
|
|
// (2) Token must be in validTokens. We accept the token via two
|
|
// transports for compatibility:
|
|
// - Sec-WebSocket-Protocol (preferred for browsers — the only
|
|
// auth header settable from the browser WebSocket API)
|
|
// - Cookie gstack_pty (works for non-browser callers and
|
|
// same-port browser callers; doesn't survive the cross-port
|
|
// jump from server.ts:34567 to the agent's random port
|
|
// when SameSite=Strict is set)
|
|
// Either path works; both verify against the same in-memory
|
|
// validTokens Set, populated by the parent server's
|
|
// authenticated /pty-session → /internal/grant chain.
|
|
if (url.pathname === '/ws') {
|
|
const origin = req.headers.get('origin') || '';
|
|
const isExtensionOrigin = origin.startsWith('chrome-extension://');
|
|
if (!isExtensionOrigin) {
|
|
return new Response('forbidden origin', { status: 403 });
|
|
}
|
|
if (EXTENSION_ID && origin !== `chrome-extension://${EXTENSION_ID}`) {
|
|
return new Response('forbidden origin', { status: 403 });
|
|
}
|
|
|
|
// Try Sec-WebSocket-Protocol first. Format: a single token, possibly
|
|
// with a `gstack-pty.` prefix (which we strip). Browsers send a
|
|
// comma-separated list when multiple were requested; we pick the
|
|
// first that matches a known token.
|
|
const protoHeader = req.headers.get('sec-websocket-protocol') || '';
|
|
let token: string | null = null;
|
|
let acceptedProtocol: string | null = null;
|
|
for (const raw of protoHeader.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
const candidate = raw.startsWith('gstack-pty.') ? raw.slice('gstack-pty.'.length) : raw;
|
|
if (validTokens.has(candidate)) {
|
|
token = candidate;
|
|
acceptedProtocol = raw;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Fallback: Cookie gstack_pty (legacy / non-browser callers).
|
|
if (!token) {
|
|
const cookieHeader = req.headers.get('cookie') || '';
|
|
for (const part of cookieHeader.split(';')) {
|
|
const [name, ...rest] = part.trim().split('=');
|
|
if (name === 'gstack_pty') {
|
|
const candidate = rest.join('=') || null;
|
|
if (candidate && validTokens.has(candidate)) {
|
|
token = candidate;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!token) {
|
|
return new Response('unauthorized', { status: 401 });
|
|
}
|
|
|
|
// v1.44+: surface the token's sessionId binding to the upgraded ws.
|
|
// open() reads it via ws.data and registers the session in
|
|
// sessionsById so /internal/restart and (Commit 3) re-attach
|
|
// lookups can find it.
|
|
const sessionId = validTokens.get(token) ?? null;
|
|
const upgraded = server.upgrade(req, {
|
|
data: { cookie: token, sessionId },
|
|
// Echo the protocol back so the browser accepts the upgrade.
|
|
// Required when the client sends Sec-WebSocket-Protocol — the
|
|
// server MUST select one of the offered protocols, otherwise
|
|
// the browser closes the connection immediately.
|
|
...(acceptedProtocol ? { headers: { 'Sec-WebSocket-Protocol': acceptedProtocol } } : {}),
|
|
});
|
|
return upgraded ? undefined : new Response('upgrade failed', { status: 500 });
|
|
}
|
|
|
|
return new Response('not found', { status: 404 });
|
|
},
|
|
|
|
websocket: {
|
|
/**
|
|
* Spawn the claude PTY for `session` if it hasn't been spawned yet.
|
|
* Called from both message paths: the legacy binary-frame trigger
|
|
* (any keystroke) AND the v1.44 explicit `{type:"start"}` trigger
|
|
* (forceRestart sends this on every fresh WS to get an eager prompt
|
|
* without requiring the user to type). Idempotent — a second call
|
|
* after `spawned: true` is a no-op.
|
|
*/
|
|
open(ws) {
|
|
const sessionId = (ws.data as any)?.sessionId ?? null;
|
|
const cookie = (ws.data as any)?.cookie || '';
|
|
|
|
// Commit 3 re-attach: if this sessionId already has a detached
|
|
// PtySession in sessionsById, REPLACE its liveWs ref and replay
|
|
// the ring buffer. The PTY process is unchanged — claude keeps
|
|
// running through the wifi blip / panel-suspend cycle.
|
|
if (sessionId) {
|
|
const existing = sessionsById.get(sessionId);
|
|
if (existing) {
|
|
if (existing.detachTimer) {
|
|
clearTimeout(existing.detachTimer);
|
|
existing.detachTimer = null;
|
|
}
|
|
existing.detached = false;
|
|
existing.liveWs = ws;
|
|
existing.cookie = cookie;
|
|
// Re-bind the WS-keyed map so resize/close/message handlers
|
|
// can still find this session via the new ws.
|
|
sessions.set(ws, existing);
|
|
// Restart keepalive on the new ws.
|
|
if (existing.pingInterval) clearInterval(existing.pingInterval);
|
|
existing.pingInterval = setInterval(() => {
|
|
try { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } catch {}
|
|
}, KEEPALIVE_INTERVAL_MS);
|
|
// Tell the client to prep its xterm (write RIS) before the
|
|
// replay binary arrives. Order matters — the binary frame
|
|
// immediately after this text frame IS the replay.
|
|
try { ws.send(JSON.stringify({ type: 'reattach-begin', sessionId })); } catch {}
|
|
try { ws.sendBinary(buildReplayPayload(existing)); } catch {}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const session: PtySession = {
|
|
proc: null,
|
|
cols: 80,
|
|
rows: 24,
|
|
cookie,
|
|
liveWs: ws,
|
|
sessionId,
|
|
spawned: false,
|
|
pingInterval: null,
|
|
ringBuffer: [],
|
|
ringBufferBytes: 0,
|
|
altScreenActive: false,
|
|
detached: false,
|
|
detachTimer: null,
|
|
};
|
|
session.pingInterval = setInterval(() => {
|
|
try {
|
|
ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
|
|
} catch {
|
|
// ws likely closed mid-tick; close handler clears the interval.
|
|
}
|
|
}, KEEPALIVE_INTERVAL_MS);
|
|
sessions.set(ws, session);
|
|
// Index by sessionId for /internal/restart + Commit 3 re-attach.
|
|
if (sessionId) sessionsById.set(sessionId, session);
|
|
},
|
|
|
|
message(ws, raw) {
|
|
let session = sessions.get(ws);
|
|
if (!session) {
|
|
// Fallback for any path where open() didn't fire (shouldn't happen
|
|
// in Bun.serve but keeps the spawn path safe). No keepalive on
|
|
// this branch — open() is the supported entry point.
|
|
session = {
|
|
proc: null,
|
|
cols: 80,
|
|
rows: 24,
|
|
cookie: (ws.data as any)?.cookie || '',
|
|
liveWs: ws,
|
|
sessionId: (ws.data as any)?.sessionId ?? null,
|
|
spawned: false,
|
|
pingInterval: null,
|
|
ringBuffer: [],
|
|
ringBufferBytes: 0,
|
|
altScreenActive: false,
|
|
detached: false,
|
|
detachTimer: null,
|
|
};
|
|
sessions.set(ws, session);
|
|
if (session.sessionId) sessionsById.set(session.sessionId, session);
|
|
}
|
|
|
|
// Text frames are control messages: {type: "resize", cols, rows},
|
|
// {type: "tabSwitch", tabId, url, title}, {type: "tabState", ...},
|
|
// or v1.44 keepalive frames: {type: "pong", ts}, {type: "keepalive"}.
|
|
// Binary frames are raw input bytes destined for the PTY stdin.
|
|
if (typeof raw === 'string') {
|
|
let msg: any;
|
|
try { msg = JSON.parse(raw); } catch { return; }
|
|
if (msg?.type === 'resize') {
|
|
const cols = Math.max(2, Math.floor(Number(msg.cols) || 80));
|
|
const rows = Math.max(2, Math.floor(Number(msg.rows) || 24));
|
|
session.cols = cols;
|
|
session.rows = rows;
|
|
try { session.proc?.terminal?.resize?.(cols, rows); } catch {}
|
|
return;
|
|
}
|
|
if (msg?.type === 'tabSwitch') {
|
|
handleTabSwitch(msg);
|
|
return;
|
|
}
|
|
if (msg?.type === 'tabState') {
|
|
handleTabState(msg);
|
|
return;
|
|
}
|
|
if (msg?.type === 'pong' || msg?.type === 'keepalive' || msg?.type === 'ping') {
|
|
// Keepalive frames — accepted and silently dropped. The mere
|
|
// fact that the WS carried this frame is the liveness signal;
|
|
// there's no application-level state to update at this layer.
|
|
// `ping` is acknowledged here too in case the client (or a
|
|
// future agent peer) mirrors our server-side ping shape.
|
|
return;
|
|
}
|
|
if (msg?.type === 'start') {
|
|
// v1.44 explicit spawn trigger. forceRestart sends this
|
|
// immediately on every fresh WS so claude boots without the
|
|
// user having to type a keystroke (pre-v1.44, the lazy-binary
|
|
// spawn made restart look stuck until the user typed). No-op
|
|
// if already spawned.
|
|
maybeSpawnPty(ws, session);
|
|
return;
|
|
}
|
|
// Unknown text frame — ignore.
|
|
return;
|
|
}
|
|
|
|
// Binary input. Lazy-spawn claude on the first byte if `start`
|
|
// wasn't sent first. Both paths land in the same maybeSpawnPty
|
|
// helper for behavior parity.
|
|
if (!session.spawned) {
|
|
if (!maybeSpawnPty(ws, session)) return;
|
|
}
|
|
try {
|
|
// raw is a Uint8Array; Bun.Terminal.write accepts string|Buffer.
|
|
// Convert to Buffer for safety.
|
|
session.proc?.terminal?.write?.(Buffer.from(raw as Uint8Array));
|
|
} catch (err) {
|
|
console.error('[terminal-agent] terminal.write failed:', err);
|
|
}
|
|
},
|
|
|
|
close(ws, code, _reason) {
|
|
const session = sessions.get(ws);
|
|
if (!session) return;
|
|
// Always drop the WS-keyed map entry and the per-attach
|
|
// attachToken — the attach grant was single-use.
|
|
sessions.delete(ws);
|
|
if (session.cookie) validTokens.delete(session.cookie);
|
|
// Keepalive lives with the WS — every attach starts a fresh one.
|
|
if (session.pingInterval) {
|
|
clearInterval(session.pingInterval);
|
|
session.pingInterval = null;
|
|
}
|
|
|
|
// Commit 3 detach state machine. If the close was intentional
|
|
// (code 4001 = restart, 4404 = no-claude error), dispose
|
|
// immediately — there's no value in keeping the PTY alive.
|
|
// Otherwise enter the detach window: claude keeps running, the
|
|
// ring buffer keeps accumulating, and a re-attach with the same
|
|
// sessionId within DETACH_WINDOW_MS picks back up. If the timer
|
|
// fires without a re-attach, the session is disposed normally.
|
|
//
|
|
// Sessions without a sessionId (legacy single-shot grants) can't
|
|
// re-attach by definition — fall through to immediate dispose.
|
|
const intentional = code === 4001 || code === 4404 || code === 1000;
|
|
if (intentional || !session.sessionId) {
|
|
disposeSession(session);
|
|
if (session.sessionId) sessionsById.delete(session.sessionId);
|
|
return;
|
|
}
|
|
|
|
// Mark detached and start the disposal timer. The session stays
|
|
// in sessionsById so the next /ws upgrade with the same
|
|
// sessionId can find and reattach to it.
|
|
session.detached = true;
|
|
session.liveWs = null;
|
|
session.detachTimer = setTimeout(() => {
|
|
if (!session.detached) return; // re-attached in the meantime
|
|
disposeSession(session);
|
|
if (session.sessionId) sessionsById.delete(session.sessionId);
|
|
}, DETACH_WINDOW_MS);
|
|
// setTimeout returns a Bun Timer; unref so the detach window
|
|
// doesn't keep the process alive past natural shutdown.
|
|
(session.detachTimer as any)?.unref?.();
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Tab-switch helper: write the active tab to a state file (claude reads it)
|
|
* and notify the parent server so its activeTabId stays synced. Skips
|
|
* chrome:// and chrome-extension:// internal pages.
|
|
*/
|
|
/**
|
|
* Live tab snapshot. Writes <stateDir>/tabs.json (full list) and updates
|
|
* <stateDir>/active-tab.json (current active). claude can read these any
|
|
* time without invoking $B tabs — saves a round-trip when the model just
|
|
* needs to check the landscape before deciding what to do.
|
|
*/
|
|
function handleTabState(msg: {
|
|
active?: { tabId?: number; url?: string; title?: string } | null;
|
|
tabs?: Array<{ tabId?: number; url?: string; title?: string; active?: boolean; windowId?: number; pinned?: boolean; audible?: boolean }>;
|
|
reason?: string;
|
|
}): void {
|
|
const stateDir = path.dirname(STATE_FILE);
|
|
try { mkdirSecure(stateDir); } catch {}
|
|
|
|
// tabs.json — full list
|
|
if (Array.isArray(msg.tabs)) {
|
|
const payload = {
|
|
updatedAt: new Date().toISOString(),
|
|
reason: msg.reason || 'unknown',
|
|
tabs: msg.tabs.map(t => ({
|
|
tabId: t.tabId ?? null,
|
|
url: t.url || '',
|
|
title: t.title || '',
|
|
active: !!t.active,
|
|
windowId: t.windowId ?? null,
|
|
pinned: !!t.pinned,
|
|
audible: !!t.audible,
|
|
})),
|
|
};
|
|
const target = path.join(stateDir, 'tabs.json');
|
|
const tmp = path.join(stateDir, `.tmp-tabs-${process.pid}`);
|
|
try {
|
|
writeSecureFile(tmp, JSON.stringify(payload, null, 2));
|
|
fs.renameSync(tmp, target);
|
|
} catch {
|
|
safeUnlink(tmp);
|
|
}
|
|
}
|
|
|
|
// active-tab.json — single active tab. Skip chrome-internal pages so
|
|
// claude doesn't see chrome:// or chrome-extension:// URLs as
|
|
// "current target."
|
|
const active = msg.active;
|
|
if (active && active.url && !active.url.startsWith('chrome://') && !active.url.startsWith('chrome-extension://')) {
|
|
const ctxFile = path.join(stateDir, 'active-tab.json');
|
|
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
|
|
try {
|
|
writeSecureFile(tmp, JSON.stringify({
|
|
tabId: active.tabId ?? null,
|
|
url: active.url,
|
|
title: active.title ?? '',
|
|
}));
|
|
fs.renameSync(tmp, ctxFile);
|
|
} catch {
|
|
safeUnlink(tmp);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }): void {
|
|
const url = msg.url || '';
|
|
if (!url || url.startsWith('chrome://') || url.startsWith('chrome-extension://')) return;
|
|
|
|
const stateDir = path.dirname(STATE_FILE);
|
|
const ctxFile = path.join(stateDir, 'active-tab.json');
|
|
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
|
|
try {
|
|
writeSecureFile(tmp, JSON.stringify({
|
|
tabId: msg.tabId ?? null,
|
|
url,
|
|
title: msg.title ?? '',
|
|
}));
|
|
fs.renameSync(tmp, ctxFile);
|
|
} catch {
|
|
safeUnlink(tmp);
|
|
}
|
|
|
|
// Best-effort sync to parent server so its activeTabId tracking matches.
|
|
// No await; this is fire-and-forget.
|
|
if (BROWSE_SERVER_PORT > 0) {
|
|
fetch(`http://127.0.0.1:${BROWSE_SERVER_PORT}/command`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${readBrowseToken()}`,
|
|
},
|
|
body: JSON.stringify({
|
|
command: 'tab',
|
|
args: [String(msg.tabId ?? ''), '--no-focus'],
|
|
}),
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
function readBrowseToken(): string {
|
|
try {
|
|
const raw = fs.readFileSync(STATE_FILE, 'utf-8');
|
|
const j = JSON.parse(raw);
|
|
return j.token || '';
|
|
} catch { return ''; }
|
|
}
|
|
|
|
// Boot.
|
|
function main() {
|
|
writeClaudeAvailable();
|
|
const server = buildServer();
|
|
const port = (server as any).port || (server as any).address?.port;
|
|
if (!port) {
|
|
console.error('[terminal-agent] failed to bind: no port');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Write port file atomically so the parent server can pick it up.
|
|
const dir = path.dirname(PORT_FILE);
|
|
try { mkdirSecure(dir); } catch {}
|
|
const tmp = `${PORT_FILE}.tmp-${process.pid}`;
|
|
writeSecureFile(tmp, String(port));
|
|
fs.renameSync(tmp, PORT_FILE);
|
|
|
|
// Write identity-based agent record (pid + per-boot gen). Replaces the
|
|
// v1.43- `pkill -f terminal-agent\.ts` regex teardown that could kill
|
|
// sibling gstack sessions. Callers (cli.ts spawn site, server.ts
|
|
// shutdown, the v1.44 watchdog) now route through killAgentByRecord in
|
|
// terminal-agent-control.ts.
|
|
writeAgentRecord(dir, { pid: process.pid, gen: CURRENT_GEN, startedAt: Date.now() });
|
|
|
|
// Hand the parent the internal token so it can call /internal/grant.
|
|
// Parent learns INTERNAL_TOKEN via env (TERMINAL_AGENT_INTERNAL_TOKEN below).
|
|
// We just print it on stdout for the supervising process to pick up if it's
|
|
// not already in env. Defense against env races at spawn time.
|
|
console.log(`[terminal-agent] listening on 127.0.0.1:${port} pid=${process.pid} gen=${CURRENT_GEN}`);
|
|
|
|
// Cleanup port file + agent record on exit.
|
|
const cleanup = () => {
|
|
safeUnlink(PORT_FILE);
|
|
clearAgentRecord(dir);
|
|
process.exit(0);
|
|
};
|
|
process.on('SIGTERM', cleanup);
|
|
process.on('SIGINT', cleanup);
|
|
}
|
|
|
|
// Export the internal token so cli.ts can pass the SAME value to the parent
|
|
// server via env. Parent reads BROWSE_TERMINAL_INTERNAL_TOKEN and uses it
|
|
// for /internal/grant calls.
|
|
//
|
|
// In practice, the agent generates INTERNAL_TOKEN once at boot and writes it
|
|
// to a state file the parent reads. This avoids env-passing races. See main().
|
|
const INTERNAL_TOKEN_FILE = path.join(path.dirname(STATE_FILE), 'terminal-internal-token');
|
|
try {
|
|
mkdirSecure(path.dirname(INTERNAL_TOKEN_FILE));
|
|
writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN);
|
|
} catch {}
|
|
|
|
main();
|