A session's mark says what its agent is doing #126

Merged
lz merged 4 commits from feat/attention-ui into main 2026-09-05 13:27:45 +02:00
Owner

Stacked on #125. That PR lands attention_state on SessionDTO and renders none of it; this is the other half. The branch is main + #125 + two UI commits — once #125 merges, the diff here is just those.

The mark

The status dot, extended rather than replaced:

state mark why
working rotating green ring a turn is running
needs-you amber dot throwing a ping the agent is blocked on you
idle dim green ring the turn finished
ended grey ring claude exited; the window is still there
null today's filled dot, unchanged never reported — not idle

Every state keeps the 8px box (box-sizing is global), so a row never shifts as its state changes. Every mark carries a tooltip, because the vocabulary is new and the mark is small.

Motion is a claim about now, so only the two live states move — and they move differently: rotation reads as busy, a ping reads as calling you. Spinning a blocked agent would say the opposite of what is true. Under prefers-reduced-motion the rotating ring closes rather than freezing mid-gap, since a stopped spinner still reads as "working".

Three inputs, one entry point

workspaces.markFor(workerId, session) is the only way any surface derives a mark, because the store is the only thing holding all three inputs: the roster row, its health probe, and whether the workspace is running at all.

That third one is not academic. stopWorker writes workers.status and never touches a session row, so every session of a deliberately stopped workspace still reads status: 'running' while its probe reports the container down — derived without it, a workspace stopped on purpose paints its whole rail capsule red.

Within a running workspace: the probe owns whether claude is alive, attention owns what a live agent is doing. So an absent claude stays a fault except when the row reported endedSessionEnd fires for logout and prompt_input_exit and never for a crash. That row gets a grey ring and "restart the agent from the ⋯ menu" instead of a red "no claude running in <worktree>". A bug fix rides along: today the sidebar reports a session you closed on purpose as a crash. The converse holds too — a live claude makes an ended report stale, so it is ignored rather than believed.

The other surfaces

  • Collapsed rail needed no new affordance: needsYou(w) already paints an amber pip for a provisioning error or a degraded boot, and a blocked agent is a third input to a predicate that exists. Segments render the same marks.
  • Hover card names which session and counts the blocked ones — a roll-up you cannot resolve is a dead end.
  • Dock tabs show only the two live states. The strip is always on screen and is not a roster.
  • The workspace shell is in the roll-up, and tints its glyph amber when blocked in both the sidebar and the rail. It runs an agent like any other row, and #117 already established what leaving it out costs.
  • The session Settings panel uses the same word for the same state — it is where this feature's own tooltip sends the operator, and it used to greet them with stopped.

Verification

Eleven mutations, eleven correct failures — including adding motion to a state that should be still, so the guards bite in both directions. A generated parity suite checks the mark against the reconstructed pre-change dotClass across every health shape the prober can emit, which is what makes "null renders exactly as before" a claim rather than a hope.

Driven in a real browser on every surface, including prefers-reduced-motion toggled live (rotation → closed ring, ping → fixed halo).

1300 tests, tsc 0 errors, eslint clean.

Review pass

The second commit is a four-agent review (simplification, reuse, an adversarial audit of the comments' factual claims, and altitude). It found two regressions against pre-branch behaviour, both sitting under a comment of mine asserting the opposite:

  • The mark read health.claudeRunning while the fault line beside it reads health.health. The field is three-valued, and its 'unknown' arm ships claudeRunning: false as a placeholder, so the boolean turned "we could not look" into "claude is dead" — a red dot with no explanation under it. The other direction gave a healthy green mark beside a red "tmux window not present".
  • The workspace-liveness input above, described in a comment as "container-level truth" that session.status does not actually provide.

It also caught the hover card rendering one session's state three ways, two of which could contradict on screen (agent stopped directly above doing agent exited), and a pile of simplifications: margin-top moved off the shared rule that three of four call sites were cancelling, a dead .status-dot.exited, an unreachable guard, two hand-rolled copies of an accessor extracted in the same commit, ~15 lines of duplicated rationale, and a test the compiler already made redundant.

Not taken: feeding blocked sessions into the command palette's "Needs you" group. Real gap, but a feature rather than a repair.

Design

Settled against a live mock: the spinner idiom picked from five animated candidates, needs-you from four chip-free treatments (ping / steady ring / amber name / row tint), and the quiet states from three (rings / rings + a plain age / nothing).

**Stacked on #125.** That PR lands `attention_state` on `SessionDTO` and renders none of it; this is the other half. The branch is `main` + #125 + two UI commits — once #125 merges, the diff here is just those. ## The mark The status dot, extended rather than replaced: | state | mark | why | |---|---|---| | `working` | rotating green ring | a turn is running | | `needs-you` | amber dot throwing a ping | the agent is blocked on you | | `idle` | dim green ring | the turn finished | | `ended` | grey ring | claude exited; the window is still there | | `null` | today's filled dot, unchanged | never reported — **not** idle | Every state keeps the 8px box (`box-sizing` is global), so a row never shifts as its state changes. Every mark carries a tooltip, because the vocabulary is new and the mark is small. **Motion is a claim about now**, so only the two live states move — and they move *differently*: rotation reads as busy, a ping reads as calling you. Spinning a blocked agent would say the opposite of what is true. Under `prefers-reduced-motion` the rotating ring **closes** rather than freezing mid-gap, since a stopped spinner still reads as "working". ## Three inputs, one entry point `workspaces.markFor(workerId, session)` is the only way any surface derives a mark, because the store is the only thing holding all three inputs: the roster row, its health probe, and **whether the workspace is running at all**. That third one is not academic. `stopWorker` writes `workers.status` and never touches a session row, so every session of a deliberately stopped workspace still reads `status: 'running'` while its probe reports the container down — derived without it, a workspace stopped on purpose paints its whole rail capsule red. Within a running workspace: the probe owns whether claude is alive, attention owns what a live agent is doing. So an absent claude stays a fault **except** when the row reported `ended` — `SessionEnd` fires for `logout` and `prompt_input_exit` and never for a crash. That row gets a grey ring and *"restart the agent from the ⋯ menu"* instead of a red *"no claude running in &lt;worktree&gt;"*. **A bug fix rides along:** today the sidebar reports a session you closed on purpose as a crash. The converse holds too — a live claude makes an `ended` report stale, so it is ignored rather than believed. ## The other surfaces - **Collapsed rail** needed no new affordance: `needsYou(w)` already paints an amber pip for a provisioning error or a degraded boot, and a blocked agent is a third input to a predicate that exists. Segments render the same marks. - **Hover card** names *which* session and counts the blocked ones — a roll-up you cannot resolve is a dead end. - **Dock tabs** show only the two live states. The strip is always on screen and is not a roster. - **The workspace shell** is in the roll-up, and tints its glyph amber when blocked in *both* the sidebar and the rail. It runs an agent like any other row, and #117 already established what leaving it out costs. - **The session Settings panel** uses the same word for the same state — it is where this feature's own tooltip sends the operator, and it used to greet them with `stopped`. ## Verification **Eleven mutations, eleven correct failures** — including *adding* motion to a state that should be still, so the guards bite in both directions. A generated parity suite checks the mark against the reconstructed pre-change `dotClass` across every health shape the prober can emit, which is what makes "null renders exactly as before" a claim rather than a hope. **Driven in a real browser** on every surface, including `prefers-reduced-motion` toggled live (rotation → closed ring, ping → fixed halo). 1300 tests, `tsc` 0 errors, eslint clean. ## Review pass The second commit is a four-agent review (simplification, reuse, an adversarial audit of the comments' factual claims, and altitude). It found **two regressions against pre-branch behaviour, both sitting under a comment of mine asserting the opposite**: - The mark read `health.claudeRunning` while the fault line beside it reads `health.health`. The field is three-valued, and its `'unknown'` arm ships `claudeRunning: false` as a placeholder, so the boolean turned "we could not look" into "claude is dead" — a red dot with no explanation under it. The other direction gave a healthy green mark beside a red "tmux window not present". - The workspace-liveness input above, described in a comment as "container-level truth" that `session.status` does not actually provide. It also caught the hover card rendering one session's state three ways, two of which could contradict on screen (`agent stopped` directly above `doing agent exited`), and a pile of simplifications: `margin-top` moved off the shared rule that three of four call sites were cancelling, a dead `.status-dot.exited`, an unreachable guard, two hand-rolled copies of an accessor extracted in the same commit, ~15 lines of duplicated rationale, and a test the compiler already made redundant. Not taken: feeding blocked sessions into the command palette's "Needs you" group. Real gap, but a feature rather than a repair. ## Design Settled against a live mock: the spinner idiom picked from five animated candidates, `needs-you` from four chip-free treatments (ping / steady ring / amber name / row tint), and the quiet states from three (rings / rings + a plain age / nothing).
lz added 37 commits 2026-09-05 11:47:58 +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.
feat(ui): a session's mark says what its agent is doing
All checks were successful
ci / nexus (pull_request) Successful in 13m12s
ci / images (pull_request) Successful in 27m35s
441da71415
PR #125 lands attention state on SessionDTO and nothing renders it. This is
that half: one mark, on the three surfaces that show a session.

The mark is the status dot, extended rather than replaced. `working` rotates,
`needs-you` pings, `idle` and `ended` are still rings, and a session that never
reported renders exactly as it did before — null is not idle. Every state keeps
the 8px box (box-sizing is global), so a row never shifts as its state changes,
and every mark carries a tooltip because the vocabulary is new.

Motion is a claim about now, so only the two live states move, and they move
differently: rotation reads as busy, a ping reads as calling you. Spinning a
blocked agent would say the opposite of what is true. Under reduced motion the
rotating ring CLOSES instead of freezing mid-gap, since a stopped spinner still
reads as working.

Two authorities, one mark. The probe owns whether claude is alive; attention
owns what a live agent is doing. So an absent claude stays a fault EXCEPT when
the row reported `ended` — SessionEnd fires for logout and prompt_input_exit and
never for a crash, which is what makes a deliberate exit distinguishable. That
row now gets a grey ring and "restart the agent from the ⋯ menu" instead of a
red "no claude running", which is a bug fix riding along: today the sidebar
reports a session you closed on purpose as a crash. The converse holds too — a
live claude makes an `ended` report stale, so it is ignored rather than believed.

The collapsed rail needed no new affordance: `needsYou(w)` already paints an
amber pip for a provisioning error or a degraded boot, and a blocked agent is a
third input to it. Its segments render the same marks, so the 52px rail says
what the expanded sidebar does, and the hover card names which session — a
roll-up that cannot be resolved is a dead end. Dock tabs show only the two live
states: the strip is always on screen and is not a roster.

The workspace shell is included in the roll-up and tints its own glyph amber
when blocked. It runs an agent like any other row, and PR #117 already found
what leaving it out costs: a signal that renders nowhere is one nobody can act
on.

Guards were mutation-tested, not assumed. Nine deliberate breakages — dropping
the exit excuse, believing a stale report, renaming a state's CSS rule, removing
the rotation, adding motion to a still state, deleting the reduced-motion
fallback, ignoring status in the roll-up, excluding the shell, inventing an age
— each produced exactly one failure, and the intended one.

Verified in a browser against a stubbed roster, since convergeSessions correctly
nulls attention for a worker whose container does not exist and wipes seeded
rows within a poll.
fix(ui): one derivation, three inputs, and two regressions it was hiding
Some checks failed
ci / nexus (pull_request) Has been cancelled
ci / images (pull_request) Has been cancelled
5654338984
Multi-agent review of the previous commit. Two of the findings were real
regressions against behaviour that shipped before this branch, and both were
sitting under a comment of mine asserting the opposite.

The mark read `health.claudeRunning`; `unhealthyReason` beside it reads
`health.health`. Those are not the same question. `health` is three-valued, and
its 'unknown' arm — container gone, session row gone — ships claudeRunning:false
as a placeholder rather than as an observation, so the boolean turned "we could
not look" into "claude is dead": a red dot asserting a fault the probe had
explicitly failed to establish, with no explanatory line under it because that
line gates on the field the dot ignored. The other direction was worse: a live
claude whose tmux window is gone reported healthy-green beside a red "tmux
window not present". Both now read `health.health`, so the two cannot disagree.

The second was in the guard I had described as container-level truth.
`session.status` is not that — it is the per-session "is the agent meant to be
up" flag, and `stopWorker` writes `workers.status` alone and never touches a
session row. Every session of a deliberately stopped workspace therefore still
reads 'running' while its probe reports the container down, which painted the
whole rail capsule red for doing exactly what was asked. Workspace liveness is
now a third input, and since only the store holds all three,
`workspaces.markFor(workerId, session)` is the single entry point every surface
goes through — a component that reaches for `sessionMark` itself has to
remember an input, and the cost of forgetting is that regression.

The hover card was rendering one session's state three ways, and two of them
could contradict on screen: `agent stopped` printed directly above
`doing agent exited` for a session that exited on purpose. It is one row from
one derivation now. The Settings panel — where this feature's own "restart it
from the ⋯ menu" tooltip sends the operator — said `stopped` where the tooltip
said exited; it now uses the same word. The rail's shell segment tints amber
when blocked, so a blocked workspace shell is named rather than only counted,
and the roll-up predicate the pip and the card each filtered for themselves is
one store method.

Simplification: `margin-top` moved off the shared `.status-dot` rule onto the
one surface that is a text row, deleting three overrides and their three
near-identical comments; a dead `.status-dot.exited` with no producer; a guard
in `unhealthyReason` that its only caller made unreachable; two hand-rolled
copies of `sessionRow`; ~15 lines of rationale stated twice; a test that could
not fail because the compiler already enforced it; and a store test that
asserted what its subject returns by construction.

Guards proven again by mutation: reintroducing either regression fails the new
generated parity suite, which checks the mark against the reconstructed
pre-change function across every health shape the prober can emit. Re-verified
in a browser on every surface.

A finding I did not take: feeding blocked sessions into the command palette's
"Needs you" group. Real gap, but it is a feature rather than a repair.
merge: origin/main after #125 landed
All checks were successful
ci / nexus (pull_request) Successful in 16m54s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 21s
ci / images (pull_request) Successful in 29m42s
b26b777ed1
Conflicts were all in the files this branch and the new-session affordance both
touched, and all resolved by keeping both sides — except one that was real.

Main taught `dotClass` a `creating` → `starting` state; this branch had replaced
`dotClass` with the shared derivation. `starting` moves INTO `$lib/attention.ts`
rather than staying at the call site, because a value of the mark living outside
the module that owns the mark is exactly the drift `sessionMark` exists to stop
— and the parity suite's reconstructed baseline is updated to match, so it keeps
checking against what main actually renders today rather than what it rendered
when this branch started.

`starting` outranks the workspace-liveness check: the row exists because the
operator just asked for it, and reporting it as 'stopped' would contradict the
optimistic row it hands over from. Proven by mutation — deleting the clause
fails four of the generated parity cases.

Worth noting for later: main's `starting` is amber and pulses, and `needs-you`
is amber with a ping. They cannot occur on the same row (a creating session has
reported nothing yet) and the motions differ, but that is two amber animations
in one sidebar and it is the kind of thing that only reads wrong once there are
several workspaces on screen.
lz merged commit 073cfb7237 into main 2026-09-05 13:27:45 +02:00
lz deleted branch feat/attention-ui 2026-09-05 13:27:48 +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!126
No description provided.