feat: session attention state via a nexus Claude Code plugin #125
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "integration"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Adds a
nexusClaude 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.
buildLaunchCommandprefixedCLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1when enabled and emitted nothing when disabled — but claude-code merges every settings scope'senvoverprocess.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:Now carried by a per-session
--settingsscope (flagSettings, which merges afteruserSettings), so the toggle is authoritative in both directions. The merge is per key, so the operator's otherenventries are untouched.Attention state. Migration 0018 adds
attention_state/attention_attosessions.NULLmeans never reported and is deliberately notidle— 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-diris repeatable, so a Playwright session loads both.Context saving.
default-CLAUDE.mdgoes 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 →
assertBridgeRequestpreamble was copy-pasted into four/api/agent/*endpoints and had already drifted:quotaandartifactswere 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 oneauthorizeAgentSession.Three hook facts measured against the binary, not the docs
Each fails silently if got wrong, and each is pinned by a test:
SessionEndfires at exit, so it is the one synchronous subscription; every other isasync: true. Measured both ways across four runs.NotificationandSessionEndmust be matcher-filtered. Notification types includeauth_successandpush_notification— a token refresh would read as "needs you".SessionEndreasons includeclearandresume— a/clearwould mark a live session ended.SessionEndpayload field isreason, not the documentedend_reason— observed as{"hook_event_name":"SessionEnd","reason":"other"}.Verified live under a real PTY, one session, all four states:
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 tonexus2. 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
Merges clean onto current
main(verified withgit merge-tree, exit 0).Follow-ups, deliberately not in scope
SessionRowreusing #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;seedOnBootself-heals it on the next boot instead.convergeSessionsclears attention on the session-relaunch path and for a stopped worker, but not on theworkspace_shellrespawn.Stopdoes not fire on user interrupt, so an interrupted turn can sit atworkinguntil the next event.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).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.