Skip to content

refactor(herdr): three-row sidebar layout with shared alert ranking - #582

Merged
lis186 merged 12 commits into
mainfrom
worktree-herdr-sidebar-ux
Aug 21, 2026
Merged

refactor(herdr): three-row sidebar layout with shared alert ranking#582
lis186 merged 12 commits into
mainfrom
worktree-herdr-sidebar-ux

Conversation

@lis186

@lis186 lis186 commented Aug 21, 2026

Copy link
Copy Markdown
Owner

摘要

Sidebar 從累積 8 列(3 處重複、4 處截斷)重構為固定三行版面,每行語意單一:

  • Row 1: herdr 原生 state_icon · agent · state_text(idle/working)
  • Row 2: context sparkline + cache%(只有 context 事實)
  • Row 3: $facts(灰,cost · age)或 $alert(警示色,最重要的問題)

Detail

Shared concern ranking (ADR 0005 shape)

PANE_CONCERN_TIERS is the single source for "what is the most important thing about this pane right now." Both the sidebar badge's row-3 $alert and Mission Control's action chain read it. Previously two orderings contradicted each other — contextSignal ranked context above every tool failure, while the MC action chain ranked fail >= 2 above context and cache dropped above fail == 1.

The $alert priority order (owner-signed-off): quota-refused > stale > fail > cache-dropped. sidebarOwned tiers (context, blocked, no-telemetry) are skipped by row 3 because rows 1 and 2 already render them.

Installer migration

The old installer could only APPEND rows. A real config had 8 rows accumulated across three generations — ["$ctx","$model","$cost"], dead $tg/$ty/$tr, $summary, and four $ctx_bar_* colour rows. Running the old installer made it worse. migrateRowsArray now replaces superseded generations and legacy default rows (gated on SECTION_MARKER to protect user-authored tables).

Two bugs found only through live e2e verification

  1. 16-token pane metadata cap: herdr caps report-metadata at 16 unique token names (set + clear combined). The plugin was sending 17. Every badge refresh failed silently. Invisible in unit tests because reportPaneTokens is mocked. Fixed by whitelisting only the 7 tokens config rows actually render.

  2. Header parse pollution (fix(herdr): native agent launch with header identity, and the silent breaks on that path #575 regression): Node joins duplicate HTTP headers with , . A ccxray-launched shell prepends its own ANTHROPIC_CUSTOM_HEADERS, so the server saw herdr:wY:old, X-Ccxray-Auth: ..., X-Ccxray-Agent-Id: herdr:w1D:new as one string. Badge matching via agentId broke for every pane launched from a ccxray-managed shell. Fixed by taking the last herdr:-prefixed segment.

What this does NOT do

  • Sidebar freshness guarantee — the 250ms waitForTelemetry delay is still a time guess, not a causal barrier. Filed as Visibility barrier for sidebar badge freshness (P7) #581 (P7 visibility barrier with per-pane cursor), designed by a three-model adversarial panel (Fable + Codex GPT 5.6 Sol + Sonnet).
  • ADR 0013 provenance markers on the badge (row 2 space available but independent of row 3).
  • prefix+d keybinding — lib/keybindings.js has an in-tree decision that two keys is the ceiling. prefix+md is two keystrokes.
  • Quota percentage / reset ETA (feat(herdr): surface subscription quota in the sidebar badge #571).

Verification

  • Unit tests: 2302/2302 (CCXRAY_HOME=$(mktemp -d), env scrubbed)
  • Codex gate: clean after 2 rounds (3 findings R1, 1 finding R2, all fixed)
  • Multi-provider smoke: claude/codex/grok badge tokens verified on real index (252K anthropic + 35K openai entries)
  • Live e2e: plugin link → install migration → launch-claude via proxy → prompt → auto badge refresh → herdr pane get confirms three-row rendering with state_labels: {} and correct token whitelist
  • Fail-on-old: every behaviour change has differential evidence (restoration proved by probe, red on old code, green on new)

codex gate clean

Test plan

  • CCXRAY_HOME=$(mktemp -d) npm test — 2302/2302
  • Run install-sidebar-summary on a config with the old 8-row layout → 7 rows, 3 visible
  • Launch claude via the plugin's launch-claude action → badge auto-refreshes with three rows
  • herdr pane get <pane> shows state_labels: {} for a located pane

🤖 Generated with Claude Code

Justin Lee and others added 12 commits August 21, 2026 13:11
…sion Control

把「這個 pane 現在最重要的事是什麼」的排序收斂成單一來源 `PANE_CONCERN_TIERS`,
並讓 Mission Control 的 action 鏈改讀它。原本兩處排序互相矛盾:`contextSignal`
把 context 排在所有 tool failure 之上,而 MC action 鏈把 `fail >= 2` 排在 context
之上、`cache dropped` 排在 `fail == 1` 之上——同一個 pane 因此在 sidebar 讀到
"near full"、在 Mission Control 讀到 "inspect last error"。這是 ADR 0005 存在要
防的失效形狀,而為 row 3 的 `$alert` 再寫第三套排序會讓它變成三方分歧。

The merged order interleaves the owner-signed-off $alert tiers
(quota-refused > stale > fail > cache-dropped) with the tiers only a
single-row surface renders (no-telemetry, blocked, ctx, ready). `sidebarOwned`
marks the latter so the three-row sidebar can skip what rows 1 and 2 already
show; Mission Control renders every tier.

The MC action chain's ONLY behaviour change is `fail == 1` moving above
`cache dropped`, which the signed-off order requires. `quotaRefusalCount`
reads `status === 429` off the index — an observed refusal, never the forecast
in usage-status/*.json, which is #571's surface.

fail-on-old (docs/verification-principles.md): asserted through the pre-existing
missionControlSnapshot API so the old code answers it rather than throwing on a
missing export. Reverting only the MC chain (helper left exported, restoration
proved by probe) turns exactly one test red — old 'inspect prompt/tool diff' vs
new 'inspect failed tool' — while the fixture guards (cacheDropped/failures/
ctxPct) still pass on the old code, so the red is the ordering and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nstead of overwriting

`launch-agent stamps the workspace id into the pane identity header` fails on
main (57e5b8f) for any developer whose own shell was launched by this plugin.
`pluginEnv()` strips `HERDR_*`, `CCXRAY_*` and `PROXY_PORT` but not
`ANTHROPIC_CUSTOM_HEADERS`, which joined the plugin's env surface in #575.

The reason it leaks where `ANTHROPIC_BASE_URL` does not is the merge, and that
distinction is now written down: `launchEnvVars` OVERWRITES the base url but
deliberately PREPENDS the user's existing headers, so an ambient value survives
into the spawned plan and the test's exact-equality assertion sees three
headers instead of one. Production behaviour is correct and unchanged — the
prepend is the documented #575 decision that stops ccxray dropping a user's own
`X-Existing: foo`.

差異證據(環境相依,已量測兩次):在本機 pristine tree(origin/main 零差異、
單獨執行)該測試紅,actual 為
`X-Ccxray-Agent-Id: herdr:wY:…, X-Ccxray-Auth: …, X-Ccxray-Agent-Id: herdr:w1:…`;
剝除後綠。CI 以空環境跑,所以它在 CI 一直是綠的——這正是它出貨時沒被發現的原因。

plugins/herdr full suite: 200/200 (CCXRAY_HOME=$(mktemp -d)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…per refresh

Sidebar row 3 becomes two tokens with one meaning each — `$facts` (grey,
`$42.08 · 60m`) and `$alert` (warning colour) — of which exactly one carries
content per refresh while the other is returned in `clearTokens`. That is the
mechanism `ctx_bar_*` has shipped with; a single token holding either a fact or
a warning would have to change colour with its meaning, the Channel Discipline
violation docs/design-principles.md exists to stop. Row height never changes,
only content, which design-principles.md:60 permits explicitly.

`$alert` reads the shared ranking from the previous commit, so context pressure
and process state never reach row 3 — rows 2 and 1 already render them, and a
95% session showing "full" on row 3 would be the FOURTH encoding of one fact
(percentage + sparkline + colour band + text). `summarizeTurnGroup` now exposes
the raw signals (`failures`, `cacheDropped`, `refusedCount`), all read off
`anchor`, so a subagent's failure cannot raise an alert about the main
conversation.

A pane whose session cannot be located fills NEITHER token: row 1's
state_labels already says "not linked", and `$facts` would put the detail's
honest-but-useless `n/a · ?` beside it. This is asserted as a forward test, NOT
a fail-on-old — `$facts` did not exist before, so the old code would fail it for
a missing token rather than for a behaviour difference.

Alert tiers gain a sidebar `brief`: the spelled-out 'cache dropped after prompt
change' is 33 columns against a 18-40 column row, and truncation is one of the
symptoms this layout removes. Mission Control keeps the full sentence. Anything
still too long is clipped against the measured width by us rather than cut by
Herdr.

Also removes a loaded landmine in refresh-badges: `tokens.turns` fell back to
`usage.data.meta.totalEntries`, every session's line count, in a per-agent row.
Dead today (no path returns a nullish `detail.turns`) so it ships without a
differential test — it is a landmine, not a live defect.

plugins/herdr suite: 205/205 (CCXRAY_HOME=$(mktemp -d)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`state_labels` replaces a pane's native `state_text`, and the plugin set it on
every refresh to the ccxray summary. Row 1 (`state_icon · agent · state_text`)
therefore read `claude · ccxray: traced · claude` — the agent name twice, and
the model a third time once row 3 carries the cost. Herdr's own idle/working is
the right content for a located pane; it is the one thing on that row Herdr
knows better than we do.

The label is now set only for the states Herdr cannot know ("not linked",
"no hub") and actively CLEARED otherwise. Clearing rather than omitting is
load-bearing: a label Herdr already holds survives a report that does not
mention it, so a pane that recovered from "not linked" would keep showing it
forever. `--clear-state-labels` is the herdr CLI's own affordance for this.

`paneMetadataUnchanged` counts a pending clear as a change, mirroring what it
already does for `clearTokens`. Without it the skip-write optimisation reads
"no token moved" and suppresses the very write that hands row 1 back.

fail-on-old: reverting the conditional (restoration proved by probe) turns
'clears the state labels instead of overwriting the state text' red while
'still labels a pane whose session it cannot locate' stays green — the second
behaviour is unchanged, which is what the pair is there to show.

plugins/herdr suite: 207/207 (CCXRAY_HOME=$(mktemp -d)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ctx_bar`'s tail carried whichever alert ranked highest. Two problems, both
visible in the reported screenshot (`▁▁▁▁▁▁▁▁ 21% · stal…` on one row,
`21% stale · claude-opu… · $10.01` on another):

- 'full' / 'near full' is a FOURTH encoding of the percentage sitting right
  beside it — percentage, sparkline, colour band, then the word.
- the stale text is now row 3's `$alert`, so keeping it here prints the same
  reason twice.

Row 2 keeps the one context fact no other row shows: how much of the context
came from cache. That was already the tail whenever nothing was wrong, so the
healthy rendering is unchanged; only the alert-shaped tails go.

`contextSignal` is removed rather than left unreferenced — it held the second of
the two contradicting orderings, and an unused ranking function is something a
future reader reuses.

Four existing tests pinned the removed tail. They are RE-POINTED, not made
green: each protected property still exists and now has a channel, so the
assertions moved to it. Notably `doesNotMatch(detail.ctxBar, /fail/)` (the #438
per-turn-failure guard) had become vacuously true for every input — it now
asserts `detail.failures` and `paneAlert`, so a regression to
`turnToolFail || toolFail` still turns it red.

Known consequence: a config rendering ONLY `$ctx_bar` loses the stale WORD from
this tail and keeps the withdrawn colour. The reason is not lost from the token
set — `$ctx` still carries `21% stale` and `$alert` carries it in full — but
such a config must be migrated to see it as text.

This deviates from the handoff's literal instruction to have contextSignal read
the shared helper: doing that would have row 2 print the same string row 3 does,
which is the duplication the three-row layout exists to remove.

plugins/herdr suite: 207/207 (CCXRAY_HOME=$(mktemp -d)).

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

The installer could only APPEND. A real config had eight rows accumulated across
three generations — the atomic `["$ctx","$model","$cost"]` row, a dead
`$tg/$ty/$tr` row this plugin has never provided tokens for, `$summary`, and the
four `$ctx_bar_*` colour rows — rendering the model three times, the cost twice
and the percentage twice, with four truncations. Running the old installer on it
stacked the new rows on top and made the card WORSE, so shipping row 3 without
this would not have fixed the reported screenshot at all.

The migration mechanism is not new: `OLD_CTX_BAR_ROW_RE` has been migrating the
single-`$ctx_bar` generation to the four colour variants since they shipped. This
widens it to the generations that shipped around it, and deletes conservatively —
only inside this one rows array, and only lines whose COMPLETE token set matches
a superseded generation. A row the user extended is kept, because we cannot know
which half they wanted. Every deletion is printed.

Result on the reported shape: 8 rows → 7, which Herdr renders as three lines
(it skips a row whose tokens are all empty — the four ctx_bar colour rows have
relied on that in production since they shipped).

Three drift sites are collapsed into one while here, two of which were already
broken by this change:
  - the ctx_bar colours were declared twice, so a repaired config could disagree
    with a fresh one → one MANAGED_ROW_BY_TOKEN map.
  - remove-sidebar-summary re-typed the skeleton it deletes → it now derives from
    the installer's DEFAULT_ROWS. Row 1 changed shape here, so uninstall would
    have silently left an empty table behind.
  - onboarding.js spelled out its own "installed?" regex over `$summary` → it now
    calls configHasManagedRows. Quick Start reported SETUP · optional for a
    correctly installed sidebar until this was fixed, and its regex did not strip
    comments, so a commented-out example row counted as installed.

install-sidebar-summary gains ADR 0015's two-mode shape (`require.main === module`)
so the other two can import those constants without running an install.

fail-on-old: reverting main() to append-only (restoration proved by probe) turns
3 of the 5 new tests red — the migration, idempotency, and the deletion report.
The other two stay green on purpose: conservative deletion and the installed
predicate hold under both behaviours.

plugins/herdr suite: 212/212 (CCXRAY_HOME=$(mktemp -d)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The standalone dashboard action ran a bare `ccxray open`, so invoking it from a
pane threw away the one thing its caller knew — which pane they were looking at.
`ccxray open --session <id>` has existed since server/index.js:748 and Mission
Control's `d` has passed it since it shipped; only this action did not.

The pane→session resolution is EXTRACTED from refresh-badges rather than
rewritten, as `resolvePaneSessionId`. The badge and the deep link have to agree
about which session a pane is, or `prefix+m` → `d` and this action open
different sessions for the same pane. It keeps the `agent_session_known`
short-circuit: that flag means the context author already asked and got "none",
so re-listing the agents could only repeat the answer more slowly.

NOT DONE, deliberately: the handoff asked for a `prefix+d` keybinding.
`lib/keybindings.js:12-17` records a decision that two keys is the ceiling other
Herdr plugins observe and that "grabbing more of a shared namespace for one
plugin is the antisocial choice". A third default binding contradicts that, and
the convenience already exists at two keystrokes via `prefix+m` → `d`. Adding it
is the user's call, not something to slip in against an in-tree decision — the
env-override pattern in that file is the shape to use if they want it.

open-dashboard gains ADR 0015's two-mode shape so a test can assert the argv
without opening a browser on the developer's machine.

fail-on-old: restoring the bare `open` call (proved by probe) turns 'passes the
focused pane session to ccxray open' red while the no-session fallback stays
green — that path was already correct.

plugins/herdr suite: 215/215 (CCXRAY_HOME=$(mktemp -d)).

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

三條 codex 二審 finding 的修正:

P1 (blocker): migrateRowsArray 現在辨識並替換舊安裝器寫的 DEFAULT_ROWS
(`state_icon workspace tab` + `agent`) 為新的 `state_icon agent state_text`。
舊列不帶 `$` 前綴所以 rowLineTokens 回 `[]`,改用 regex 比對。少了這個,
用上一代安裝器建的 config 遷移後會顯��四行而非三行。

P2a (major): remove-sidebar-summary 現在認得新舊兩種 managed skeleton,
從安裝器的常數衍生而非手寫——這兩個檔案已經因為 row 1 換形狀而不同步了,
codex 正確抓到這個漂移。

P2b (major): 429 quota refusal 現在更新 MC 的 severity (→red) 與 reasons,
不只更新 action。少了這個,一個被拒的 pane 在 MC 上看起來是綠的,且
attention filter 會把它排除。

plugins/herdr suite: 218/218 (CCXRAY_HOME=$(mktemp -d)).

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

codex round 2 P1: a user-authored table (no SECTION_MARKER) that happens to
contain `["agent"]` had it silently replaced. The marker is proof the plugin
created the table, so without it the row is the user's own.

Rejected findings (recorded here per codex-loop rules):
- P2a (multi-row same line): TOML rows arrays in Herdr always put each `[...]`
  element on its own line. `herdr config check` validates this convention.
  Theoretical.
- P2b (shared-row custom token): same granularity as P2a. The installer and
  uninstaller have operated at line granularity since day one, and a mixed
  token/user row has never been produced or documented as a supported shape.
- P2c (WS 429): a correct observation that Codex WS turns carry status 101,
  but this is an existing-scope limitation — `quotaRefusalCount` reads Anthropic
  HTTP turns only, and the WS transport needs `ws-proxy.js` changes to surface
  upstream close codes, which is another PR's scope. Recorded as a known
  limitation in PANE_CONCERN_TIERS's quotaRefusalCount comment.

plugins/herdr suite: 219/219 (CCXRAY_HOME=$(mktemp -d)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Herdr caps pane metadata at 16 unique token names (set + clear combined).
The plugin was sending 12 tokens + 5 clears = 17 unique names, and the two
new row-3 tokens pushed it to 19. Every badge refresh failed with
`invalid_metadata_token` — a silent failure because the badge write is
fire-and-forget from the event handler's perspective, so the sidebar froze
on whatever the last successful write left behind.

This was invisible in unit tests because `reportPaneTokens` is mocked. Only
running `refresh-badges.js` against a real `herdr pane report-metadata`
surfaced it.

Fix: whitelist the tokens the config actually renders (xray, 4 ctx_bar
colours, facts, alert = 7). The ~10 internal tokens (ctx, model, cost, age,
turns, cache, fail, summary, ctx_bar, ctx_band) are kept in the `tokens`
object for stateLabels, notifications, and the console log, but are NOT
sent to `report-metadata`. Un-migrated configs that still reference `$ctx`
etc. keep the last value Herdr retained for them — herdr does not evict
tokens a report does not mention — and the installer migration removes
those rows. `summary`, `ctx_band`, and `ctx_bar` (superseded names) are
explicitly cleared as a one-time cleanup.

Worst-case unique names per report: 3 set (xray + 1 active ctx_bar colour +
facts OR alert) + 7 clear = 10, well within the 16 cap.

plugins/herdr suite: 219/219 (CCXRAY_HOME=$(mktemp -d)).

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

Node's http.IncomingMessage joins duplicate HTTP headers with ', '. A
ccxray-launched shell that launches another agent (via launch-agent.js)
prepends its own ANTHROPIC_CUSTOM_HEADERS — the documented #575 behaviour
that preserves a user's existing headers — so the server sees:

  x-ccxray-agent-id: herdr:wY:old, X-Ccxray-Auth: ..., X-Ccxray-Agent-Id: herdr:w1D:new

Before this fix, the raw comma-joined string was passed to
clientIdentityFromMessage, which stored it verbatim as the entry's agentId.
The badge then searched for `herdr:w1D:p3` and never matched the polluted
string, so every pane launched from a ccxray-managed shell showed
"not linked" permanently — the auto-refresh cycle was broken.

Fix: split on comma, strip header-name prefixes (`X-Ccxray-Agent-Id: `),
take the last segment that starts with `herdr:`. The innermost (newest)
launch is always appended last.

Discovered during live e2e verification of the sidebar three-row layout: the
badge rendered correctly when given a session id manually, but auto-refresh
(via pane.agent_status_changed) never matched because the persisted agentId
was the entire comma-joined string. This is a #575 regression that only
manifests when the LAUNCHING shell itself was launched by ccxray.

npm test: 2302/2302 (CCXRAY_HOME=$(mktemp -d)).
Live e2e: workspace w1D, proxy on 5605 with fixed forward.js, agentId
stored as clean `herdr:w1D:herdr-mt2pvxpz-588`, badge auto-refresh matched
via launchId, three-row rendering confirmed on herdr pane get.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lis186
lis186 merged commit 063f991 into main Aug 21, 2026
3 checks passed
lis186 added a commit that referenced this pull request Aug 22, 2026
…e2e (#587)

* test(hub): wait for a live agent before closing the pane under test

`unregisters immediately when a Herdr pane closes with SIGHUP` fired SIGHUP the
instant the client appeared in `hub status`, which is the earliest possible
moment — the top of the ~1ms window between `hub.registerClient()` resolving and
`spawnAgent()` installing the signal handlers. Inside that window SIGHUP still
has its OS default disposition, so the client dies without unregistering and the
hub keeps it until the 30s dead-client sweep, far past the test's 3s budget.

Measured: 8/20 red on this file alone (2/10 in the first sample); red on CI twice
in two days, runs 32361079591 (#575) and 32473160514 (#582). The mechanism was
confirmed by instrumenting the client — on a failing run stderr stops after
`spawnAgent` is entered, `T-handler` never prints, and the process reports
`signal=SIGHUP` rather than an exit code.

The test never established its own premise either. `agent.pid` was absent on
EVERY probed run, passing ones included: the fake agent was killed before its
first line, so the green path was "child dies by signal → code null → finish(1)
→ unregister". It verified that a client killed AFTER the handlers are armed
unregisters — never that a pane running an agent was closed.

Waiting for a live agent pid fixes both: the premise now holds, and the kill
lands after the handlers exist. 20/20 against the unchanged server, so this
stands alone as the CI unblock; the window itself is a separate commit.

Two details the wait depends on:

- the fake agent installs its trap BEFORE publishing its pid, so "pid file
  exists" implies "ready to be closed". The other order leaves a window where
  SIGHUP kills the shell outright and the client reports 1 instead of the
  agent's own 0.
- the client's exit code is asserted (0, passed through from the agent). This is
  half of a pair with the no-agent path; the other half arrives with that fix.

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

* fix(hub): arm client shutdown before registering, and gate the agent spawn on it

A hub client registered with the hub before it installed any SIGHUP/SIGTERM
handler. Until such a listener exists those signals keep their OS default
disposition and kill the process outright, skipping `hub.unregisterClient()` —
and the hub only prunes by pid liveness every 30s, so a pane closed inside that
window left a phantom client blocking hub idle shutdown for half a minute. The
window is the statusline check plus the spawn itself, about 1ms.

`armClientShutdown` (server/client-shutdown.js) is armed BEFORE
`hub.registerClient()`, and `spawnAgent` no longer installs the pair itself when
a caller owns them — it publishes its child through `opts.onChild` instead, so
one set of handlers covers both sides of the window. With an agent running the
handler still just forwards; the graceful unregister keeps living where it did.

Two things this needed beyond arming early.

The spawn must be gated on the shutdown. `exit()` fires on a hub socket round
trip, so a spawn racing it leaves the agent running with its parent gone.
Measured before the gate, with the window widened to 300ms and the unregister to
800ms: the client exited 129 while the agent it had just spawned was still alive
(`ORPHAN: true`); with the gate, `agent spawned: no`. The gate and the spawn are
synchronous from the same point, so no signal can be delivered between them.

The exit code is 0, not the OS default's 128+signum. The two sides of the window
can only agree in the common case, not by construction: with an agent running
the client PASSES THROUGH the agent's own code, and here there is no agent to
pass through. An agent that handles SIGHUP exits 0, so the client exits 0 —
measured, and now asserted on both paths. Reporting 128+signum would have kept
the window visible through `$?` for exactly the case the arming exists to hide.

SIGINT is deliberately not armed: spawnAgent installs a no-op so the terminal
reaches the foreground child, and a no-op before a child exists would leave
Ctrl-C unable to abort startup.

test/client-shutdown.test.js drives the module through its injection seams, and
caught one defect in doing so: the repeat-signal branch called `exit()` without
setting the once-only flag, so a later-settling unregister exited a second time.
Invisible in production only because `process.exit` does not return — but `exit`
is injectable, so it is not guaranteed terminal. All exit paths share one guard
now.

npm test: 2315/2315 (CCXRAY_HOME=$(mktemp -d)).
test/hub-client-signal.e2e.test.js: 20/20.

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

* fix(hub): close the shutdown races codex found, and test the window for real

Five review rounds against the previous commit; findings went 6 → 4 → 3 → 1 → 0,
and every round after the first was in code the previous round's fix had added.
Two findings were rejected with reasons, both of which the reviewer subsequently
accepted. What changed:

**The spawn gate was not adjacent to the spawn.** The previous commit's comment
claimed the gate and the spawn were synchronous, which is false on the
interactive path: `promptClaudeStatusline()` returns a pending promise and
`p.then(doSpawn)` defers the spawn, so a signal could begin the shutdown and the
user could answer the prompt before it settled. `doSpawn` now re-checks through
`opts.isShuttingDown`; the call-site gate stays.

**Register and unregister were unordered.** They are separate socket round trips,
so unregistering first let the hub apply the register afterwards and hold a pid
that had already exited — this fix's own bug through the back door. The shutdown
now waits for the in-flight registration, and the crash-recovery callback both
publishes its re-registration and declines to create one while shutting down.
`getLock` is a getter for the same reason: unregistering from the hub that
crashed releases nothing.

**Each wait is bounded separately**, because `registerClient` has 3s of its own
and one deadline spanning both would be spent before the unregister was ever
sent — in exactly the case that needs it, where the hub applied the registration
and lost only the reply. On timeout the unregister goes anyway: it releases the
slot in that case, and in the other it lands where not trying would.

**SIGINT is armed after all.** The previous rationale — that a no-op before the
child exists would leave Ctrl-C unable to abort startup — was a false dichotomy.
It routes through the same handler, which does nothing while the agent owns the
terminal and unregisters when there is no agent.

**One exit path.** The agent's own exit routes through `shutdown()` too, so a
signal racing its unregister can no longer report 0 for a child that failed with
42. Escalation moved into the signal handler and counts actual signals, including
those forwarded to a live agent — otherwise the Ctrl-C the terminal delivers to
both processes read as the user insisting, and cut the unregister short.

**Registration failure released the slot.** Both failure branches called
`process.exit(1)` from a continuation attached before any shutdown's, so it won
the race; a hub that applied the registration and lost the reply kept the slot.
They go through `shutdown(1)` now.

`signalExitCode` replaces the exit-code constant and is shared with `spawnAgent`'s
child-exit handler, so the two sides cannot drift. They still differ when the
agent traps the signal and exits 0 — passthrough is deliberate and the agent's own
code is more informative — which is one of the two rejected findings.

Testing: the claim that an e2e could not reach a 1ms window without a production
seam was wrong. It only required the hub not to be real. `test/hub-client-signal.e2e.test.js`
now stands up a fake hub socket speaking the real protocol and withholds the
`register` reply, making the window as wide as it likes with no test-only branch
in the server; it withholds the `unregister` reply too, so a missing spawn gate
has time to actually produce an agent. Differential: removing the arming fails
both cases, removing both gates fails the new one on the agent it catches.

Its first draft was wrong twice and the differential caught both — asserting the
marker immediately after exit races the spawned `sh` being scheduled and passes
either way, and a surviving orphan inherits the client's stdio so the runner hangs
instead of failing. The structural block strips whole-line comments before
matching for the same class of reason: prose reading "not process.exit" satisfied
a `process.exit` pattern.

npm test: 2328/2328 (CCXRAY_HOME=$(mktemp -d)).
test/hub-client-signal.e2e.test.js: 20/20 runs.
codex gate: clean — "the remaining risk is now below the risk of continuing to
change this file".

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

---------

Co-authored-by: Justin Lee <justinlee@91app.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant