feat: session attention state via a nexus Claude Code plugin #125

Merged
lz merged 36 commits from integration into main 2026-09-05 13:01:07 +02:00
Owner

Adds a nexus Claude Code plugin whose lifecycle hooks report each session's attention state to Nexus, so the operator can see which of N sessions is blocked waiting on them — plus a bug fix, a context saving, and one approved refactor found along the way.

Stops at the DTO by design. No UI: the sidebar rebuild had not landed when this started, so attention state is delivered to SessionDTO (both sides of the wire) and the rendering follows as a separate PR.

What's in it

A bug fix. The per-session Agent Teams toggle could only ever turn the feature on. buildLaunchCommand prefixed CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 when enabled and emitted nothing when disabled — but claude-code merges every settings scope's env over process.env, and this key sits in the unconditional half of the write allowlist, so an entry in the fleet-shared ~/.claude/settings.json (which this instance has) silently overwrote the launch prefix. Measured against 2.1.260:

CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0 claude …    -> effective value 1
claude --settings '{"env":{"…":"0"}}' …            -> effective value 0

Now carried by a per-session --settings scope (flagSettings, which merges after userSettings), so the toggle is authoritative in both directions. The merge is per key, so the operator's other env entries are untouched.

Attention state. Migration 0018 adds attention_state / attention_at to sessions. NULL means never reported and is deliberately not idle — hence no column default. Stored on the row rather than in the in-memory store the quota feature uses, because a session blocked on the operator emits no further hooks and an in-memory value could not survive a restart.

The plugin. worker/plugins/nexus/ is baked into the base image and loaded on every session by an unconditional --plugin-dir. Not a capability — capabilities are label-gated and this must reach every image; --plugin-dir is repeatable, so a Playwright session loads both.

Context saving. default-CLAUDE.md goes from 6,479 to 630 bytes — it was prepended to every turn of every session fleet-wide to document helpers most sessions never touch. The capability docs become an on-demand skill, and the dev-server host/origin table a reference the skill loads only when a server is actually running. A hash-gated re-seed replaces the file on existing installs only if the operator never edited it.

One approved refactor. The session-lookup → container-lookup → bridge-IP → assertBridgeRequest preamble was copy-pasted into four /api/agent/* endpoints and had already drifted: quota and artifacts were silently missing two of four rejection log lines, so an operator debugging a worker that could not report got nothing from half the family. Now one authorizeAgentSession.

Three hook facts measured against the binary, not the docs

Each fails silently if got wrong, and each is pinned by a test:

  • Async hooks are killed when the claude process exits. SessionEnd fires at exit, so it is the one synchronous subscription; every other is async: true. Measured both ways across four runs.
  • Notification and SessionEnd must be matcher-filtered. Notification types include auth_success and push_notification — a token refresh would read as "needs you". SessionEnd reasons include clear and resume — a /clear would mark a live session ended.
  • The SessionEnd payload field is reason, not the documented end_reason — observed as {"hook_event_name":"SessionEnd","reason":"other"}.

Verified live under a real PTY, one session, all four states:

idle (SessionStart) → working (UserPromptSubmit) → needs-you (PermissionRequest)
→ needs-you (Notification) → idle (Stop) → ended (SessionEnd)

On the tests

Six guards on this branch could not fail and were caught by mutation, not by running green — including one added specifically to prevent silent path divergence that used toContain, so it passed when the path was sabotaged to nexus2. Every guard here has since been verified to fail when the thing it protects is broken. "The suite is green" was not sufficient evidence anywhere in this branch.

Gates

117 files / 1055 tests        passed
tsc --noEmit                  0 errors
eslint                        clean
worker/nexus-report.test.sh   passed=9 failed=0
docker build --target base    plugin present, handler 100755, 6 hooks parse

Merges clean onto current main (verified with git merge-tree, exit 0).

Follow-ups, deliberately not in scope

  • The UI. Next PR: the status dot in SessionRow reusing #117's amber "waiting on you" vocabulary, plus a workspace-level roll-up for the collapsed rail.
  • resetToDefault() doesn't update the recorded hash at its write site; seedOnBoot self-heals it on the next boot instead.
  • convergeSessions clears attention on the session-relaunch path and for a stopped worker, but not on the workspace_shell respawn.
  • Stop does not fire on user interrupt, so an interrupted turn can sit at working until the next event.
Adds a `nexus` Claude Code plugin whose lifecycle hooks report each session's **attention state** to Nexus, so the operator can see which of N sessions is blocked waiting on them — plus a bug fix, a context saving, and one approved refactor found along the way. Stops at the DTO by design. No UI: the sidebar rebuild had not landed when this started, so attention state is delivered to `SessionDTO` (both sides of the wire) and the rendering follows as a separate PR. ## What's in it **A bug fix.** The per-session Agent Teams toggle could only ever turn the feature *on*. `buildLaunchCommand` prefixed `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` when enabled and emitted nothing when disabled — but claude-code merges every settings scope's `env` over `process.env`, and this key sits in the unconditional half of the write allowlist, so an entry in the fleet-shared `~/.claude/settings.json` (which this instance has) silently overwrote the launch prefix. Measured against 2.1.260: ``` CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0 claude … -> effective value 1 claude --settings '{"env":{"…":"0"}}' … -> effective value 0 ``` Now carried by a per-session `--settings` scope (`flagSettings`, which merges after `userSettings`), so the toggle is authoritative in both directions. The merge is per key, so the operator's other `env` entries are untouched. **Attention state.** Migration 0018 adds `attention_state` / `attention_at` to `sessions`. `NULL` means *never reported* and is deliberately **not** `idle` — hence no column default. Stored on the row rather than in the in-memory store the quota feature uses, because a session blocked on the operator emits no further hooks and an in-memory value could not survive a restart. **The plugin.** `worker/plugins/nexus/` is baked into the base image and loaded on every session by an unconditional `--plugin-dir`. Not a capability — capabilities are label-gated and this must reach every image; `--plugin-dir` is repeatable, so a Playwright session loads both. **Context saving.** `default-CLAUDE.md` goes from **6,479 to 630 bytes** — it was prepended to every turn of every session fleet-wide to document helpers most sessions never touch. The capability docs become an on-demand skill, and the dev-server host/origin table a reference the skill loads only when a server is actually running. A hash-gated re-seed replaces the file on existing installs only if the operator never edited it. **One approved refactor.** The session-lookup → container-lookup → bridge-IP → `assertBridgeRequest` preamble was copy-pasted into four `/api/agent/*` endpoints and had already drifted: `quota` and `artifacts` were silently missing two of four rejection log lines, so an operator debugging a worker that could not report got nothing from half the family. Now one `authorizeAgentSession`. ## Three hook facts measured against the binary, not the docs Each fails **silently** if got wrong, and each is pinned by a test: - **Async hooks are killed when the claude process exits.** `SessionEnd` fires *at* exit, so it is the one synchronous subscription; every other is `async: true`. Measured both ways across four runs. - **`Notification` and `SessionEnd` must be matcher-filtered.** Notification types include `auth_success` and `push_notification` — a token refresh would read as "needs you". `SessionEnd` reasons include `clear` and `resume` — a `/clear` would mark a live session ended. - **The `SessionEnd` payload field is `reason`, not the documented `end_reason`** — observed as `{"hook_event_name":"SessionEnd","reason":"other"}`. Verified live under a real PTY, one session, all four states: ``` idle (SessionStart) → working (UserPromptSubmit) → needs-you (PermissionRequest) → needs-you (Notification) → idle (Stop) → ended (SessionEnd) ``` ## On the tests Six guards on this branch could not fail and were caught by mutation, not by running green — including one added specifically to prevent silent path divergence that used `toContain`, so it passed when the path was sabotaged to `nexus2`. Every guard here has since been verified to fail when the thing it protects is broken. "The suite is green" was not sufficient evidence anywhere in this branch. ## Gates ``` 117 files / 1055 tests passed tsc --noEmit 0 errors eslint clean worker/nexus-report.test.sh passed=9 failed=0 docker build --target base plugin present, handler 100755, 6 hooks parse ``` Merges clean onto current `main` (verified with `git merge-tree`, exit 0). ## Follow-ups, deliberately not in scope - The UI. Next PR: the status dot in `SessionRow` reusing #117's amber "waiting on you" vocabulary, plus a workspace-level roll-up for the collapsed rail. - `resetToDefault()` doesn't update the recorded hash at its write site; `seedOnBoot` self-heals it on the next boot instead. - `convergeSessions` clears attention on the session-relaunch path and for a stopped worker, but not on the `workspace_shell` respawn. - `Stop` does not fire on user interrupt, so an interrupted turn can sit at `working` until the next event.
lz added 35 commits 2026-09-05 00:58:21 +02:00
The per-session toggle could only ever turn agent teams ON. buildLaunchCommand
prepended CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 when enabled and emitted
nothing when disabled — but claude-code merges every settings scope's `env`
over process.env, and this key is in the unconditional write allowlist, so an
entry in the fleet-shared ~/.claude/settings.json overwrites the launch prefix.
The operator's shared settings.json sets it to "1", so every session ran with
agent teams on while the UI showed the toggle off.

Measured against 2.1.260, shared settings.json = "1":

  CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0 claude ...   -> effective value 1
  claude --settings '{"env":{"...":"0"}}' ...         -> effective value 0

--settings is `flagSettings`, which merges after `userSettings`, so it wins in
both directions; the merge is per key, so the operator's other env entries,
statusLine, permissions and plugins are untouched. State the disabled case
explicitly rather than implying it by absence, and pin that with a regression
test — absence was the bug.

Also refresh worker/README.md, which still documented `--worktree` and one
session per container, both superseded by Nexus-owned worktrees (fact #9) and
the multi-session model (fact #7).
One plugin baked into the base worker image carries both halves: hooks/hooks.json
reporting session attention state back to Nexus, and the Nexus instructions moved
out of the always-on CLAUDE.md into a progressively-disclosed skill.

Hook events, matcher values and the async option are verified against the
installed 2.1.260 binary and cross-checked with code.claude.com/docs/en/hooks.
That pass caught three defects in the first draft: unfiltered Notification would
fire needs-you on a token refresh, unfiltered SessionEnd would mark a live
session dead after /clear, and exit 2 on a Stop hook prevents Claude from
stopping — so the handler's exit-0 / no-stdout contract is safety-critical.

Stops at the DTO by design. The sidebar is being rebuilt in the #98..#107 stack;
attention state lands as one more fact on the store #106 introduces, and the
rendering is designed once that merges.
Ran the spike against 2.1.260 in a live worker with a throwaway --plugin-dir
plugin — no image rebuild needed. All four questions answered; two changed the
design.

Confirmed: plugin hooks fire with no approval prompt; NEXUS_SESSION_ID and
NEXUS_URL both reach the handler; ${CLAUDE_PLUGIN_ROOT} resolves; async:true
does not delay a turn.

Changed the design:

- Async hooks are KILLED at process exit and racy near it (a 12s async hook
  logged its start and never its finish; the same script standalone finished
  normally). SessionEnd fires at exit, so it is now the one synchronous
  subscription. Everything else stays async — an interactive session does not
  exit at Stop, so the race does not arise there.

- The SessionEnd payload field is `reason`, not the documented `end_reason`:
  {"hook_event_name":"SessionEnd","reason":"other"}. Matchers are unaffected
  (logout|prompt_input_exit|other matched, clear|resume did not, confirming the
  split), but a handler parsing end_reason would read null forever.

Also records the remaining unmeasured case: Notification types are verified by
string against the binary but never fired in a non-interactive run.
Nine TDD tasks: attention column + DTO, the bridge-guarded events endpoint,
clearing state on relaunch, the plugin directory and its handler, the parity
test, baking and loading the plugin, the skill and slim CLAUDE.md, the
hash-gated re-seed, and docs.

Stops at the DTO. No client code — attention state lands as one more fact for
the redesigned sidebar to read once the #98..#107 stack merges.

The parity test pins both spike findings so they cannot silently regress:
SessionEnd stays synchronous while everything else is async, and SessionEnd
never matches clear/resume.
NULL means never reported and is deliberately not 'idle' — no column default.
Stored on the row rather than in memory so it survives a Nexus restart, which
an in-memory value could not: a session blocked on the operator emits no
further hooks to rebuild it from.

Also updates terminal/upgrade.test.ts's session fixtures for the two new
required DTO fields.
NEXUS_PLUGIN_DIR and the COPY target are one path written twice in two
languages. Divergence is silent: --plugin-dir points at nothing, hooks never
fire, the skill never loads, and nothing is logged. Nothing pins the existing
PLAYWRIGHT_PLUGIN_DIR either, so the guard covers both.
821873d hand-wrote the state set twice — the union type in db/schema.ts and
the ATTENTION_STATES array in sessions/attention.ts had nothing tying them
together, so a rename or addition to one would type-check while silently
drifting from the other. AGENTS.md's enum convention (see BIND_INTERFACES in
previews/types.ts) is to derive the type from the array. attention.ts is now
the sole source: `AttentionState = (typeof ATTENTION_STATES)[number]`;
db/schema.ts and sessions/service.ts both import the type from there instead
of declaring or re-deriving it.

This is a follow-up to 821873d rather than an amend of it: another commit
had already landed on top by the time this review feedback arrived, so
rewriting 821873d in place would mean rebasing a branch other parallel work
is actively building on.
The handler hand-writes /api/agent/events and the route is a directory; nothing
links them. A rename makes every hook 404 silently — the handler swallows curl
failures by design, so neither side logs anything.
Same two-stage bridge authorization as /api/agent/previews, reusing
assertBridgeRequest rather than reimplementing it.
The doc comment overstated the 403-with-no-detail claim: body-shape
failures return 400 before authorization runs. Scope the claim to
authorization rejections and say so explicitly.

Also assert attention_at is written on the success path, not just
attention_state — the mock previously let that write silently drop.
The prior loopback test used a bridge IP of 172.30.0.5, so a 127.0.0.1
request was rejected by the IP-mismatch check alone -- it would still
pass with isPrivateBridgeIPv4's loopback exclusion deleted entirely.

Add a case where the container's bridge IP is also 127.0.0.1, so the
mismatch check would pass and only the deliberate loopback exclusion
can reject the request. Verified as a negative control: temporarily
patching isPrivateBridgeIPv4 to accept 127.x made only this new test
fail (5 passed, 1 failed); reverting restored 6/6.
The prescribed test called clearAttention directly and asserted it worked —
which Task 1 already proves. It pinned nothing about the call site, so the
wiring could be deleted and it would still pass. Now drives convergeSessions
through the existing harness, with a negative control.
A vanished window's last state predates the relaunch. Clearing to NULL keeps
'never reported' honest instead of showing a stale needs-you.
Operator-approved scope addition. Four copies of a security-critical preamble
that have already drifted — quota and artifacts lost half their log lines.
Swapping the two calls reintroduces a race where the clear lands on top of the
relaunched session's own first report. Also names the real backstop: the next
converge tick is NOT one, since it sees the window present and skips the
branch entirely — the relaunched session's SessionStart hook is.
The session-lookup -> container-lookup -> bridge-IP -> assertBridgeRequest
sequence was copied into four agent endpoints, and had already drifted: quota
and artifacts were silent on 'container missing' and 'cannot resolve bridge IP',
so an operator debugging a worker that could not report got nothing from half
the family. Extracting it repairs those two as a side effect — strictly more
logging, no status code or response body changes.
assertBridgeRequest lived under previews/ because previews was once its only
consumer. After the authorization extraction its only production caller is
agent/authorize.ts, serving all four /api/agent/* routes — so a shared security
primitive was sitting in one consumer's domain, which is a misleading place to
look for it. The name already said "agent"; now the directory does too, and the
tests that pin it move with it unchanged.

Also drops a comment on the events route that claimed it "mirrors
/api/agent/previews" — they no longer mirror, they share a helper.
scope was a bare string used as a prefix on security log lines, so a typo
produced a line nobody could grep for. The four callback routes are a closed
set; naming them makes a typo a compile error. Adding the type immediately
rejected the test file's placeholder 'test' scope, which is the check working.
Both routes had no tests while carrying the shared authorization helper's only
untested call sites. authorize.ts's unit tests would stay green through an
inverted !sess check or a dropped worktree_name, because they test the helper
in isolation. Two cases each: authorization gates the side effect, and the
authorized session's fields reach the downstream call.
SessionEnd is synchronous and every other subscription is async: async hooks
are killed at process exit (measured in the Task 0 spike), and SessionEnd
fires at exit. The handler always exits 0 and never writes stdout — exit 2 on
a Stop hook would wedge the turn, and stdout on SessionStart is injected into
Claude's context.
Nothing links hand-written hooks.json to the endpoint's Zod enum at compile
time, so drift would 400 silently forever. Also pins the two spike findings:
SessionEnd stays synchronous, and it never matches clear/resume.
Unconditional rather than a capability — capabilities are label-gated and this
must reach every image. --plugin-dir is repeatable, so a playwright session
loads both plugins.
default-CLAUDE.md was 6.5 KB prepended to every turn of every session to
describe helpers most sessions never use. It keeps a short breadcrumb; the
capability docs become a skill, and the dev-server host/origin table a
reference the skill loads only when a server is actually running.

Claude-Session: https://claude.ai/code/session_01RS5JLcTbFRencoct6FdrE9
`DEFAULT_CONTENT mentions notify-preview` encoded the old arrangement, where
the seeded CLAUDE.md carried the full capability docs. Those now live in the
nexus skill, so the assertion moves with them rather than being deleted: the
breadcrumb must point at the skill and must NOT inline the helpers, and the
skill must still document them. Dropping the second half would have retired a
real guarantee under cover of a refactor.
The seeded flag suppresses re-seeding, so an existing install would keep the
pre-skill instructions forever. agent_instructions_last_seeded_hash has been
recorded since the feature shipped and never read; this is the drift detection
it was stored for. Operator edits are never clobbered.
Async hooks die at process exit, Notification/SessionEnd must be matcher-
filtered, and the SessionEnd field is `reason` not `end_reason`. All three fail
silently if got wrong.
The server DTO gained attention_state/attention_at and the fields travelled
over the wire correctly, but the client SessionDTO in lib/api/types.ts never
gained them — and the two declarations are structurally unrelated, so tsc was
silent. Every client consumer types against the client copy, so the sidebar
this branch exists to unblock would have hit a compile error reading a field
that was already arriving.

The plan named only the server DTO; the design doc then asserted the rail, dock
tab, hover card and workspace card would read it. All of those read the client
type. Caught by whole-branch review, not by any per-task check.

Fixed the way this repo already handles both-sides-of-the-wire types
(AGENTS.md fact #22): a derived union on each side plus a parity test, verified
to fail when the two lists diverge.
Both found by whole-branch review, both defects in the prescribed test code.

worker/nexus-report.test.sh: the two "unset" cases used bare `env`, which
INHERITS. NEXUS_SESSION_ID and NEXUS_URL are exported in every real Nexus
session — the only environment this suite ever runs in — so neither case was
ever created; they tested the both-set path twice. Proven: with the handler
mutated to exit 2 on a missing NEXUS_URL, the old form reported passed=9
failed=0 and the `env -u` form reports the failure.

hooks-parity: the matcher test only asked whether a value is one claude-code
emits, so Notification stayed green both when given auth_success and when its
matcher was deleted outright — the exact false positive AGENTS.md fact #23 says
the filter prevents. SessionEnd had a negative control; Notification is named
beside it as a MUST and had none. Both mutations now fail.
isAttentionState had zero call sites — added expecting the events endpoint to
consume it, but that endpoint validates with z.enum(ATTENTION_STATES) directly.
An exported guard nothing calls is the shape this repo has been burned by, and
the file header asserted a coupling that did not exist.

AGENTS.md fact #17 still linked previews/agent-ip-guard.ts, which the
authorization extraction moved to agent/ip-guard.ts.
"Reset to default" rewrites CLAUDE.md but not agent_instructions_last_seeded_hash,
so an operator who edited and then reset was classified as drifted forever and
would never receive another shipped default — defeating the hash gate for
exactly the operator who used the UI as intended.

Compares content rather than hashes. The first attempt tested
`currentHash === DEFAULT_HASH`, which failed under the suite's mock: it stubs
DEFAULT_HASH to a literal that is not the sha256 of the stubbed DEFAULT_CONTENT,
breaking an invariant default.ts guarantees. Comparing the normalized text is
both simpler and independent of that.

Verified by removing the branch: the new test fails.
The design doc claimed "a stopped worker's sessions likewise read NULL", but
convergeSessions returns at `if (!info || info.State !== 'running')` — well
before the only clearAttention call site. A workspace shut down while a session
was blocked would keep reporting 'needs-you' indefinitely, claiming an agent
waits on the operator long after nothing is running.

The generalizable rule is not "age the state out" — attention has no natural
TTL, a session can legitimately sit at 'working' for twenty minutes. It is
"state is only trustworthy while the thing that would refresh it exists", which
is the signal convergeSessions already computes.

Also swaps two hand-rolled FK fixtures for testing/db.ts's seedSession, removing
four `as never` casts that only existed to route around the row types.

Verified by removing the clear: the new test fails.
authorizeAgentSession did two sequential Docker round-trips: findContainer-
ByWorkerId (a listContainers call) and then getContainer().inspect(), purely to
re-read NetworkSettings.Networks[].IPAddress. The Engine returns that field on
the list response, so the second call fetched data already retrieved. Verified
against a live container rather than the type declarations alone: list and
inspect returned an identical IP.

Pre-existing — it was copy-pasted inline in three routes before the extraction —
but the branch added a fourth and by far the highest-frequency caller (events,
which fires on every turn boundary and permission prompt), and consolidating the
four made this the one place to fix it.

NOT the cached-IP pattern AGENTS.md fact #16 warns against: the value is still
resolved fresh inside each request, one line above its use. Nothing is retained
across requests.

The route tests' mocks encoded the two-call shape, so they now supply the IP on
the list response instead; `deps()` in authorize.test.ts deliberately omits
getContainer entirely, so a regression back to inspect() throws rather than
silently passing. Re-ran the loopback negative control through the new path:
disabling the exclusion still fails all three guarding tests.
refactor: collapse duplicated launch template and instance_settings upsert
All checks were successful
ci / nexus (pull_request) Successful in 12m45s
ci / images (pull_request) Successful in 25m17s
7a02976c5a
buildLaunchCommand and buildWorkspaceLaunchCommand ended this branch sharing
their entire body except two inputs — this branch's own additions
(agentTeamsSettingsFlag, NEXUS_PLUGIN_FLAG) closed the last gaps between them.
Leaving the template duplicated contradicted the file's stated principle: the
comment on resumeFlag says it was "extracted here so both launch builders change
in one place". They now delegate to one composeLaunch.

seed.ts wrote the same instance_settings upsert three times and had factored out
only one of them; now one upsertSetting with three callers.

Also moved buildLaunchCommand's doc comment back onto the function it documents
— the helpers added over this branch had pushed it ~90 lines away.

Behaviour-preserving: the launch builders' exact-string tests are unchanged and
still pass, which is what makes the collapse safe to assert.
Merge origin/main into integration
All checks were successful
ci / nexus (pull_request) Successful in 12m41s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 58s
ci / images (pull_request) Successful in 23m17s
a629eac79f
AGENTS.md was the only conflict, and it was a numbering collision rather than a
disagreement: this branch appended a fact 23 for the nexus plugin while main
gained 23 (auth gate), 24 (API tokens) and 25 (session expiry). All four are
kept; the plugin fact is renumbered to 26 and fact 18's cross-reference to it
updated to match.
lz merged commit 647e70aa7a into main 2026-09-05 13:01:07 +02:00
lz referenced this pull request from a commit 2026-09-05 13:26:19 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!125
No description provided.