* 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>
48 KiB
gstack development
Commands
bun install # install dependencies
bun test # run free tests (browse + snapshot + skill validation)
bun run test:evals # run paid evals: LLM judge + E2E (diff-based, ~$4/run max)
bun run test:evals:all # run ALL paid evals regardless of diff
bun run test:gate # run gate-tier tests only (CI default, blocks merge)
bun run test:periodic # run periodic-tier tests only (weekly cron / manual)
bun run test:e2e # run E2E tests only (diff-based, ~$3.85/run max)
bun run test:e2e:all # run ALL E2E tests regardless of diff
bun run eval:select # show which tests would run based on current diff
bun run dev <cmd> # run CLI in dev mode, e.g. bun run dev goto https://example.com
bun run build # gen docs + compile binaries
bun run gen:skill-docs # regenerate SKILL.md files from templates
bun run skill:check # health dashboard for all skills
bun run dev:skill # watch mode: auto-regen + validate on change
bun run eval:list # list all eval runs from ~/.gstack-dev/evals/
bun run eval:compare # compare two eval runs (auto-picks most recent)
bun run eval:summary # aggregate stats across all eval runs
bun run slop # full slop-scan report (all files)
bun run slop:diff # slop findings in files changed on this branch only
test:evals requires ANTHROPIC_API_KEY. Codex E2E tests (test/codex-e2e.test.ts)
use Codex's own auth from ~/.codex/ config — no OPENAI_API_KEY env var needed.
Env keys in Conductor workspaces. The GSTACK_* env-shim (v1.39.2.0+,
lib/conductor-env-shim.ts) promotes GSTACK_ANTHROPIC_API_KEY /
GSTACK_OPENAI_API_KEY to their canonical names inside gstack's TS binaries.
Tests run through gstack entrypoints inherit this promotion automatically.
Don't echo the key value to stdout, logs, or shell history. When passing to a
test's Agent SDK, do NOT pass env: {...} to runAgentSdkTest — the SDK's
auth pipeline doesn't pick up the key the same way when env is supplied as an
object (confirmed failure mode). Mutate process.env.ANTHROPIC_API_KEY
ambiently before the call and restore in finally.
E2E tests stream progress in real-time (tool-by-tool via --output-format stream-json --verbose). Results are persisted to ~/.gstack-dev/evals/ with auto-comparison
against the previous run.
Diff-based test selection: test:evals and test:e2e auto-select tests based
on git diff against the base branch. Each test declares its file dependencies in
test/helpers/touchfiles.ts. Changes to global touchfiles (session-runner, eval-store,
touchfiles.ts itself) trigger all tests. Use EVALS_ALL=1 or the :all script
variants to force all tests. Run eval:select to preview which tests would run.
Two-tier system: Tests are classified as gate or periodic in E2E_TIERS
(in test/helpers/touchfiles.ts). CI runs only gate tests (EVALS_TIER=gate);
periodic tests run weekly via cron or manually. Use EVALS_TIER=gate or
EVALS_TIER=periodic to filter. When adding new E2E tests, classify them:
- Safety guardrail or deterministic functional test? ->
gate - Quality benchmark, Opus model test, or non-deterministic? ->
periodic - Requires external service (Codex, Gemini)? ->
periodic
Testing
bun test # run before every commit — free, <2s
bun run test:evals # run before shipping — paid, diff-based (~$4/run max)
bun test runs skill validation, gen-skill-docs quality checks, and browse
integration tests. bun run test:evals runs LLM-judge quality evals and E2E
tests via claude -p. Both must pass before creating a PR.
Project structure
gstack/
├── browse/ # Headless browser CLI (Playwright)
│ ├── src/ # CLI + server + commands
│ │ ├── commands.ts # Command registry (single source of truth)
│ │ └── snapshot.ts # SNAPSHOT_FLAGS metadata array
│ ├── test/ # Integration tests + fixtures
│ └── dist/ # Compiled binary
├── hosts/ # Typed host configs (one per AI agent)
│ ├── claude.ts # Primary host config
│ ├── codex.ts, factory.ts, kiro.ts # Existing hosts
│ ├── opencode.ts, slate.ts, cursor.ts, openclaw.ts # IDE hosts
│ ├── hermes.ts, gbrain.ts # Agent runtime hosts
│ └── index.ts # Registry: exports all, derives Host type
├── scripts/ # Build + DX tooling
│ ├── gen-skill-docs.ts # Template → SKILL.md generator (config-driven)
│ ├── host-config.ts # HostConfig interface + validator
│ ├── host-config-export.ts # Shell bridge for setup script
│ ├── host-adapters/ # Host-specific adapters (OpenClaw tool mapping)
│ ├── resolvers/ # Template resolver modules (preamble, design, review, gbrain, etc.)
│ ├── skill-check.ts # Health dashboard
│ └── dev-skill.ts # Watch mode
├── test/ # Skill validation + eval tests
│ ├── helpers/ # skill-parser.ts, session-runner.ts, llm-judge.ts, eval-store.ts
│ ├── fixtures/ # Ground truth JSON, planted-bug fixtures, eval baselines
│ ├── skill-validation.test.ts # Tier 1: static validation (free, <1s)
│ ├── gen-skill-docs.test.ts # Tier 1: generator quality (free, <1s)
│ ├── skill-llm-eval.test.ts # Tier 3: LLM-as-judge (~$0.15/run)
│ └── skill-e2e-*.test.ts # Tier 2: E2E via claude -p (~$3.85/run, split by category)
├── qa-only/ # /qa-only skill (report-only QA, no fixes)
├── plan-design-review/ # /plan-design-review skill (report-only design audit)
├── design-review/ # /design-review skill (design audit + fix loop)
├── ship/ # Ship workflow skill
├── review/ # PR review skill
├── plan-ceo-review/ # /plan-ceo-review skill
├── plan-eng-review/ # /plan-eng-review skill
├── autoplan/ # /autoplan skill (auto-review pipeline: CEO → design → eng)
├── benchmark/ # /benchmark skill (performance regression detection)
├── canary/ # /canary skill (post-deploy monitoring loop)
├── codex/ # /codex skill (multi-AI second opinion via OpenAI Codex CLI)
├── land-and-deploy/ # /land-and-deploy skill (merge → deploy → canary verify)
├── office-hours/ # /office-hours skill (YC Office Hours — startup diagnostic + builder brainstorm)
├── investigate/ # /investigate skill (systematic root-cause debugging)
├── retro/ # Retrospective skill (includes /retro global cross-project mode)
├── bin/ # CLI utilities (gstack-repo-mode, gstack-slug, gstack-config, etc.)
├── document-release/ # /document-release skill (post-ship doc updates + Diataxis coverage map)
├── document-generate/ # /document-generate skill (Diataxis doc generator: tutorial/how-to/reference/explanation)
├── cso/ # /cso skill (OWASP Top 10 + STRIDE security audit)
├── design-consultation/ # /design-consultation skill (design system from scratch)
├── design-shotgun/ # /design-shotgun skill (visual design exploration)
├── open-gstack-browser/ # /open-gstack-browser skill (launch GStack Browser)
├── connect-chrome/ # symlink → open-gstack-browser (backwards compat)
├── design/ # Design binary CLI (GPT Image API)
│ ├── src/ # CLI + commands (generate, variants, compare, serve, etc.)
│ ├── test/ # Integration tests
│ └── dist/ # Compiled binary
├── extension/ # Chrome extension (side panel + activity feed + CSS inspector)
├── lib/ # Shared libraries (worktree.ts)
├── docs/designs/ # Design documents
├── setup-deploy/ # /setup-deploy skill (one-time deploy config)
├── .github/ # CI workflows + Docker image
│ ├── workflows/ # evals.yml (E2E on Ubicloud), skill-docs.yml, actionlint.yml
│ └── docker/ # Dockerfile.ci (pre-baked toolchain + Playwright/Chromium)
├── contrib/ # Contributor-only tools (never installed for users)
│ └── add-host/ # /gstack-contrib-add-host skill
├── setup # One-time setup: build binary + symlink skills
├── SKILL.md # Generated from SKILL.md.tmpl (don't edit directly)
├── SKILL.md.tmpl # Template: edit this, run gen:skill-docs
├── ETHOS.md # Builder philosophy (Boil the Lake, Search Before Building)
└── package.json # Build scripts for browse
SKILL.md workflow
SKILL.md files are generated from .tmpl templates. To update docs:
- Edit the
.tmplfile (e.g.SKILL.md.tmplorbrowse/SKILL.md.tmpl) - Run
bun run gen:skill-docs(orbun run buildwhich does it automatically) - Commit both the
.tmpland generated.mdfiles
To add a new browse command: add it to browse/src/commands.ts and rebuild.
To add a snapshot flag: add it to SNAPSHOT_FLAGS in browse/src/snapshot.ts and rebuild.
Token ceiling: Generated SKILL.md files trip a warning above 160KB (~40K tokens).
This is a "watch for feature bloat" guardrail, not a hard gate. Modern flagship
models have 200K-1M context windows, so 40K is 4-20% of window, and prompt caching
makes the marginal cost of larger skills small. The ceiling exists to catch runaway
preamble/resolver growth, not to force compression on carefully-tuned big skills
(ship, plan-ceo-review, office-hours legitimately pack 25-35K tokens of
behavior). If you blow past 40K, the right fix is usually: (1) look at WHAT grew,
(2) if one resolver added 10K+ in a single PR, question whether it belongs inline
or as a reference doc, (3) only compress carefully-tuned prose as a last resort —
cuts to the coverage audit, review army, or voice directive have real quality cost.
Merge conflicts on SKILL.md files: NEVER resolve conflicts on generated SKILL.md
files by accepting either side. Instead: (1) resolve conflicts on the .tmpl templates
and scripts/gen-skill-docs.ts (the sources of truth), (2) run bun run gen:skill-docs
to regenerate all SKILL.md files, (3) stage the regenerated files. Accepting one side's
generated output silently drops the other side's template changes.
Platform-agnostic design
Skills must NEVER hardcode framework-specific commands, file patterns, or directory structures. Instead:
- Read CLAUDE.md for project-specific config (test commands, eval commands, etc.)
- If missing, AskUserQuestion — let the user tell you or let gstack search the repo
- Persist the answer to CLAUDE.md so we never have to ask again
This applies to test commands, eval commands, deploy commands, and any other project-specific behavior. The project owns its config; gstack reads it.
Writing SKILL templates
SKILL.md.tmpl files are prompt templates read by Claude, not bash scripts. Each bash code block runs in a separate shell — variables do not persist between blocks.
Rules:
- Use natural language for logic and state. Don't use shell variables to pass state between code blocks. Instead, tell Claude what to remember and reference it in prose (e.g., "the base branch detected in Step 0").
- Don't hardcode branch names. Detect
main/master/etc dynamically viagh pr vieworgh repo view. Use{{BASE_BRANCH_DETECT}}for PR-targeting skills. Use "the base branch" in prose,<base>in code block placeholders. - Keep bash blocks self-contained. Each code block should work independently. If a block needs context from a previous step, restate it in the prose above.
- Express conditionals as English. Instead of nested
if/elif/elsein bash, write numbered decision steps: "1. If X, do Y. 2. Otherwise, do Z."
Writing style (V1)
Default output from every tier-≥2 skill follows the Writing Style section in
scripts/resolvers/preamble.ts: jargon glossed on first use (curated list in
scripts/jargon-list.json, baked at gen-skill-docs time), questions framed in
outcome terms ("what breaks for your users if...") not implementation terms,
short sentences, decisions close with user impact. Power users who want the
tighter V0 prose set gstack-config set explain_level terse (binary switch,
no middle mode). See docs/designs/PLAN_TUNING_V1.md for the full design
rationale. The review pacing overhaul that originally tried to ride alongside
writing-style was extracted to V1.1 — see docs/designs/PACING_UPDATES_V0.md.
Browser interaction
When you need to interact with a browser (QA, dogfooding, cookie setup), use the
/browse skill or run the browse binary directly via $B <command>. NEVER use
mcp__claude-in-chrome__* tools — they are slow, unreliable, and not what this
project uses.
Sidebar architecture: Before modifying sidepanel.js, background.js,
content.js, terminal-agent.ts, or sidebar-related server endpoints,
read docs/designs/SIDEBAR_MESSAGE_FLOW.md. The sidebar has one primary
surface — the Terminal pane (interactive claude PTY) — with
Activity / Refs / Inspector as debug overlays behind the footer's
debug toggle. The chat queue path was ripped once the PTY proved out;
sidebar-agent.ts and the /sidebar-command / /sidebar-chat /
/sidebar-agent/event endpoints are gone. The doc covers the WS auth
flow, dual-token model, and threat-model boundary — silent failures
here usually trace to not understanding the cross-component flow.
Embedder terminal-agent ownership (v1.42.1.0+, identity-based kill v1.44.0.0+).
buildFetchHandler in browse/src/server.ts accepts ServerConfig.ownsTerminalAgent?: boolean (default true). When true, factory shutdown runs the full teardown:
identity-based kill via killAgentByRecord(readAgentRecord(stateDir)) from
browse/src/terminal-agent-control.ts plus safeUnlinkQuiet on
<stateDir>/terminal-port, <stateDir>/terminal-internal-token, and
<stateDir>/terminal-agent-pid (the per-boot agent record introduced in v1.44).
Embedders (e.g. the gbrowser phoenix overlay) that pre-launch their own PTY
server must pass false so their discovery files survive gstack teardown cycles.
The flag is the third caller-owned teardown gate in ServerConfig (alongside
xvfb? and proxyBridge?); polarity is inverted (explicit bool vs presence) and
documented in the field's JSDoc. CLI start() always passes true explicitly —
the static-grep test in browse/test/server-embedder-terminal-port.test.ts fails
CI if a refactor drops it. Pre-v1.44 used pkill -f terminal-agent\.ts (regex
match) which would kill sibling gstack sessions on the same host; the new
browse/test/terminal-agent-pid-identity.test.ts static-grep tripwire fails CI
if any source file re-introduces pkill ... terminal-agent or spawnSync('pkill', ...).
WebSocket auth uses Sec-WebSocket-Protocol, not cookies. Browsers
can't set Authorization on a WebSocket upgrade, but they CAN set
Sec-WebSocket-Protocol via new WebSocket(url, [token]). The agent
reads it, validates against validTokens, and MUST echo the protocol
back in the upgrade response — without the echo, Chromium closes the
connection immediately. Set-Cookie: gstack_pty=... is kept as a
fallback for non-browser callers (the cross-port SameSite=Strict
cookie path doesn't survive from a chrome-extension origin).
Cross-pane PTY injection. The toolbar's Cleanup button and the
Inspector's "Send to Code" action both pipe text into the live claude
PTY via window.gstackInjectToTerminal(text), exposed by
sidepanel-terminal.js. No /sidebar-command POST — the live REPL is
the only execution surface in the sidebar now.
/health MUST NOT surface any shell-grant token. It already leaks
AUTH_TOKEN to localhost callers in headed mode (a v1.1+ TODO). Don't
make that worse by adding the PTY session token there. PTY auth flows
through POST /pty-session only.
Transport-layer security (v1.6.0.0+). When pair-agent starts an ngrok tunnel,
the daemon binds two HTTP listeners: a local listener (127.0.0.1, full command
surface, never forwarded) and a tunnel listener (locked allowlist: /connect,
/command with a scoped token + 26-command browser-driving allowlist,
/sidebar-chat). ngrok forwards only the tunnel port. Root tokens over the tunnel
return 403. SSE endpoints use a 30-minute HttpOnly gstack_sse cookie minted via
POST /sse-session (never valid against /command). Tunnel-surface rejections go
to ~/.gstack/security/attempts.jsonl via tunnel-denial-log.ts. Before editing
server.ts, sse-session-cookie.ts, or tunnel-denial-log.ts, read
ARCHITECTURE.md —
the module boundary (no imports from token-registry.ts into sse-session-cookie.ts)
is load-bearing for scope isolation.
Unicode sanitization at server egress (v1.38.0.0+). Every server egress that
ships page-content-derived strings MUST go through JSON.stringify(payload, sanitizeReplacer) for object payloads or sanitizeLoneSurrogates(body) for text
bodies. Lone UTF-16 surrogate halves from CDP page content otherwise reach the
Anthropic API as \uD800-style escapes and trigger a 400. Wired at four egress
points today: handleCommandInternal (HTTP + batch via a sanitizing wrapper around
handleCommandInternalImpl) and both SSE producers (/activity/stream,
/inspector/events). Post-stringify regex is a no-op — JSON.stringify has
already escaped the surrogate before regex could match, so the replacer must run
inside the encoding pipeline. Before adding a new SSE/WebSocket writer or HTTP
response in server.ts, read
ARCHITECTURE.md.
browse/test/server-sanitize-surrogates.test.ts pins the wiring with invariant
tests, so bypasses fail CI.
Setup symlink hardening (v1.38.0.0+). Every link site in setup MUST route
through the _link_or_copy SRC DST helper near the IS_WINDOWS detection. On
Windows without Developer Mode, plain ln -snf produces frozen file copies that
don't refresh on git pull — silent staleness across every host adapter. The
helper preserves ln -snf on Unix and switches to cp -R / cp -f on Windows.
test/setup-windows-fallback.test.ts enforces a static invariant: a single raw
ln call outside the helper body fails CI. Windows users get a one-line note
from _print_windows_copy_note_once reminding them to re-run ./setup after
every git pull.
Sidebar security stack (layered defense against prompt injection):
| Layer | Module | Lives in |
|---|---|---|
| L1-L3 | content-security.ts |
both server and agent — datamarking, hidden element strip, ARIA regex, URL blocklist, envelope wrapping |
| L4 | security-classifier.ts (TestSavantAI ONNX) |
sidebar-agent only |
| L4b | security-classifier.ts (Claude Haiku transcript) |
sidebar-agent only |
| L5 | security.ts (canary) |
both — inject in compiled, check in agent |
| L6 | security.ts (combineVerdict ensemble) |
both |
Critical constraint: security-classifier.ts CANNOT be imported from the
compiled browse binary. @huggingface/transformers v4 requires onnxruntime-node
which fails to dlopen from Bun compile's temp extract dir. Only security.ts
(pure-string operations — canary, verdict combiner, attack log, status) is safe
for server.ts. See ~/.gstack/projects/garrytan-gstack/ceo-plans/2026-04-19-prompt-injection-guard.md
§"Pre-Impl Gate 1 Outcome" for full architectural decision.
Thresholds (in security.ts):
BLOCK: 0.85— single-layer score that would cause BLOCK if cross-confirmedWARN: 0.75— cross-confirm threshold. When L4 AND L4b both >= 0.75 → BLOCKLOG_ONLY: 0.40— gates transcript classifier (skip Haiku when all layers < 0.40)SOLO_CONTENT_BLOCK: 0.92— single-layer threshold for label-less content classifiers (testsavant, deberta). Intentionally higher thanBLOCKbecause these layers can't distinguish "this is an injection" from "this looks like phishing aimed at the user." The transcript classifier keeps a separate, label-gated solo path atBLOCK(0.85).
Ensemble rule: BLOCK only when the ML content classifier AND the transcript classifier both report >= WARN. Single-layer high confidence degrades to WARN — this is the Stack Overflow instruction-writing FP mitigation. Canary leak always BLOCKs (deterministic).
Env knobs:
GSTACK_SECURITY_OFF=1— emergency kill switch. Classifier stays off even if warmed. Canary is still injected; just the ML scan is skipped.GSTACK_SECURITY_ENSEMBLE=deberta— opt-in DeBERTa-v3 ensemble. Adds ProtectAI DeBERTa-v3-base-injection-onnx as L4c classifier for cross-model agreement. 721MB first-run download. With ensemble enabled, BLOCK requires 2-of-3 ML classifiers agreeing at >= WARN (testsavant, deberta, transcript). Without ensemble (default), BLOCK requires testsavant + transcript at >= WARN.- Classifier model cache:
~/.gstack/models/testsavant-small/(112MB, first run only) plus~/.gstack/models/deberta-v3-injection/(721MB, only when ensemble enabled) - Attack log:
~/.gstack/security/attempts.jsonl(salted sha256 + domain only, rotates at 10MB, 5 generations) - Per-device salt:
~/.gstack/security/device-salt(0600) - Session state:
~/.gstack/security/session-state.json(cross-process, atomic)
Dev symlink awareness
When developing gstack, .claude/skills/gstack may be a symlink back to this
working directory (gitignored). This means skill changes are live immediately,
great for rapid iteration, risky during big refactors where half-written skills
could break other Claude Code sessions using gstack concurrently.
Check once per session: Run ls -la .claude/skills/gstack to see if it's a
symlink or a real copy. If it's a symlink to your working directory, be aware that:
- Template changes +
bun run gen:skill-docsimmediately affect all gstack invocations - Breaking changes to SKILL.md.tmpl files can break concurrent gstack sessions
- During large refactors, remove the symlink (
rm .claude/skills/gstack) so the global install at~/.claude/skills/gstack/is used instead
Prefix setting: Setup creates real directories (not symlinks) at the top level
with a SKILL.md symlink inside (e.g., qa/SKILL.md -> gstack/qa/SKILL.md). This
ensures Claude discovers them as top-level skills, not nested under gstack/.
Names are either short (qa) or namespaced (gstack-qa), controlled by
skill_prefix in ~/.gstack/config.yaml. Pass --no-prefix or --prefix to
skip the interactive prompt.
Note: Vendoring gstack into a project's repo is deprecated. Use global install
./setup --teaminstead. See README.md for team mode instructions.
For plan reviews: When reviewing plans that modify skill templates or the gen-skill-docs pipeline, consider whether the changes should be tested in isolation before going live (especially if the user is actively using gstack in other windows).
Upgrade migrations: When a change modifies on-disk state (directory structure,
config format, stale files) in ways that could break existing user installs, add a
migration script to gstack-upgrade/migrations/. Read CONTRIBUTING.md's "Upgrade
migrations" section for the format and testing requirements. The upgrade skill runs
these automatically after ./setup during /gstack-upgrade.
Compiled binaries — NEVER commit browse/dist/ or design/dist/
The browse/dist/ and design/dist/ directories contain compiled Bun binaries
(browse, find-browse, design, ~58MB each). These are Mach-O arm64 only — they
do NOT work on Linux, Windows, or Intel Macs. The ./setup script already builds
from source for every platform, so the checked-in binaries are redundant. They are
tracked by git due to a historical mistake and should eventually be removed with
git rm --cached.
NEVER stage or commit these files. They show up as modified in git status
because they're tracked despite .gitignore — ignore them. When staging files,
always use specific filenames (git add file1 file2) — never git add . or
git add -A, which will accidentally include the binaries.
Commit style
Always bisect commits. Every commit should be a single logical change. When you've made multiple changes (e.g., a rename + a rewrite + new tests), split them into separate commits before pushing. Each commit should be independently understandable and revertable.
Examples of good bisection:
- Rename/move separate from behavior changes
- Test infrastructure (touchfiles, helpers) separate from test implementations
- Template changes separate from generated file regeneration
- Mechanical refactors separate from new features
When the user says "bisect commit" or "bisect and push," split staged/unstaged changes into logical commits and push.
Slop-scan: AI code quality, not AI code hiding
We use slop-scan to catch patterns where AI-generated code is genuinely worse than what a human would write. We are NOT trying to pass as human code. We are AI-coded and proud of it. The goal is code quality.
npx slop-scan scan . # human-readable report
npx slop-scan scan . --json # machine-readable for diffing
Config: slop-scan.config.json at repo root (currently excludes **/vendor/**).
What to fix (genuine quality improvements)
- Empty catches around file ops — use
safeUnlink()(ignores ENOENT, rethrows EPERM/EIO). A swallowed EPERM in cleanup means silent data loss. - Empty catches around process kills — use
safeKill()(ignores ESRCH, rethrows EPERM). A swallowed EPERM means you think you killed something you didn't. - Redundant
return await— remove when there's no enclosing try block. Saves a microtask, signals intent. - Typed exception catches —
catch (err) { if (!(err instanceof TypeError)) throw err }is genuinely better thancatch {}when the try block does URL parsing or DOM work. You know what error you expect, so say so.
What NOT to fix (linter gaming, not quality)
- String-matching on error messages —
err.message.includes('closed')is brittle. Playwright/Chrome can change wording anytime. If a fire-and-forget operation can fail for ANY reason and you don't care,catch {}is the correct pattern. - Adding comments to exempt pass-through wrappers — "alias for active session" above a method just to trip slop-scan's exemption rule is noise, not documentation.
- Converting extension catch-and-log to selective rethrow — Chrome extensions crash entirely on uncaught errors. If the catch logs and continues, that IS the right pattern for extension code. Don't make it throw.
- Tightening best-effort cleanup paths — shutdown, emergency cleanup, and disconnect
code should use
safeUnlinkQuiet()(swallows ALL errors). A cleanup path that throws on EPERM means the rest of cleanup doesn't run. That's worse.
Utilities in browse/src/error-handling.ts
| Function | Use when | Behavior |
|---|---|---|
safeUnlink(path) |
Normal file deletion | Ignores ENOENT, rethrows others |
safeUnlinkQuiet(path) |
Shutdown/emergency cleanup | Swallows all errors |
safeKill(pid, signal) |
Sending signals | Ignores ESRCH, rethrows others |
isProcessAlive(pid) |
Boolean process checks | Returns true/false, never throws |
Score tracking
Baseline (2026-04-09, before cleanup): 100 findings, 432.8 score, 2.38 score/file. After cleanup: 90 findings, 358.1 score, 1.96 score/file.
Don't chase the number. Fix patterns that represent actual code quality problems. Accept findings where the "sloppy" pattern is the correct engineering choice.
Community PR guardrails
When reviewing or merging community PRs, always AskUserQuestion before accepting any commit that:
- Touches ETHOS.md — this file is Garry's personal builder philosophy. No edits from external contributors or AI agents, period.
- Removes or softens promotional material — YC references, founder perspective, and product voice are intentional. PRs that frame these as "unnecessary" or "too promotional" must be rejected.
- Changes Garry's voice — the tone, humor, directness, and perspective in skill templates, CHANGELOG, and docs are not generic. PRs that rewrite voice to be more "neutral" or "professional" must be rejected.
Even if the agent strongly believes a change improves the project, these three categories require explicit user approval via AskUserQuestion. No exceptions. No auto-merging. No "I'll just clean this up."
Checking out PRs from garrytan-agents
When the user says "check out " and the PR is from garrytan-agents/gstack
(or any other fork that is NOT a collaborator on garrytan/gstack), do NOT just
gh pr checkout. Fork PRs don't receive base-repo secrets (ANTHROPIC_API_KEY,
OPENAI_API_KEY, etc.), so the eval/E2E CI jobs fail with empty-env auth errors
regardless of what's set on the base repo.
Workflow: push the branch to garrytan/gstack (the base repo) and re-target
the PR from there.
Concretely, after gh pr checkout <N>:
- Note the original PR number and head branch name.
- Push the same branch to the base repo:
git push origin HEAD:<branch-name>(origin =garrytan/gstack, since the worktree is set up with that remote). - Close the fork PR (
gh pr close <N> --comment "moving to base-repo branch for secret access"). - Open a new PR from the base-repo branch:
gh pr create --base main --head <branch-name>. - New PR's workflows will get secrets automatically.
Why not fix it on the fork side? garrytan-agents isn't a collaborator on
garrytan/gstack. Adding it as a collaborator (option A) or flipping the
repo-wide "send secrets to fork PRs" toggle (option B) would let secrets reach
fork PRs from anyone — broader blast radius than just moving this one branch.
Option C (this section) keeps secret-distribution scope tight.
If the user asks you to skip the move (e.g., "just leave it as a fork PR"), respect that — eval CI will fail with empty-env auth, but check-freshness, workflow-lint, and windows-tests will still pass on the fork PR.
CHANGELOG + VERSION style
Versioning invariant (workspace-aware ship). VERSION is a monotonic ordered
release identifier, not a strict semver commitment. The bump level
(major/minor/patch/micro) expresses intent at ship time. Queue-advancing past a
claimed version within the same bump level is explicitly permitted — if branch A
claims v1.7.0.0 as a MINOR and branch B is also a MINOR, B lands at v1.8.0.0
(still a MINOR relative to main). Downstream consumers must NOT rely on
"MINOR = feature-only, PATCH = fix-only" as a strict contract. This is why
bin/gstack-next-version advances within the chosen bump level rather than
repicking the level when collisions happen.
Scale-aware bumps — use common sense. When the diff is big, bump MINOR (or MAJOR), not PATCH. PATCH is for bug fixes and small additions; MINOR is for substantial new capability or substantial reduction; MAJOR is for breaking changes. Rough guideposts (don't treat as rules, treat as smell-checks):
- PATCH (X.Y.Z+1.0): bug fix, doc tweak, small additive change, single test/file added. Net diff under ~500 lines, no new user-facing capability.
- MINOR (X.Y+1.0.0): new capability shipped (skill, harness, command, big refactor), substantial code reduction (compression, migration), or coordinated multi-file change. Net diff over ~2000 lines added/removed, OR a user-visible feature you'd put in a tweet.
- MAJOR (X+1.0.0.0): breaking change to public surface (CLI flag rename, skill removed, config format changed), OR a release big enough to be the headline of a blog post.
If you find yourself debating "is 10K added + 24K removed really a PATCH?" — it isn't. Bump MINOR. Same for "this adds a whole new test harness with 6 new E2E tests + helper utilities" — MINOR. The bump level is communication to the user about what kind of release this is; don't undersell it.
When merging origin/main brings a higher VERSION, re-evaluate the bump level against the SCALE of your branch's work, not just whether main moved forward. If main bumped MINOR and your branch is also a substantial change, you bump MINOR again on top (e.g., main at v1.14.0.0, your branch lands v1.15.0.0).
VERSION and CHANGELOG are branch-scoped. Every feature branch that ships gets its own version bump and CHANGELOG entry. The entry describes what THIS branch adds — not what was already on main.
The CHANGELOG entry is the diff between main and the shipping branch — what users get when they upgrade. NOT how the branch got there. A reader landing on the entry should learn what they can do now that they couldn't before; they should not learn about the branch's internal version bumps, the bugs we caught and fixed mid-branch, the plan reviews we ran, or the commits we squashed. That is branch development narrative. It belongs in PR descriptions and commit messages, not CHANGELOG.
Never reference branch-internal versions in a CHANGELOG entry. If your branch bumped VERSION from v1.5.0.0 → v1.5.1.0 → v1.6.0.0 during development and only the final v1.6.0.0 ships to main, the entry must read as if v1.5.1.0 never existed. Concretely, NEVER write:
- "v1.5.1.0 had a bug that v1.6.0.0 fixes" — readers don't know about v1.5.1.0; it's a branch-internal artifact.
- "The shipping headline of v1.5.1.0 was broken because..." — same reason. From main's perspective, v1.5.1.0 was never released.
- "Pre-fix tests encoded the broken behavior" — that's a contributor's victory lap, not a user benefit.
- "Two surgical edits, both in the dispatch path" — micro-narrative of the patch.
Instead, describe the released system: "Browser-skills run end-to-end with the expected tab-access semantics." If a property of the shipped system is worth calling out (e.g., "skill spawns get permissive tab access; pair-agent tunnel tokens require ownership"), document it as a property, not as a fix. The shipped system is what the user gets; the path to that system is invisible to them.
When to write the CHANGELOG entry:
- At
/shiptime (Step 13), not during development or mid-branch. - The entry covers ALL commits on this branch vs the base branch.
- Never fold new work into an existing CHANGELOG entry from a prior version that already landed on main. If main has v0.10.0.0 and your branch adds features, bump to v0.10.1.0 with a new entry — don't edit the v0.10.0.0 entry.
Key questions before writing:
- What branch am I on? What did THIS branch change?
- Is the base branch version already released? (If yes, bump and create new entry.)
- Does an existing entry on this branch already cover earlier work? (If yes, replace it with one unified entry for the final version.)
Merging main does NOT mean adopting main's version. When you merge origin/main into a feature branch, main may bring new CHANGELOG entries and a higher VERSION. Your branch still needs its OWN version bump on top. If main is at v0.13.8.0 and your branch adds features, bump to v0.13.9.0 with a new entry. Never jam your changes into an entry that already landed on main. Your entry goes on top because your branch lands next.
After merging main, always check:
- Does CHANGELOG have your branch's own entry separate from main's entries?
- Is VERSION higher than main's VERSION?
- Is your entry the topmost entry in CHANGELOG (above main's latest)? If any answer is no, fix it before continuing.
After any CHANGELOG edit that moves, adds, or removes entries, immediately run
grep "^## \[" CHANGELOG.md to verify no duplicates and a sensible reverse-chronological
order. Gaps between version numbers are fine. A branch that ships at v1.6.4.0 without
a prior v1.5.2.0 or v1.5.3.0 entry on main is correct — those were branch-internal
version numbers that never landed. Do not back-fill gaps with placeholder entries.
Never orphan branch-internal versions. If your branch bumped VERSION several times during development (v1.5.1.0 → v1.5.2.0 → v1.6.4.0, say) and those earlier entries were never released to main, the final ship consolidates ALL of them into a single entry at the final version (v1.6.4.0). Collapse them — delete the old entries and move their content into the final entry, re-version table columns accordingly. Readers see one release, not a branch diary. Gaps are fine (v1.6.3.0 → v1.6.4.0 with no v1.5.x in between on main is correct).
CHANGELOG.md is for users, not contributors. Write it like product release notes:
- Lead with what the user can now do that they couldn't before. Sell the feature.
- Use plain language, not implementation details. "You can now..." not "Refactored the..."
- Never mention TODOS.md, internal tracking, eval infrastructure, or contributor-facing details. These are invisible to users and meaningless to them.
- Put contributor/internal changes in a separate "For contributors" section at the bottom.
- Every entry should make someone think "oh nice, I want to try that."
- No jargon: say "every question now tells you which project and branch you're in" not "AskUserQuestion format standardized across skill templates via preamble resolver."
Only document what shipped between main and this change. Readers do not care how we got here. Keep out of the CHANGELOG, always:
- Branch resyncs, merge commits with main, rebase activity.
- Plan approvals, review outcomes (CEO / eng / design / outside-voice / codex findings), AskUserQuestion decisions, scope negotiations.
- "Work queued," "plan approved," "in-progress," "will ship later" — the CHANGELOG documents what DID ship, not what MIGHT ship.
- Version-bump housekeeping when no user-facing work actually landed.
If the diff between the base branch version and this version has no user-facing change (only merges, only CHANGELOG edits, only placeholder work), the honest entry is one sentence: "Version bump for branch-ahead discipline. No user-facing changes yet." Stop there. Do not pad. Do not explain the plan that will ship eventually. Do not narrate the branch's history. When real work lands, the entry will replace this at /ship time.
Release-summary format (every ## [X.Y.Z] entry)
Every version entry in CHANGELOG.md MUST start with a release-summary section in
the GStack/Garry voice, one viewport's worth of prose + tables that lands like a
verdict, not marketing. The itemized changelog (subsections, bullets, files) goes
BELOW that summary, separated by a ### Itemized changes header.
The release-summary section gets read by humans, by the auto-update agent, and by anyone deciding whether to upgrade. The itemized list is for agents that need to know exactly what changed.
Structure for the top of every ## [X.Y.Z] entry:
- Two-line bold headline (10-14 words total). Should land like a verdict, not marketing. Sound like someone who shipped today and cares whether it works.
- Lead paragraph (3-5 sentences). What shipped, what changed for the user. Specific, concrete, no AI vocabulary, no em dashes, no hype.
- A "The X numbers that matter" section with:
- One short setup paragraph naming the source of the numbers (real production deployment OR a reproducible benchmark, name the file/command to run).
- A table of 3-6 key metrics with BEFORE / AFTER / Δ columns.
- A second optional table for per-category breakdown if relevant.
- 1-2 sentences interpreting the most striking number in concrete user terms.
- A "What this means for [audience]" closing paragraph (2-4 sentences) tying the metrics to a real workflow shift. End with what to do.
Voice rules for the release summary:
- No em dashes (use commas, periods, "...").
- No AI vocabulary (delve, robust, comprehensive, nuanced, fundamental, etc.) or banned phrases ("here's the kicker", "the bottom line", etc.).
- Real numbers, real file names, real commands. Not "fast" but "~30s on 30K pages."
- Short paragraphs, mix one-sentence punches with 2-3 sentence runs.
- Connect to user outcomes: "the agent does ~3x less reading" beats "improved precision."
- Be direct about quality. "Well-designed" or "this is a mess." No dancing.
Source material:
- CHANGELOG previous entry for prior context.
- Benchmark files or
/retrooutput for headline numbers. - Recent commits (
git log <prev-version>..HEAD --oneline) for what shipped. - Don't make up numbers. If a metric isn't in a benchmark or production data, don't include it. Say "no measurement yet" if asked.
Target length: ~250-350 words for the summary. Should render as one viewport.
Itemized changes (below the release summary)
Write ### Itemized changes and continue with the detailed subsections (Added,
Changed, Fixed, For contributors). Same rules as the user-facing voice guidance
above, plus:
- Always credit community contributions. When an entry includes work from a
community PR, name the contributor with
Contributed by @username. Contributors did real work. Thank them publicly every time, no exceptions.
AI effort compression
When estimating or discussing effort, always show both human-team and CC+gstack time:
| Task type | Human team | CC+gstack | Compression |
|---|---|---|---|
| Boilerplate / scaffolding | 2 days | 15 min | ~100x |
| Test writing | 1 day | 15 min | ~50x |
| Feature implementation | 1 week | 30 min | ~30x |
| Bug fix + regression test | 4 hours | 15 min | ~20x |
| Architecture / design | 2 days | 4 hours | ~5x |
| Research / exploration | 1 day | 3 hours | ~3x |
Completeness is cheap. Don't recommend shortcuts when the complete implementation is a "lake" (achievable) not an "ocean" (multi-quarter migration). See the Completeness Principle in the skill preamble for the full philosophy.
Search before building
Before designing any solution that involves concurrency, unfamiliar patterns, infrastructure, or anything where the runtime/framework might have a built-in:
- Search for "{runtime} {thing} built-in"
- Search for "{thing} best practice {current year}"
- Check official runtime/framework docs
Three layers of knowledge: tried-and-true (Layer 1), new-and-popular (Layer 2), first-principles (Layer 3). Prize Layer 3 above all. See ETHOS.md for the full builder philosophy.
Local plans
Contributors can store long-range vision docs and design documents in ~/.gstack-dev/plans/.
These are local-only (not checked in). When reviewing TODOS.md, check plans/ for candidates
that may be ready to promote to TODOs or implement.
E2E eval failure blame protocol
When an E2E eval fails during /ship or any other workflow, never claim "not
related to our changes" without proving it. These systems have invisible couplings —
a preamble text change affects agent behavior, a new helper changes timing, a
regenerated SKILL.md shifts prompt context.
Required before attributing a failure to "pre-existing":
- Run the same eval on main (or base branch) and show it fails there too
- If it passes on main but fails on the branch — it IS your change. Trace the blame.
- If you can't run on main, say "unverified — may or may not be related" and flag it as a risk in the PR body
"Pre-existing" without receipts is a lazy claim. Prove it or don't say it.
Long-running tasks: don't give up
When running evals, E2E tests, or any long-running background task, poll until
completion. Use sleep 180 && echo "ready" + TaskOutput in a loop every 3
minutes. Never switch to blocking mode and give up when the poll times out. Never
say "I'll be notified when it completes" and stop checking — keep the loop going
until the task finishes or the user tells you to stop.
The full E2E suite can take 30-45 minutes. That's 10-15 polling cycles. Do all of them. Report progress at each check (which tests passed, which are running, any failures so far). The user wants to see the run complete, not a promise that you'll check later.
E2E test fixtures: extract, don't copy
NEVER copy a full SKILL.md file into an E2E test fixture. SKILL.md files are
1500-2000 lines. When claude -p reads a file that large, context bloat causes
timeouts, flaky turn limits, and tests that take 5-10x longer than necessary.
Instead, extract only the section the test actually needs:
// BAD — agent reads 1900 lines, burns tokens on irrelevant sections
fs.copyFileSync(path.join(ROOT, 'ship', 'SKILL.md'), path.join(dir, 'ship-SKILL.md'));
// GOOD — agent reads ~60 lines, finishes in 38s instead of timing out
const full = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
const start = full.indexOf('## Review Readiness Dashboard');
const end = full.indexOf('\n---\n', start);
fs.writeFileSync(path.join(dir, 'ship-SKILL.md'), full.slice(start, end > start ? end : undefined));
Also when running targeted E2E tests to debug failures:
- Run in foreground (
bun test ...), not background with&andtee - Never
pkillrunning eval processes and restart — you lose results and waste money - One clean run beats three killed-and-restarted runs
Publishing native OpenClaw skills to ClawHub
Native OpenClaw skills live in openclaw/skills/gstack-openclaw-*/SKILL.md. These are
hand-crafted methodology skills (not generated by the pipeline) published to ClawHub
so any OpenClaw user can install them.
Publishing: The command is clawhub publish (NOT clawhub skill publish):
clawhub publish openclaw/skills/gstack-openclaw-office-hours \
--slug gstack-openclaw-office-hours --name "gstack Office Hours" \
--version 1.0.0 --changelog "description of changes"
Repeat for each skill: gstack-openclaw-ceo-review, gstack-openclaw-investigate,
gstack-openclaw-retro. Bump --version on each update.
Auth: clawhub login (opens browser for GitHub auth). clawhub whoami to verify.
Updating: Same clawhub publish command with a higher --version and --changelog.
Verification: clawhub search gstack to confirm they're live.
Deploying to the active skill
The active skill lives at ~/.claude/skills/gstack/. After making changes:
- Push your branch
- Fetch and reset in the skill directory:
cd ~/.claude/skills/gstack && git fetch origin && git reset --hard origin/main - Rebuild:
cd ~/.claude/skills/gstack && bun run build
Or copy the binaries directly:
cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browsecp design/dist/design ~/.claude/skills/gstack/design/dist/design
Skill routing
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
Key routing rules:
- Product ideas/brainstorming → invoke /office-hours
- Strategy/scope → invoke /plan-ceo-review
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
- Ship/deploy/PR → invoke /ship or /land-and-deploy
- Save progress → invoke /context-save
- Resume context → invoke /context-restore
GBrain Search Guidance (configured by /sync-gbrain)
GBrain is set up and synced on this machine. The agent should prefer gbrain over Grep when the question is semantic or when you don't know the exact identifier yet.
This worktree is pinned to a worktree-scoped code source via the
.gbrain-source file in the repo root (kubectl-style context). Any
gbrain code-def, code-refs, code-callers, code-callees, or query
call from anywhere under this worktree routes to that source by default —
no --source flag needed. Conductor sibling worktrees of the same repo
each have their own pin and their own indexed pages, so semantic results
match the actual code on disk in this worktree.
Two indexed corpora available via the gbrain CLI:
- This worktree's code (auto-pinned via
.gbrain-source). ~/.gstack/curated memory (registered asgstack-brain-<user>source via the existing federation pipeline).
Prefer gbrain when:
- "Where is X handled?" / semantic intent, no exact string yet:
gbrain search "<terms>"orgbrain query "<question>" - "Where is symbol Y defined?" / symbol-based code questions:
gbrain code-def <symbol>orgbrain code-refs <symbol> - "What calls Y?" / "What does Y depend on?":
gbrain code-callers <symbol>/gbrain code-callees <symbol> - "What did we decide last time?" / past plans, retros, learnings:
gbrain search "<terms>" --source gstack-brain-<user>
Grep is still right for known exact strings, regex, multiline patterns, and
file globs. Run /sync-gbrain after meaningful code changes; for ongoing
auto-sync across all worktrees, run gbrain autopilot --install once per
machine — gbrain's daemon handles incremental refresh on a schedule.