Land the sidebar-issues work from the release candidate onto main #116

Manually merged
lz merged 50 commits from rc/sidebar-96 into main 2026-09-04 17:36:57 +02:00
Owner

Brings the four sidebar-issues PRs onto main. Everything else the RC carried — the #96 stack (#98/#99/#100/#101/#106/#107), #103 and #104, and #105 with its conflict resolved — has already landed on main through its own PR, so this merge is exactly those four and nothing else: +370 −201 across 21 files.

Contained here, each already reviewed and merged into the RC:

  • #112 — one washed-out fill per tab, and a 2px bottom border for the current one
  • #113 — the muted text tokens lifted to WCAG AA
  • #114 — the sidebar as a full-bleed list; colour only marks what varies
  • #115env_var_count on WorkerDTO, plus the client/server parity guard

Why a merge and not four fresh PRs

They were reviewed and merged against the RC while it was the trial artifact, so re-opening them against main would duplicate a record that already exists. Merging keeps every commit and every merge commit in main's history rather than squashing them, so git log main still shows the four individually.

Verified before merging

rc → main was confirmed conflict-free, and the RC was gated after integration: typecheck 4898 files / 0 errors, lint clean, 121 files / 1201 tests.

rc/sidebar-96 is deleted after this lands. It existed to be a deployable trial with a rollback to :latest, and main now carries everything it did.

Brings the four `sidebar-issues` PRs onto `main`. Everything else the RC carried — the #96 stack (#98/#99/#100/#101/#106/#107), #103 and #104, and #105 with its conflict resolved — has already landed on `main` through its own PR, so this merge is exactly those four and nothing else: **+370 −201 across 21 files**. Contained here, each already reviewed and merged into the RC: - **#112** — one washed-out fill per tab, and a 2px bottom border for the current one - **#113** — the muted text tokens lifted to WCAG AA - **#114** — the sidebar as a full-bleed list; colour only marks what varies - **#115** — `env_var_count` on `WorkerDTO`, plus the client/server parity guard ## Why a merge and not four fresh PRs They were reviewed and merged against the RC while it was the trial artifact, so re-opening them against `main` would duplicate a record that already exists. Merging keeps every commit and every merge commit in `main`'s history rather than squashing them, so `git log main` still shows the four individually. ## Verified before merging `rc → main` was confirmed conflict-free, and the RC was gated after integration: typecheck 4898 files / 0 errors, lint clean, 121 files / 1201 tests. `rc/sidebar-96` is deleted after this lands. It existed to be a deployable trial with a rollback to `:latest`, and `main` now carries everything it did.
lz added 50 commits 2026-09-04 17:29:50 +02:00
fix(sidebar): let the workspace list scroll on mobile
All checks were successful
ci / nexus (pull_request) Successful in 7m45s
ci / images (pull_request) Successful in 8m29s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 9s
f1aded9690
`.left-pane` sets `flex-shrink: 0` for the desktop row layout. Under 768px
`.main-split` flips to `flex-direction: column`, so that shrink factor starts
applying to the vertical axis: the pane grows to its full content height and is
clipped by `.main-split { overflow: hidden }`. `.lp-body { overflow-y: auto }`
therefore never gets a constrained height to scroll inside, and everything past
the first viewport is unreachable.

Give the pane `flex: 1 1 auto; min-height: 0` in the mobile block only.

Measured at 390x740 with 12 cards in the list, on the real page:

  before  pane 1488px, lp-body client 1488 == scroll 1488, scrollTop stuck at 0
  after   pane  683px, lp-body client  683 <  scroll 1488, scrolls freely

Desktop is unaffected -- at 1280px the pane still computes to 360px with
`flex-shrink: 0`, since the media query does not apply there.
feat(workers): show real startup progress, and let a failed provision retry
All checks were successful
ci / nexus (pull_request) Successful in 8m13s
ci / images (pull_request) Successful in 10m28s
78fc7bc8de
The card used to claim "running · healthy" the instant container.start()
returned, while the entrypoint spent another 30-90s cloning the repo and
running `npm install -g @anthropic-ai/claude-code@latest`. Every session action
failed in that window with no explanation on screen.

Adds a `booting` phase that survives finishProvisioning and is cleared by
checkWorkerHealth on the first successful `tmux has-session` probe -- tmux
answering IS the definition of usable. While booting, the card shows the last
`==> ` marker the entrypoint printed; entrypoint.sh already emits those, so no
worker-side change was needed.

Adds pull progress. pullWorkerImage already yielded per-layer
{id, current, total} and provisionWorker discarded all of it except as a
boolean "has anything happened yet" flag. PullProgress folds those into a
percentage and a layer/byte line, throttled to one DB write per 750ms (docker
emits per layer per chunk) with a forced final write so the bar cannot freeze
mid-percentage. Percent is null -- an indeterminate bar -- whenever any layer
lacks a known total, rather than showing a number that races upward and jumps
back as sizes arrive.

Adds Retry on a failed card. It cannot restart anything: provisionWorker tears
the container down on every failure path and container_id was never written, so
retryWorker re-enters the pipeline from `queued` on the same row, keeping the
frozen mounts_json snapshot it was created with. It needs the master key to
rebuild the sealed secret block, so it is an operator route and never something
convergeSessions could do. The provider is resolved BEFORE the row is claimed,
so a retry that fails early leaves the row in `error` and retryable.

Two traps handled:

- failStuckProvisioning must SPARE `booting`. Nothing in Nexus drives that
  phase -- the container is started and its entrypoint runs to completion
  whether or not Nexus is up -- so sweeping it would errored-out every
  workspace booting across a Nexus restart. Verified live: on a harness restart
  the two `pulling` rows failed and the `booting` row was untouched.
- Session controls are gated on the phase, not just on `running`. Sessions, the
  shell agent and the view panels all need tmux, so leaving them live during
  boot just moves the failure one click later.

Verified in a browser against a seeded instance: determinate bar at 62% with
"4 of 7 layers · 218.0 MB / 351.0 MB", indeterminate bar when a layer size is
unknown, the booting card showing its entrypoint marker with no "start agent"
button, POST /retry rejecting a non-errored row with 400, and a failed retry
leaving the row's original error message intact.
fix(workers): correct pull layer counting and stale phase detail
Some checks failed
ci / images (pull_request) Has been cancelled
ci / nexus (pull_request) Has been cancelled
37e9968951
Both found by running the real worker image through a real dind daemon, not by
the suite.

1. The tag was counted as a layer. Docker's stream-level "Pulling from <repo>"
   event carries an `id` -- the TAG, not a layer -- so observe() registered it
   alongside the real layers. Measured against docker 29.6.2: a 5-layer image
   reported 6. Stream-level statuses are now filtered out.

2. Completion could not be inferred from bytes. A cached layer reports
   "Already exists" and transfers nothing, so it never reaches a total and was
   counted as incomplete forever. Layer completion now comes from the terminal
   statuses ("Pull complete" / "Already exists"), which is what docker actually
   tells us. A fully cached pull previously read "0 of 6 layers · 0.0 MB"; it
   now reads "5 of 5 layers", and the byte clause is dropped entirely when
   nothing was transferred rather than shouting "0.0 MB".

3. Phase detail outlived its phase. advanceProvisionPhase left provision_detail
   and provision_percent in place, so the card showed "Creating container… 100%"
   with the pull's byte count under it. Observed live at the pulling→creating
   transition. Advancing now clears both; the new phase writes its own.

Live trace after the fixes, against x.lck.sh/lz/agent-nexus-worker:latest
(318 MB, 20 layers) pulled from an empty local store:

  queued
  pulling   pct=null  1 of 20 layers · 5.3 MB
  pulling   pct=38    15 of 20 layers · 122.5 MB / 318.1 MB
  pulling   pct=100   19 of 20 layers · 318.1 MB / 318.1 MB
  creating  pct=null  (no detail)
  booting   Cloning https://x.lck.sh/lz/agent-nexus.git
  booting   Registering forgejo-mcp
  booting   Updating @anthropic-ai/claude-code to latest
  null      (ready -- healthy, tmux up, repo cloned on main)

Percent is null only for the first ~10s, while docker is still announcing layer
sizes; once every total is known it rises monotonically to 100. The earlier
worry that the determinate bar would rarely appear did not hold up.
refactor(workers): tail the container log once per health poll
Some checks failed
ci / nexus (pull_request) Successful in 9m51s
ci / images (pull_request) Has been cancelled
67135b61e2
A `booting` worker tailed its container log TWICE in the same poll: once to
read the entrypoint's `==> ` marker, and again to fill the report's
recentLogs. tailLogs resolves the container itself (findContainerByWorkerId
lists containers), so that was two extra docker round-trips every 10s for every
second of the boot window.

One tail now feeds both, and the `booting` test is hoisted into a local instead
of being re-derived at three call sites.

Also merges a duplicated `.bar i` CSS rule.
refactor(workers): apply /simplify review — plus two real gaps it found
All checks were successful
ci / nexus (pull_request) Successful in 8m39s
ci / images (pull_request) Successful in 12m18s
cb0263ccef
Four review passes over the diff (reuse / simplification / efficiency /
altitude). Two of their findings were behaviour bugs, not tidiness:

1. `startWorker` never set `booting`. Restarting re-runs the whole entrypoint
   (claude-code update, tmux start), so Stop -> Start went straight back to a
   green card whose every session action failed -- the exact defect this branch
   exists to remove, surviving on the path nobody tested. One field in an UPDATE
   that already existed. Verified live: Start now shows "Starting up..." with a
   live marker until tmux answers.

2. `booting` was the only phase with no failure exit. Every other in-flight
   phase is bounded (`pulling` by PROVISION_PULL_STALL_MS, the rest by
   failStuckProvisioning), but excluding `booting` from that sweep removed its
   only bound without adding another -- so an entrypoint stalled on, say, its
   wait-for-secrets loop would show a cheerful "Starting up..." forever. Bounded
   by BOOT_GRACE_MS against `last_seen_at`, which was already stamped. Past it
   the phase is DROPPED rather than errored: the container really is running, so
   plain `unhealthy` is the honest label.

3. `WorkspaceRail` painted a booting workspace green and opened a terminal
   against a container with no tmux. `isRunning`/`isReady` are now exported once
   from the store and used by both the rail and the card, instead of a fifth
   inline derivation.

Quality fixes:

- pull-progress.test.ts was a BINARY file. A test for docker's 8-byte frame
  header embedded literal NUL bytes, so git classified the whole file as binary
  and `git diff`/`git blame` were useless on it. The defence was dead anyway:
  the only caller passes tailLogs output, and tailLogs already runs demuxLogs.
  lastBootMarker now anchors with startsWith -- which also stops a "==> "
  appearing mid-line in unrelated output from counting as a stage.
- The throttle moved out of PullProgress into the loop that owns the clock,
  deleting a `force` parameter that had no caller and whose doc comment
  described a mechanism that was never wired.
- snapshot() folded four passes over the layer Map into one, and two returns
  repeating the same template into one.
- The indeterminate bar animated `margin-left`, forcing layout+paint on the main
  thread every frame for the whole boot window, on a page also running xterm
  terminals. Now `transform: translateX`, which is composited.
- `provision_detail: null, provision_percent: null` was written five times; now
  one CLEAR_PROGRESS spread.
- The booting transition is extracted as `settleBootingPhase`, so the decision
  is testable against the in-memory DB rather than only reachable inside a
  dockerode-bound function.
- recentLogs is no longer shipped while booting -- nothing reads it, and this
  branch turned a rare fault path into the routine path for every spawn.

Deliberately NOT taken: structuring provision_detail into columns
(provision_error is already free text in the same table), a server-side `ready`
DTO field (a third representation of what tmuxRunning already carries), and a
shared formatBytes (no drop-in exists; the nearest is private to a component and
uses a different base).
fix(dock): persist floating panels across a reload
Some checks failed
ci / nexus (pull_request) Successful in 10m14s
ci / images (pull_request) Has been cancelled
45d8717000
Shift-drag a panel out of the dock, reload, and it was silently gone — no error,
no watermark clue. `stripArtifactPanels` rebuilt the layout with only
grid/panels/activeGroup, so every float was dropped on the way to localStorage
while shift-drag stayed enabled. Floating was half-implemented in the worst
direction: the gesture worked, the result did not survive.

Floats are now REPAIRED rather than dropped — the same treatment the grid
already got. A float loses only the views that were actually stripped; one that
loses all of them is removed; one that keeps some is kept with the survivors.

Three things the fix has to get right that a naive `.data.views` filter misses:

- **dockview 7 serializes a float in two mutually exclusive forms.** `data` when
  the floating window hosts a single group, `grid` when it hosts several (see
  `SerializedFloatingGroup`). Repairing only `data` would have silently discarded
  every multi-group float — the same class of loss this fix exists to stop. The
  nested form reuses `pruneGridObject`, since a float's grid is the same shape as
  the dock's.
- **`activeGroup` can name a FLOATING group.** Collecting surviving ids from the
  grid alone would drop the active-group pointer every time the operator left a
  float focused.
- **A float in neither form is dropped, not trusted** — it may reference a
  stripped panel.

popoutGroups and edgeGroups keep being dropped: popouts need a static/popout.html
that does not exist, and neither is reachable from the UI, so nothing is silently
lost there.

The test the spec named for inversion (it pinned the dropping behaviour, and its
staying green is why nothing flagged this) is replaced by ten cases covering both
serialized forms, activeView re-pointing, activeGroup on a float, and the
deliberate limitation that a layout whose grid does not survive still returns
null even if a float would have.

Verified against the old implementation: the five tests asserting the new
behaviour fail without the fix; the invariants still pass. Verified in a browser
by restoring a float from localStorage and reloading three times — the panel is
present each time and the float is re-serialized intact.

KNOWN, NOT FIXED HERE: the float's POSITION. dockview passes the stored position
to `addFloatingGroup`, but `constrainBounds()` runs immediately after
deserialization and calls `overlay.setBounds()`, which re-clamps against a
container that may not be measured yet; the restored float landed at the dock
origin and the next save then persisted 0,0. Whether that reproduces for a float
created by a real shift-drag needs a manual check — it is dockview-side and
outside what D8 prescribes.
fix(dock): prune tab groups, which name panel ids too
All checks were successful
ci / nexus (pull_request) Successful in 7m52s
ci / images (pull_request) Successful in 10m27s
7281aa4100
`views` was not the only field on a group's view state that references panel
ids — `tabGroups[].panelIds` does as well. It rode through the `{...data}`
spread unexamined, so a tab group could keep naming an artifact panel that had
just been stripped: a dangling reference persisted by the one function whose
job is to persist none.

TypeScript could not see it. The local narrowed `PanelViewState` deliberately
omitted every optional field of dockview's `GroupPanelViewState`, and its own
comment listed `tabGroups` among them — so the field was carried at runtime and
invisible at compile time. It is now modelled locally, with the rule stated:
model a field here the moment it can name a panel.

Latent rather than live — Nexus never creates tab groups, and no saved layout
carries the key today. Fixed anyway because a dangling-reference vector inside
the module that exists to eliminate them is incoherent whether or not anything
currently triggers it.

Emptied tab groups are dropped rather than persisted with an empty panelIds
array. Verified the three new tests fail against the previous implementation and
the fourth (no tab groups present) passes either way.
fix(dock): keep a float-only layout instead of wiping storage
Some checks failed
ci / nexus (pull_request) Successful in 10m15s
ci / images (pull_request) Has been cancelled
c5c13e2063
The previous commit shipped a hole I had documented as a deliberate limitation.
It was not one, and it sat on the most natural way to try floating at all.

Floating a panel calls `removePanel(item, { removeEmptyGroup: true })` and
`removeGroup` fires when the source group empties (dockviewComponent.js:1332,
:2576). So floating your ONLY panel leaves a childless branch as the grid root.
`pruneGridObject` returned null for that, `stripArtifactPanels` returned null,
and `saveLayout` then REMOVED the stored layout — wiping the float on the next
save. The same silent loss the previous commit set out to fix, reached by a
different route, and reachable by the single most obvious gesture.

The reasoning I recorded for it ("dockview needs a grid root, not worth risking a
fromJSON throw") is factually wrong for 7.0.2. `_doFromJSON` validates only
`root.type === 'branch' && Array.isArray(root.data)`
(dockviewComponent.js:1937-1939); an empty branch passes, and is exactly what
`Gridview.clear()` produces for an empty dock. Verified in a browser: injecting
an empty-branch root plus one float restores the float, renders it, and
round-trips through three reloads with a forced relayout each time.

So `null` now means one thing only — nothing survived ANYWHERE — which is what
saveLayout's removeItem branch is actually for. Those two states were conflated.
When the grid is empty but floats survive, the grid is emitted as an empty
branch, preserving the original root's own fields rather than rebuilding it.

The test that asserted the old behaviour is inverted, and joined by the literal
post-float state (already-empty branch root) and by the genuine nothing-survives
case, so the two are no longer confused.

Found by an adversarial review of the previous commit. It was right and I was
wrong: I had called this a corner case, and it is the primary path.
fix(dock): restore floats at their saved position, and guard the field set
Some checks failed
ci / nexus (pull_request) Successful in 9m50s
ci / images (pull_request) Has been cancelled
2adc3d3a77
Three findings from an adversarial review of this branch.

**Floats came back in the corner.** dockview hands sizing to ShellManager, whose
only size source is a ResizeObserver deferred through requestAnimationFrame — so
the component is still 0x0 on the synchronous line where we call fromJSON. That
captures 0x0, and the constrainBounds() fromJSON runs at the end clamps every
restored float against a zero-height container: clamp(top, -100, 0) is 0, and
the same for left. The float lands at the dock origin, and because the clamp
writes style.top, a later save persists 0,0 and the position is gone for good.

One line fixes it: lay the dock out before restoring. Deferring the restore into
a rAF would be a race instead, since dockview's own observer callback is already
inside one.

Measured, with a negative control first: seeding a float at top 137 / left 211
and reloading rendered it at 0px/0px before the change and at 137px/211px after,
stable across reloads with a forced relayout each time.

**The whitelist rebuild had no guard, only comments.** `stripArtifactPanels`
rebuilds its result from a hand-listed set of SerializedDockview fields, and
that is exactly how floats were lost for a release: a field existed, nothing
referenced it, no test noticed. The justification for still dropping
popoutGroups/edgeGroups was a comment asserting a fact about the UI that nothing
enforced. There is now a compile-time assertion over `keyof SerializedDockview`,
in the same idiom as the `_exhaustive: never` guards in mounts/validate.ts.
Verified it fires by dropping a key from the allowlist:

  ERROR layout.ts 185:7 Type 'boolean' is not assignable to type
  '{ UNHANDLED_DOCKVIEW_KEY: "edgeGroups"; }'

The next dockview upgrade that adds a serialized field now fails the build and
names it, instead of silently discarding operator state.

**The narrowed local types are gone.** `GroupPanelViewState` is not re-exported
by name, but it is reachable structurally as
`NonNullable<SerializedFloatingGroup['data']>` — through the very type this
branch already imported. The hand-narrowed copy is what let the tabGroups bug in
two commits ago: it omitted the one field that names panel ids, so the spread
carried it through invisibly. The hand-rolled SerializedTabGroup mirror goes too;
dockview exports it (`export * from 'dockview-core'`). What was a standing
promise to mirror the right fields is now structural.

Also adds the invariant the reviewer rated above every individual case: whatever
else happens, a branch root in produces a branch root out. dockview rejects a
non-branch root outright, so it is the single property deciding whether the blob
we persist is restorable at all.
fix(dock): drop maximizedNode from floats, and guard the grid shape too
All checks were successful
ci / nexus (pull_request) Successful in 9m12s
ci / images (pull_request) Successful in 8m51s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 19s
df7d39489a
The guard added in the previous commit was one level too shallow, and the field
it missed was live.

`maximizedNode` is a POSITIONAL reference — `{ location: number[] }`, an index
path into the grid tree — so it is invisible to a panel-id audit. The float
branch spread it through with `{ ...float.grid, root }` while the main grid,
which rebuilds field by field, dropped it. Pruning shifts or deletes the indices
it names, so `Gridview.deserialize` then calls `getNode(location)` and throws
'Invalid location' (gridview.js:767-769): the STRIPPED layout fails to restore
where the unstripped one would have. Measured against real dockview 7.0.2 in
jsdom, not a fixture.

Unreachable in 7.0.2 — `maximize()` early-returns unless the group's location is
'grid', so a float's nested gridview cannot carry the field — but the asymmetry
was the bug: two rebuild sites, two different answers, one of them unsafe. Both
now drop it. Dropping beats repairing: an index path cannot be corrected after
pruning without re-deriving it, and maximize state is not worth that.

The deeper problem is why the guard did not catch it. `AssertNoUnhandledKeys`
was parameterised over `keyof SerializedDockview`, which pins the TOP-level field
set only — while the grid sub-object at the rebuild site is a second hand-listed
set, and hand-listed sets are precisely the mechanism that lost floats for a
release. Widening to `keyof SerializedDockview['grid']` would not work either:
dockview declares that member inline with four fields
(dockviewComponent.d.ts:99-105) while `toJSON` assigns it
`this.gridview.serialize()` — a full `SerializedGridview` with five
(dockviewComponent.js:1832, :1840). The declared type understates what the
runtime writes, so a guard over the declared shape is blind by construction.

So the second assertion guards `SerializedGridview` itself, the shape actually
written. Verified it fires by removing a key:

  ERROR layout.ts 225:7 Type 'boolean' is not assignable to type
  '{ UNHANDLED_DOCKVIEW_KEY: "maximizedNode"; }'

Found by the same adversarial review, re-run against the previous commit. Its
verdict on the rest of that commit: idempotence and purity survive the rewrite,
`emptyBranchRoot` holds on both arms, tabGroups pruning reaches both float forms,
and the layout-before-restore call has no zero-size mount to trip on.
feat(workers): the step strip D2.5 specifies, and the retry tests
All checks were successful
ci / nexus (pull_request) Successful in 9m23s
ci / images (pull_request) Successful in 10m20s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 16s
1772a55ca2
Two gaps an audit of the shipped code against the spec turned up.

The five-segment step strip (queued / pulling / creating+starting / booting /
ready) is in every provisioning frame of the mock and was absent from the card.
`creating` and `starting` share a segment deliberately: both are sub-second
docker calls, and a segment that lights for 40ms reads as a glitch. `Ready` is
never the active step — a workspace reaches it by `provision_phase` going null,
at which point the strip is gone — and a test pins that rather than trusting the
switch to stay honest.

On `error` the strip renders nothing. Pointing at a failed step would claim to
know which one failed, and the row does not record it; the card already shows
the reason plus Retry / Logs / Remove.

`retry.test.ts` is named in the spec's testing plan and did not exist. Its three
cases now sit at the layer that actually owns each rule: the vault gate and the
error mapping are the route's, so they live beside it; the failed-state guard
and the frozen-mounts reuse are the service's.

The mounts case is the one worth having. It seeds a repo default that DIFFERS
from the row's snapshot and asserts which one reached `docker.createContainer`,
so it fails with the actual regression rather than on a mock call count —
verified by pointing retryWorker at `resolveSpawnMounts` and watching it go red
naming `/mnt/newer-default`.
Two different things claimed a card's left edge: status (running / stopped /
booting / errored) and identity ("these panels are docked, and this is their
tab colour"). Worse than crowding, three of the eight SESSION_PALETTE hues --
--danger, so the two channels could read as each other.

Identity moves to a background wash on the ROW (and the dock tab), leaving the
border to status. The status dot's fill is never recoloured, so the signals no
longer compete for the same pixels.

Two alphas, not one: --wash-row 0.10 reads on a 330px sidebar row and
disappears entirely on a 40px rail tile, which needs ~0.22. One global value
cannot serve both surfaces.

sessionColourRgb() is DERIVED from SESSION_PALETTE rather than written out a
second time -- a parallel hand-maintained list would drift silently, and the two
must agree by construction because they identify the same target. A test pins
them together.

A wash is a fill, and the sidebar already fills for hover, so hover on a washed
row becomes a border change instead of a third fill.

Also promotes the .bar track/fill to app.css: QuotaBadge already had the same
class name and the same track+<i> idiom, so the card was the second copy, not
the first.
The load-bearing piece of the sidebar declutter. `buildMenu` is pure and
framework-free, so the three menu shapes (session / workspace shell / workspace)
are unit-tested without mounting anything; TargetMenu.svelte only renders what
it returns.

The four views come first in a fixed order in every shape, and an already-docked
view is marked with a check -- that is the signal the lit icon buttons carried,
which is what makes removing them from the row possible.

DockView gains `ports` and `settings`, since both are panels rather than
modals. Their renderers land in the next commits.

Two things the component has to get right:

- It is PORTALLED to <body>, via a real DOM move rather than `position: fixed`.
  Fixed escapes ancestor `overflow` clipping, but any ancestor with a transform
  becomes its containing block -- and both surfaces this opens from have one:
  dockview transforms its panels, and `.right-pane` is translated on mobile.
  Inside those, a fixed menu is positioned against the wrong origin and clipped
  again.
- It is placed AFTER mount, once it has a measured size to clamp against the
  viewport, and stays hidden until then rather than rendering at 0,0 for a
  frame. Opened near an edge, an unclamped menu is one whose last items cannot
  be reached.

Under 768px it renders as a bottom sheet instead: a long-press has no cursor to
anchor a popup to.
Ports leaves the sidebar. The preview list previously rendered inline under
every session row -- a form, a row per port, and a URL, repeated per session
whether or not anything was forwarded -- which is most of what made the sidebar
heavy. Ports are keyed on a session row, and the workspace shell IS one
(kind='workspace_shell'), so a workspace-scoped panel resolves to the shell's
id rather than having no target.

Settings is a panel and not a modal on purpose: a modal blocks the terminal
behind it, and editing an env var while watching the agent restart is a normal
thing to want.

Its Environment and Mounts sections are READ-ONLY, and say why. Both tables are
keyed on repo_full_name, not on a worker, so neither is workspace-local -- and
they are not both "defaults" either: editing env pushes into every RUNNING
workspace on that repo immediately, while mounts are frozen onto
workers.mounts_json at container create and reach only the next one. An editable
table under a "workspace" heading would silently reconfigure siblings.

Picks up three of issue #95's asks:

- No confirm on revoke. It tears down a listener, destroys no work, and
  re-adding is one field away -- a modal was friction on a reversible action.
- Operator-created ports auto-approve. Approval exists to gate the AGENT
  callback, where something inside a container asks Nexus to open a listener on
  the operator's machine. An operator typing a port into their own UI has
  already expressed that intent; asking them to then approve their own request
  is a confirmation of the thing they just did. It reuses approvePreview rather
  than inlining a second insert path, so host-port selection and the proxy bind
  stay in one place, and a bind failure degrades to a pending row instead of
  failing the create.
- The remapped host port is shown when it differs from the requested one, so a
  URL that doesn't match what the dev server printed explains itself.
A session row was five hit targets -- the name, three view icons and a remove
button -- with a whole preview list rendered underneath it. It is now two: the
NAME (which opens the terminal, the one action worth a first-class target) and
⋯. Right-click anywhere on the row does the same as ⋯.

The workspace shell moves to the TOP of the card as its own row. That is what
fixes the naming complaint at its root: "start agent" was ambiguous because it
sat on the workspace header next to Start/Stop (the container). It is now
"Start/Stop shell agent" on the row it controls, and container power is
"Start/Stop workspace" in the workspace menu.

Deleted: ViewButtons (its icon set survives inside the menu), the five-button
.card-actions row, the inline <Previews> block under every session, and the
per-row × button. Retry is the one action that stays a button -- it is the only
thing an errored card can do, and burying the sole recovery path in a menu
would be perverse.

The ports chip renders only when a port is actually forwarded, where the inline
list rendered a heading and a form on every row regardless.

NOT included, deliberately: a "Restart agent" item. There is no restart-session
endpoint, and a menu row that does nothing is worse than an absent one.

Two preview tests pinned the pre-#95 behaviour (manual previews landing
pending). Updated rather than worked around, plus a new case for the bind
failure degrading to pending instead of failing the create.
Once anything is docked, switching views no longer requires going back to the
sidebar -- which is what makes collapsing it lossless.

Cheap because SessionTab already receives kind/workerId/sessionId/basePath and
resolves them with scopeFromParams(), which IS the ExplorerScope the sidebar
menu takes. DockDeps gains openPanel / isViewOpen / closeOtherPanels so a tab
can act on its own target.

The tab variant deliberately carries no lifecycle or destructive items:
removing a session from under one of its own open panels is not something to
offer at the pointer, and a tab has no sensible place for the workspace's
container controls. Views + Close + Close others, and nothing that would be
dead.
RAIL. One bordered capsule per workspace: initials on top, then one zero-gap
segment per target. The shared border is what makes a session read as belonging
to its workspace -- the loose 9px dots said nothing about who owned them, and at
81px² were barely clickable. A segment is 40×24 = 960px². The rail is
desktop-only (MainSplit gates it on isDesktop), so the bar is WCAG 2.2's 24×24
minimum, which 24px segments meet exactly and the 9px dot failed outright.

The capsule's left border carries STATUS, matching the wash carrying identity
everywhere else. A booting workspace gets a progress segment so the one status a
collapsed rail must not hide stays visible, and a warn pip marks anything
waiting on the operator.

HOVER CARD, replacing a one-line `${kind} ${name}` tooltip that told the
operator less than the row it replaced -- so collapsing the sidebar cost
information, not just space. Three things it had to get right:
  - opens on focus as well as hover; the segments are buttons and a keyboard
    user got nothing at all before
  - portalled to <body>, because the rail scrolls and its overflow clips
    anything past 52px
  - ONE timer shared across segments, so sliding down the rail does not re-arm
    a full delay at every stop

PILL. Reaching the dock on mobile used to REQUIRE opening a panel, since
openPanel() was showDock()'s only caller: forward always mutated the layout
while coming back was free. The pill is the missing free direction. It renders
only when something is docked, so it is never a control leading nowhere, and it
carries identity-coloured dots rather than a numeral -- the colours say whose
panels are waiting, which a count cannot.
The browser pass caught both. A ports or settings tab fell through the icon
chain to the artifacts glyph, so three different views wore the same icon. And
the card still listed every mount plus "change → remove & re-create", which the
Settings panel now owns and states more precisely -- saying it twice is exactly
what the declutter is meant to stop.
Five real defects, four of them invisible to the suite:

- The workspace card's ⋯ button rendered UNSTYLED. `.icobtn` lived only in
  Sessions.svelte's scoped <style>, so it compiled to `.icobtn.svelte-<hash>`
  and never matched Workers' button. Hoisted to app.css, which is where a
  two-component class belongs.
- The identity wash never painted on the collapsed rail. `.seg`'s
  `background:` SHORTHAND resets background-image, and the scoped rule outranks
  the global `.washed-tile` (two classes vs one), so the rail showed no colour
  at all. Now `background-color`.
- The ⋯ menu could not be closed with Escape, and a keyboard user could not
  reach its items. `el.focus()` ran while the menu was still
  `visibility: hidden` — focus() on a hidden element is a silent no-op — so
  focus stayed on the trigger. Focus after the second tick, and hand it back to
  the invoker on close (the spec asked for both).
- The Ports panel never refreshed. It declared a `tick` prop dockview cannot
  supply (params are serialized scalars, frozen at create), so Previews loaded
  once and a port registered by notify-preview never appeared. `tick` now rides
  on DockDeps, the live proxy, assigned in Dock's existing $effect.
- Stopping the shell agent made the workspace's OWN views unreachable.
  closeWorkspaceSession deletes the shell session row, the shell row was gated
  on that row existing, and the workspace card's menu carries no view items —
  while the ViewButtons this replaced deliberately kept Files working with no
  agent. The shell row now always renders, reading "agent stopped".

Menus also advertised Shift-F / Shift-A / Ctrl-W, which nothing binds; the
hints return with the Ctrl-K palette that implements them. The rail's hover
card promised a right-click menu the rail does not have.

Simplifications:

- One `portal` action in $lib/actions (was three byte-identical copies, each
  with a different comment explaining the same trap).
- `.dock-empty` to app.css (six copies, already two variants).
- `buildMenu(variant, ctx)` — `scope` was read only by its own default-argument
  expression, and dragged a required prop through TargetMenu for nothing.
- `sessionColourRgb` looks up from `sessionColour` instead of re-running the
  hash; agreement is now structural rather than asserted by a test.
- `washVar(scope, on)` replaces the same template at four call sites, and the
  rail no longer calls hasPanels twice per segment.
- `workspaces.openShell()` — the card menu and the shell row had byte-identical
  copies, toast strings included.
- `hasPanels` derives from `openTargetKeys` rather than walking panels twice.
- DockView's set is declared ONCE, and Dock's factory and SessionTab's icons are
  `Record<DockView, …>`: a sixth view is now a compile error, where it threw at
  runtime and silently rendered the artifacts glyph. panel-target.test.ts
  iterates VIEWS — it had gone stale at three of five views.
- Dead: MenuContext.envCount (no caller ever set it), WorkspaceRail's isOpen
  prop, RailHoverCard's duplicate mount-time place(), and a full-page
  location.href where goto() keeps the terminals connected.

Verified in a browser against a seeded instance: menu contents per variant,
portalling, Escape/scrim dismissal and focus return, the wash painting at both
alphas on all four surfaces, a preview inserted mid-session appearing without
interaction, the mobile bottom sheet and panels pill, and the sidebar still
scrolling on a phone.
`workers.worker_image` has always been a column and was never on the DTO, so
three surfaces had no way to name the image: the rail's hover card, the
workspace card's chips, and the settings panel. The hover card's row LABELLED
"image" printed `container_name` instead — the one fact a collapsed rail cannot
otherwise recover, reported as something else entirely.

Capabilities are looked back up from the image list rather than read off the
row, because nothing persists what `applyImageCapabilities` resolved at create.
The consequence is stated in the store: chips describe the image as it is now,
not as the container was built, and a deleted or re-pulled tag degrades to fewer
chips. Nothing branches on them.

`.chip` moves to app.css. Three components render the same vocabulary — a health
word, a capability, a mount count — and three scoped copies is exactly how
`.icobtn` ended up unstyled on one of its two callers.

The card's status line becomes those chips (D4), which retires `.badge-degraded`
and its bespoke amber pill.
Session-agent lifecycle did not exist. `sessions/[sessionId]` was GET + DELETE,
and DELETE destroys the worktree — so the only thing an operator could do to a
misbehaving claude was remove the session it was running in. That single gap
explains four separate holes at once: the D3 menu's agent rows, D6's Agent
section, and the mock's session pane.

`POST` restarts, `DELETE` stops, mirroring the verb pairing the workspace shell
already uses at `sessions/workspace`. Neither touches the row or the worktree.

Both go through `tmux respawn-window -k` rather than killing the window, and for
stop that is load-bearing: `convergeSessions` restores any session whose window
is MISSING while its worktree is present (fact #15), so a stop that removed the
window would be silently undone within one 10s poll. Respawning it as plain bash
keeps the window present — converge sees it and does nothing — while claude is
genuinely gone and the health probe says so.

Restart resumes the pinned `claude_session_id`. An operator restarting an agent
wants the conversation back, not a blank one.

`PATCH` flips `agent_teams_enabled`, which was settable only at create. It takes
effect on the next launch, because claude reads the env var at startup and
`buildLaunchCommand` prepends it per process (fact #8). The UI says so rather
than restarting the agent behind the operator's back.
refactor(ui): one menu dispatcher on four surfaces, and the D6 panel
All checks were successful
ci / nexus (pull_request) Successful in 8m40s
ci / images (pull_request) Successful in 9m18s
c07700ad40
The audit's blocking finding: "every action reachable from the sidebar is
reachable from a dock tab's context menu, so the sidebar can be collapsed
without losing capability" was false. The tab menu was a hand-written fourth
shape carrying four views plus Close, so ten-odd actions had no route to it.

The cause was three near-identical `onPick` switches — Workers, Sessions,
SessionTab — each knowing a different subset of `MenuAction`. `menu-actions.ts`
is now the only one. The two `-tab` variants are COMPOSED from the sidebar's own
item builders plus Close / Close others, so the criterion holds by construction
rather than by anyone remembering, and `target-menu.test.ts` asserts the
superset relation across all four toggled-row combinations. Verified by
rebuilding `session-tab` as a hand-written shape and watching that test go red.

Surfaces now differ only in what they can PROVIDE. `MenuActionHost` lives in
MainSplit because two of its three members act on the sidebar — the log modal
and the create-session form — which a dock tab cannot otherwise reach. That,
not the switches, is why "Container logs" and "New session…" had no tab route:
LogsModal was local state inside a component that is not rendered at all while
the sidebar is collapsed.

Also closed, all from the same audit:

- **The rail had no context menu**, leaving the collapsed sidebar able to open
  terminals and nothing else — the one surface where "collapse without losing
  capability" matters most. Decision 2 says the menu opens on a row, a rail
  segment or a tab; now it does.
- **D6 was a stub** — two links and no `<button>` at all. It now carries the
  agent's live state with Restart / Stop, the agent-teams toggle, created +
  usage, the image and its capabilities, container state with Start/Stop, the
  repo's env KEYS (values are sealed and the DTO has no field for them), and
  both Remove actions. Name and label stay read-only on purpose: `sessions.name`
  is simultaneously the tmux window, the branch and the worktree directory, and
  `workers.label` is baked into every running claude's `--remote-control` argv,
  where a rename breaks the health probe until each agent is relaunched. A
  rename is a feature, not a text input.
- **The hover card** gains usage and which views are docked. Not the claude
  version or pid: neither is probed anywhere, and session health is fetched by
  <Sessions>, which is unmounted exactly when the rail is showing.
- **The pill's 4-dot cap was silent.** D11 claims the dots convey the count as
  well as whose panels they are; four dots standing for seven quietly made that
  false. A hollow overflow dot says so — a numeral is what D11 rules out.
- **The edge swipe was missing entirely.** Thresholds live in `edge-swipe.ts`
  as a pure function so the vertical clamp — the thing that stops a thumb
  scrolling the list from flipping the pane — is testable.
- **The ports panel body still said "Previews"** with a count, against
  Decision 3 and Decision 11. The API surface keeps its name: renaming
  `notify-preview` would break every worker image in the field.

`/api/quota` was fetched once per workspace CARD on its own interval; it answers
for the whole fleet in one call, so it now rides the single poll like everything
else. `SessionRow` and `ProvisionProgress` come out of the two sidebar files,
which is also what finally makes their net line count FALL (662 → 627) rather
than rise, as the spec requires.

Browser-verified at 1400px and 390px: the tab menu's twelve rows, the rail menu,
both settings scopes, the hover card's corrected image row, the overflow dot,
and the edge swipe in both directions plus its scroll guard.
fix(sessions): the health probe was matching itself, not claude
All checks were successful
ci / nexus (pull_request) Successful in 9m31s
ci / images (pull_request) Successful in 10m45s
3e594a975b
Found by running the new stop/restart endpoints against a real container, which
is the first thing that can produce the state the bug hides in: a live worker,
the tmux window present, and no claude in it.

The probe was `/bin/sh -c "<script>"` where the script greps every
`/proc/*/cmdline` for `--remote-control <workspace · session>`. The script text
CONTAINS that pattern, so the loop reached its own `/proc` entry and exited 0.
Measured in the container: a session name that had NEVER existed reported claude
as running, and so did a session whose agent had just been stopped.

Pre-existing — but invisible until now, because nothing could previously make a
running container hold a present window with no claude. Stopping an agent is
exactly that state, so shipping stop without this means the action succeeds and
then the UI reports the agent as running, on the sidebar dot and in the new
settings panel's Agent chip.

Matched by CWD instead, the same shape `probeClaudeWorkspaceProcess` already
used and immune for the same reason: the first 7 bytes of the probe's own
cmdline are `/bin/sh`, and its cwd is not a worktree. Still per-session — two
sessions in one worker have different worktree names — which is the property
fact #5 wanted from the argv match.

Verified against a real container before and after: `claudeRunning` now goes
true → false → true across stop and restart, and the reason names the worktree.

Tests for both new endpoints come with it, plus the dispatcher's. The
`setSessionAgent` set pins the three decisions that are easy to undo: `-k` (or
respawn refuses), reuse of the window rather than `new-window`, and `--resume`
rather than the bare id — that last assertion was too weak until a negative
control showed `resume: false` emits `--session-id <same id>`, which carries the
id while telling claude to start a fresh conversation under it.

`menu-actions.test.ts` derives its action list from `buildMenu` across every
variant rather than hand-listing it, so an action a menu can offer but the
dispatcher ignores fails the suite. A hand-listed set is exactly what let the
dock tab ignore twelve of fifteen actions while everything stayed green.
refactor: apply /simplify — including a regression two reviewers caught
All checks were successful
ci / nexus (pull_request) Successful in 8m54s
ci / images (pull_request) Successful in 10m9s
45f2b31106
Four cleanup agents over the audit-fix commits. Two of them independently found
the same functional regression, which is the one worth reading:

**`setSessionAgent` dropped `-e NEXUS_SESSION_ID`.** tmux builds a spawned
pane's environment from the server environment plus the `-e` arguments and does
NOT remember a window's original ones, so a respawn has to re-pass it —
`openWorkspaceSession` already does, on its own respawn, which was the
codebase's own evidence. Without it, `notify-preview` and `notify-artifact`
stop working in that session after a Restart (fact #16), silently and until the
session is recreated. The stop path had the same hole for the bash it leaves
behind. Fixed by pulling the launch out into a shared `spawnSessionPane` rather
than by adding the flag in a fourth place; the test now pins the env block, and
removing it goes red.

**`workspace-tab` offered `open-shell` twice** — as "Start shell agent" and as
"Open workspace shell", two rows firing one action under different names. The
guard test compared LABELS, so it passed; my own browser check compared labels
too, so I confirmed the wrong thing and reported no duplicates. The variants now
compose through `dedupeByAction`, the `withSettings` flag that patched the one
earlier collision is gone, and the test compares actions across all four toggle
states.

Also:

- "Session log" is deleted, not fixed. It opened the same terminal panel as the
  row four above it, minus the ✓ that says the panel is already docked. There is
  no separate transcript: the agent's log IS its tmux window.
- The repo-env effect in `SettingsPanel` was a poller. `workspaces.workers` is
  reassigned to a fresh array every tick, so an effect reading
  `worker?.repo_full_name` re-ran forever; it now derives the string first and
  only runs for the scope that renders it.
- The panel's health probe gets the same 8s TTL `<Sessions>` uses (both poll the
  same session when the sidebar is open), stops re-probing straight after a
  restart (the respawn does not wait for claude, so it painted "stopped"), and
  its Restart/Stop go through the dispatcher — which is also how Stop acquired
  the confirm it was missing while the identical menu action had one.
- Toggling agent-teams no longer refetches the whole fleet to reflect a boolean.
- `MenuActionHost`'s sidebar members are required; the two toast fallbacks no
  caller could reach are gone. `closePanel`/`closeOthers` stay optional and the
  header now says plainly that they are silent, instead of claiming everything
  degrades to a toast.
- One `probeClaudeCwd` for both probes, run concurrently — neither gates the
  other and both are docker execs. `reload()` likewise stops serialising two
  independent refreshes.
- Smaller: `readJsonBody`, `isRunning` and `WORKTREES_ROOT` instead of fourth
  copies; a dead `sessionStats.get()`; a separator that rendered above the first
  row of the workspace menu; Dock's sham `menuHost` seed replaced by a getter;
  and the restart/stop JSDoc moved onto the function it describes.
fix(sessions): apply /pr-review — a tmux target that hits the wrong window
All checks were successful
ci / nexus (pull_request) Successful in 8m40s
ci / images (pull_request) Successful in 9m9s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 9s
3c05a7c6d3
Two adversarial reviewers over the audit-fix commits. The critical one is
verified against real tmux, not reasoned:

**`respawn-window -t nexus:<name>` resolves an all-digit name as an INDEX.**
tmux tries the window half of a target as an index before it tries it as a
name, and `1` / `42` are legal git refs the create form accepts. Measured on
3.3a with windows `0:workspace 1:alpha 2:beta 3:"1"`, `-t nexus:1` resolves to
**alpha** — and the `=` exact-match prefix does not change it. So Restart on a
session named `1` SIGKILLs alpha's claude, relaunches session 1's agent inside
alpha's window (where notify-preview then attributes to the wrong session), and
leaves alpha dead permanently: convergeSessions only restores a session whose
window is MISSING, and alpha's window is right there.

Now resolved to a window ID first. The same hole existed in `deleteSession`'s
`kill-window`, older than this PR and worse — it would destroy a bystander's
window outright — so that site moves too. Resolving also means a Restart on a
session whose window went missing now CREATES it instead of failing, which is
the state the panel reports while leaving the button enabled.

**Stop left `sessions.status = 'running'`.** That column is what the rail and
its hover card read, so a stopped agent showed green and "agent: running" —
while the expanded sidebar, which reads the probe, painted the row RED with a
"no claude running in <worktree>" line. The operator stops an agent on purpose
and one surface says it is fine, another says it crashed. `'stopped'` was
already in the union, so this is a write and a guard, no migration.

**Restart claimed to resume a conversation it had not resumed.** `resumeFlag`
emits nothing for the empty `claude_session_id` that migration 0012 backfilled,
so on an upgraded install Restart silently started a fresh conversation under
the old toast, and left the row permanently unresumable. It now mints and pins
an id the way `openWorkspaceSession` does, and reports `resumed: false` so the
toast says which of the two things happened.

**Neither action waited for claude.** tmux exiting 0 only means the pane was
respawned; the launch line falls through to `exec bash` if claude dies on
startup. Both paths now wait — the shape `openWorkspaceSession` already used —
and return `{confirmed}`, so "Agent relaunched, but claude has not come up yet"
is sayable instead of an unconditional success.

The silent-failure pass found four more places the operator is told nothing:
`goto` was the one awaited call outside a try (a rejected navigation became an
unhandled rejection); "Copy branch name" returned silently when the roster had
no branch, leaving the previous clipboard contents to be pasted into a checkout;
"New session…" on a booting workspace expanded and scrolled and then found no
form; and a failed repo-env fetch rendered as "None set for this repository" —
an affirmative claim produced by not knowing, on the pane whose whole job is
stating a blast radius. `worker-images` also latched a transient failure for the
life of the page, so one bad response hid every capability chip.

Two more the reviewers caught in the merged workspace tab: it offered "Stop
shell agent" beside "Open workspace shell" (distinct actions, so the new
dedupe did not collapse them) — fixed in the builder, since offering to open a
running shell was equally wrong on the card; and a session Settings panel
restored from localStorage rendered the WORKSPACE pane, complete with a live
"Remove workspace…", during the window between listWorkers and listSessions.

Verified: the getter on `deps.menuHost` does survive the `$state` proxy (an
accessor has no `writable`, so the trap falls through to `Reflect.get` and the
getter runs on every read), `lastProbedAt` is per-instance, and `dedupeByAction`
keeps its separator. Five new tests cover the numeric name, exact-name matching,
the missing-window fallback, the unresumable row and the status write; each was
confirmed to go red against the behaviour it describes.
D7. A filter over the workspaces store, an overlay, and a keydown listener.

Index = every workspace, every session, and each of the five DockViews for each
of them, plus "New workspace…". Enter opens the highlighted target's terminal;
Tab narrows to that target's views; Escape backs out of the narrowing before it
closes the palette, so leaving costs the same keystrokes as entering.

An empty query is not "everything": it shows the two groups worth showing
unprompted — **Needs you** (workspaces stuck in pulling/booting or errored, and
previews awaiting approval) and **Recent**. Once the operator types, the list is
FLAT and ranked: grouping there would put a pending-port row above the workspace
you just named, and Enter would open the wrong thing.

Two deviations from the spec, both deliberate:

- The spec says "no API". Needs-you's pending previews are not in any client
  store — previews are fetched per session — so this adds one read-only route,
  GET /api/previews/pending, behind the same operator gate as everything else.
  Without it the group could only report half of what it promises.
- The spec has Tab step into "the D3 menu, rendered inline". Half of that menu
  (new-session, container logs) is wired to component-local state that a
  palette cannot reach, so a faithful version would ship rows that do nothing —
  and each new onPick switch is a new place for exactly that. Tab narrows to
  the target's views instead, which is the drill-down a palette is for and
  offers nothing dead.

Also here because the palette is what makes them reachable:

- The rail's pinned footer. At 52px there was no way to search or to create a
  workspace without expanding the sidebar first.
- A header Ctrl-K chip, which is the whole affordance on a phone. Hidden off
  the main page, where the palette is not mounted and it would do nothing.
- NewWorkerModal moves from Workers up to MainSplit: three surfaces raise it
  now and only one of them is the list. Focus returns to whichever raised it.

isPaletteChord is a pure predicate with its own test, because the interesting
part is what it must NOT match and that is only checkable in a test. Measured in
a real browser: Ctrl-K and Ctrl-P are preventDefault'd (both are browser chords
in a tab), Ctrl-Shift-K is left to the devtools console, and a caps-locked 'K'
with no Shift still opens the palette.

Verified in a browser: open by chord/chip/rail, the Needs-you group against
seeded stuck workspaces and pending previews, ranking, Tab-drill and Shift-Tab,
Enter opening a panel and recording a recent, the modal from all three entry
points with focus return, and the mobile layout at 390px.
Three defects, one of them the kind only a reactivity trace finds:

- The index was rebuilt on every 10s poll with the palette CLOSED. A $derived
  is lazy, but the $effect that clamped activeIndex READ it, and effects are
  eager — so each new `workspaces.workers` array reference pulled buildIndex →
  filterRows → groupRows and threw the result away. Measured at a 6x4 fleet:
  ~181 row objects and ~750 string allocations every 10s, unread. `index` is now
  gated on `palette.open`, so a closed palette does not even subscribe to the
  roster, and the effect depends on `query`/`drill` instead of the result set.
- Focus restore after "New workspace…" was a silent no-op. Raising the modal
  FROM the palette destroys the input that had focus, so the captured invoker
  was already detached. Guarded with isConnected, and nulled after use so it
  stops pinning a button the next poll's re-render has replaced.
- The palette printed the raw phase enum at the operator — "pulling" where
  every other surface says "Pulling image…". It now uses provisionLabel.

Needs-you was also incoherent: it listed workspaces that were merely slow while
omitting DEGRADED ones, which boot without secrets, look healthy and cannot
push. The rail already flags those. `workerNeedsYou` becomes `needsYouReason`,
returning the reason so the row can show it and so the precedence is explicit —
a real failure outranks a degraded boot, which outranks being slow. It is
deliberately NOT the rail's predicate: the rail's pip answers "needs attention
NOW" and rightly excludes the transient phases. Two questions, two answers; only
the names were ever similar, and now neither is.

Accessibility: the palette was a search overlay whose arrow keys moved a purely
visual highlight. Now a proper combobox — role + aria-expanded +
aria-activedescendant on the input, listbox on the results, option +
aria-selected per row. Verified in a browser that activedescendant tracks the
highlight across ArrowDown.

Reuse:

- `workspaceScope` / `sessionScope` were hand-written in FOUR modules, including
  the /workspace/.nexus/worktrees/<name> path formula, which has to agree with
  what createSession actually made on disk. One definition in main-page/scope.ts.
- `VIEW_LABEL` joins the view set it belongs to in dock/panel-target.ts; the
  menu builder now reads its labels from there rather than spelling out the same
  five names a second time.

Also corrected a comment that claimed the pending-previews query is bounded by
pending rows: that is true of the result, not the scan — approved rows persist,
so it reads the whole table. Still the right call at this scale, but the reader
should not be told otherwise.
Closes a latent bug the review found NEXT DOOR to this PR: `viewItems` in
target-menu.ts was a hand-written four-element array — the one per-view list
with no compile-time guard. Dock's panel map and SessionTab's icon map are both
`Record<DockView, …>`, so a sixth view fails to compile there; added to
viewItems it compiled clean and was simply absent from every ⋯ menu, on every
surface, with nothing to catch it. Now derived from VIEWS.

`needsYouReason` stops enumerating phases. `provision_phase` is nulled on the
first successful tmux probe, so non-null already means "still coming up, or
failed" — which also covers queued/creating/starting, dropped by the old list
while its own comment argued for including them. Nothing left to drift.

`PaletteRow` carried each row's target three times: as `action.scope`, as a
`scope` field always identical to it, and as a `colourKey` that was `targetKey`
of the same thing. One `rowKey(row)` accessor replaces both fields. Drilling is
keyed on that too, which deletes the one-caller `sameScope`, a non-null
assertion and a guard.

`GET /api/previews/pending` returned `{previews}` — the only list GET in the
codebase that wraps; all eight others return a bare array. Unwrapped, and the
one-off generic in the client goes with it. It also had no test, the only new
server surface in this PR without one; now covered for both the fleet-wide
pending set and the empty case.

The header no longer tests `pathname === '/'` to decide whether to show its
Ctrl-K chip. AGENTS.md calls out this exact file for deriving visibility rather
than hardcoding a route — a literal there once hid Settings from the operator
who needed it. The palette now reports its own mount, so the chip follows what
exists rather than where it is mounted. Falls out of that: the Ctrl-K chord is
claimed from the browser ONLY on a page that has a palette, verified.

Smaller: `groupRows` already returns the shape the identity `.map()` rebuilt;
`query.trim()` was recomputed five times beside a `searching` that was used
once; the reset effect no longer lists `drill`, which drillInto/drillOut already
handle synchronously (an effect would paint one frame on the old row first);
GROUP_ORDER is module-private; a dead class attribute and a stray blank line.

Reviewed and left alone, with reasons: the fleet-wide read and its absent
try/catch (single operator by design, and its GET siblings do the same);
PaletteAction's two-member union (a third member is a compile error at the
destructure); building the index per open rather than caching.
One request per poll instead of one per session
-----------------------------------------------
Sessions.svelte fetched the whole preview list for EVERY session on EVERY tick
and read `.length` off each response, to render a chip. The palette then
fetched the pending set again on each open. Both now read one fleet-wide list
the workspaces store loads alongside the roster.

`GET /api/previews/pending` becomes `GET /api/previews`, unfiltered: two callers
want different slices of the same tens-of-rows table, and neither slice is worth
a round trip of its own. The palette still calls `refreshPreviews()` on open, so
a port an agent registered three seconds ago is not poll-stale — that is what
the Needs-you group exists to catch.

Measured in a browser over one poll cycle: per-session preview calls 0 (was up
to one per session per mounted card), fleet-wide calls 1.

A store for the new-workspace modal
-----------------------------------
It was `$state` in MainSplit threaded as an `onNewWorkspace` prop through
Workers, WorkspaceRail and CommandPalette — which left the rail's two adjacent
footer buttons doing the same job by two different mechanisms, one store and one
prop. Prop-drilling both was never available: Header sits above MainSplit in the
layout, so the palette had to be a store. Now both are, and three Props entries
and three threading sites go with it.

The invoker capture moves onto the store with the flag, rather than being split
between an opener in MainSplit and a restore in a closure — one owner of "who
opened this and where focus goes back to". The isConnected guard survives: the
palette destroys the focused element on its way out.

$lib/storage.ts
---------------
The guarded localStorage accessors were written three times (dock/layout.ts,
main-page/sidebar-state.ts, palette/palette-recents.ts). This is a move, not an
abstraction: no key handling, no serialisation, no type parameter, and every
module keeps its own injectable-for-vitest signature. Both guards are documented
in one place now — `typeof localStorage` for SSR and the node test environment,
try/catch for a browser that has the global and still throws (Safari private
mode, blocked site data).

It also clears layout.ts's header comment, which pointed at a sibling module
(`right-pane-tab.ts`) deleted some time ago.

New tests: the store's preview loading (one call per refresh, stale list kept on
failure, pending vs total, refreshPreviews without a roster read), and
listAllPreviews returning both statuses.
fix(dock): drop the second VIEW_LABEL the rebase left behind
All checks were successful
ci / nexus (pull_request) Successful in 8m13s
ci / images (pull_request) Successful in 8m44s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 9s
d164870f48
The menu unification below this PR and the palette both wanted the same Record,
and each added one. Two `export const VIEW_LABEL` in one module is a compile
error, so this could not survive — but it is worth saying which copy stayed: the
palette's, which documents why the ellipsis on `Settings…` belongs at the call
site rather than in the name.
tmux resolves the window half of a `session:window` target as an INDEX
first, falling back to a name match only if that fails. Session names are
git refs, and `1` / `42` are legal refs the create form accepts, so both
remaining name-based targets could act on a different session's window.

Measured on tmux 3.3a with windows `0:workspace 1:alpha 2:"1"`: `nexus:1`
and `nexus:=1` both resolve to **alpha** (the `=` exact-match prefix does
not help), while `@2` is the window actually named `1`. `send-keys -t
nexus:1 -l TEXT` was delivered into alpha's pane.

- artifacts/tmux-nudge.ts: an operator nudge aimed at the session named
  `1` was typed into another agent's claude prompt, and Enter pressed. The
  list-windows call that already verified the window now also carries
  `#{window_id}`, and the sends target `@N`. The
  `{delivered:false, reason:'window not found'}` contract is unchanged.
- terminal/exec-pty.ts: the browser terminal attached to and displayed the
  wrong session's pane, so the operator typed into an agent they had not
  picked. `buildTmuxAttachArgv` stays a pure argv builder and now takes
  the id; `openTerminalPty` resolves it (one extra exec per terminal-open,
  not a poll path) and rejects when no window carries the name rather than
  falling back — handleTerminalUpgradeCore already answers an openPty
  rejection with `500 failed to open terminal`, so no new failure mode.

`VIEW:@ID` still leaves the original `nexus` session's own active-window
pointer untouched, so the grouped-session shape is otherwise unchanged.

lib/tmux-window.ts holds the shared `-F` format and its parser so the
measurement is documented once instead of at each call site.
Five places where the same fact was fetched twice, or handed in by a
caller that happened to know it.

Session health moves into WorkspacesStore. Sessions.svelte owned the
sessionHealth probe, and that component is unmounted for exactly as long
as the collapsed rail is showing — so the rail's hover card could not
report a live agent, and the menus opened from a rail segment or a dock
tab had no answer at all. SettingsPanel had grown a second probe on the
same endpoint behind the same TTL to compensate. The probe now runs once,
off the tail of refresh(), with the TTL bookkeeping in a plain Map for
the reason the old comment gives.

menuContextFor no longer takes an `extra` argument. portCount and
agentRunning were the last two fields passed in by hand and only one of
four callers passed them, so three surfaces showed no count on the Ports
row and all three offered "Stop agent" for an agent that was already
stopped — the exact defect class the helper exists to prevent.

/api/quota is polled once, not twice. QuotaBadge and the stats store each
had a 10s timer for two halves of one payload. The poll moves into the
store behind a subscriber refcount, because the badge renders on pages
where MainSplit is not mounted and MainSplit renders where Header hides
the badge — neither can drive the other.

NewWorkerModal reads the workerImages store instead of fetching the list
a second time. load() now coalesces onto an in-flight request: the modal
is the only reader that branches on the list being EMPTY, and an early
return to a not-yet-populated list would drop the operator into Pull mode
with no default image.

menu-actions.ts and target-menu.ts are framework-free TypeScript and move
to $lib/menu/. TargetMenu.svelte stays under components/.

No visible behaviour changes beyond the two menu defects above.
fix(tmux): kill windows by id too, and stop reporting tmux failures as missing windows
All checks were successful
ci / nexus (pull_request) Successful in 6m40s
ci / images (pull_request) Successful in 7m39s
135b3a75b6
Review pass over c42159c. Three findings.

1. `deleteSession` still built `nexus:${row.name}` for its `kill-window`.
   Measured on tmux 3.3a with windows `0:workspace 1:alpha 2:"1"`: deleting
   the session named `1` ran `kill-window -t nexus:1` and killed **alpha** —
   another agent's claude, SIGHUPed — while the session being deleted kept
   running. Same defect class the commit fixes, one call site short. It now
   resolves the id first and kills `@N`, or nothing if the window is gone.

2. A failed listing was indistinguishable from an absent window. Both call
   sites read a non-zero `list-windows` exit as "no such window":
   `openTerminalPty` threw `no tmux window named "alpha"` when tmux was not
   answering at all, and `sendNudge` returned `window not found` — pointing
   the operator at a session that is fine. `resolveWindowId` now throws with
   tmux's own exit code and stderr; the nudge reports `cannot reach tmux`.

3. `sendNudge` claimed delivery on a failed send. `execInContainer` resolves
   with a non-zero exitCode rather than throwing, so a window that died
   between the listing and the send-keys produced `delivered: true` — the
   artifact flips to submitted with nothing typed into any pane.

Also: the shared `resolveWindowId` moved into lib/tmux-window.ts so both
container-side callers use one implementation; a source-scan test fails on any
new `nexus:${...}` target that isn't the fixed `WORKSPACE_WINDOW` constant;
`reject()` no longer labels a 500 `Bad Request`; and the rationale duplicated
at each call site is trimmed back to the module that holds the measurement.

Measured and added to the module doc: tmux never reuses a window id (killing
`@1` then creating a window gives `@3`, and `-t @1` fails rather than hitting a
bystander), so resolve-then-use cannot misfire on a stale id.
test(stores): pin the /api/quota subscriber refcount
All checks were successful
ci / nexus (pull_request) Successful in 6m58s
ci / images (pull_request) Successful in 8m14s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 11s
020830c4c8
The refcount was the one part of the poll consolidation argued from the
code rather than observed. A browser reaches "both subscribers mounted"
trivially and "neither mounted" almost never — the only route to it is the
lock screen — so the branch that actually clears the interval had never
run under test or in a browser. A stranded interval polls forever and
surfaces nowhere.

Five cases, each negative-controlled by breaking the guard it covers:

- drop the `--count === 0` guard, so any leave clears the timer
  -> "keeps polling while ONE subscriber remains" fails
- drop the idempotent-leave guard
  -> "survives a double leave without stranding the timer" fails
- never clearInterval
  -> "STOPS polling when the last subscriber leaves" fails,
     "expected 7 to be 1"
- drop the `++count === 1` guard, so every subscribe starts a timer
  -> "runs ONE timer for two subscribers" fails, "expected 6 to be 3" —
     which is the two-polls-per-tick regression this change removed
feat(ui): ports, pids and ahead-counts on the surfaces that lacked them
All checks were successful
ci / nexus (pull_request) Successful in 6m51s
ci / images (pull_request) Successful in 8m17s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 11s
204fbbfa5a
The per-session health poll already paid one docker exec per session per
tick to ask "is claude up". Two more facts ride inside that same script
rather than adding execs: the pid of the matched process (the walk
already has it — the directory it read the cmdline from), and how far
the worktree's branch is ahead.

The ahead-count has two sources and no third: the branch's own upstream,
then origin/HEAD for a branch that has never been pushed, which is most
session branches. When neither resolves the answer is ABSENT, not zero —
a branch level with its upstream and one we failed to measure are
different facts, so only the measured one gets a chip.

claude's version is NOT on that poll. It is fixed for the life of a
container (the entrypoint's boot-time update writes it), so it sits on
worker health behind a per-container cache and costs one exec per
container instead of one per session per 10s.

probeClaudeCwd is untouched and is not reimplemented on top of the new
probe: its other callers include the 100ms waitForClaude loop, which
must not run git. The new script keeps that function's process walk
byte-for-byte, including the shape that makes it immune to matching its
own /bin/sh — verified by running both shapes through `sh -c` here, where
the old argv-grep form matches its own cmdline and the new one does not.

Then the three surfaces that could not show any of it:

- The rail's hover card gets a ports row (a session names each port, a
  workspace states the total and how many are waiting on the operator —
  the only thing on that card that says an agent is blocked), an ahead
  chip, and a live agent row. Its comment saying a live probe was
  impossible here is now false and is gone.
- The session settings pane lists its ports inline with an approve
  button, instead of only linking to the ports panel; the branch gets
  the ahead chip and the agent status gets `claude <v> · pid <n>`.
- The workspace pane resolves its connection to a provider chip.

Three copies of "write to the clipboard and toast" become one helper,
and the preview-URL rule (advertise host vs the operator's own hostname)
stops being a second copy in the settings pane.
Both sides fixed deleteSession's kill-window independently — #100 with a
local resolveSessionWindowId, #105 with the shared lib/tmux-window.ts.
Unified onto the shared helper and deleted the duplicate.

The two resolvers were not equivalent, and taking the shared one closes a
latent bug rather than just deduplicating:

  resolveSessionWindowId  exit != 0 -> null
  resolveWindowId         exit != 0 -> throws (exit code + stderr)

spawnSessionPane treats null as "no such window" and falls through to
new-window, which is deliberate — a Restart then repairs a session whose
window went missing. But under the old resolver an unreachable tmux ALSO
returned null, so a restart against a momentarily unresponsive tmux would
create a SECOND window for a session that already had one. Throwing fails
the restart loudly instead. The repair path is unaffected: a genuinely
absent window still returns null from findWindowId.

service.test.ts was an add/add, not a real conflict — #100 added the
setSessionAgent suites, #105 added the deleteSession ones. Both kept.

120 files / 1200 tests, typecheck 4897 files 0 errors 0 warnings, lint clean.
The complaint was the tab BACKGROUND: dockview paints `background-color` on
`.dv-tab`, which is the PARENT of the div SessionTab renders, so `--accent-dim`
on the focused group's visible panel composited UNDER the session wash rather
than replacing it. The current tab rendered as yellow-green tinted by its
session's hue — two colours over the same pixels, neither readable as itself.

Now a tab has exactly one fill, its session wash, and which tab is current is a
2px bottom border in that same hue, reserved transparent on every tab so
selecting one never shifts its text.

Text keeps its session colour on EVERY tab, active included. That is a fix in
itself: dockview's `--dv-activegroup-visiblepanel-tab-color: var(--accent)` won
on the active tab, so the selected tab read as the DIMMEST in the strip while
the two unselected ones showed their names in full-strength colour. Selection
is the border's job; identity is the micro-caps line's and the glyph's,
unconditionally.

- all four `--dv-*-tab-background-color` → transparent: the wash is the only
  fill left.
- `--dv-*-tab-color` → `--text` / `--muted`, mirroring what the inner div sets.
  These land on a wrapper every text node already overrides, so the accent was
  invisible — and was a yellow-green inherited value waiting for the first child
  that forgot to set its own.
- `.dv-tab` padding zeroed inside the tabs container: it inset the tab by
  `0.25rem 0.5rem`, so the wash stopped 4px short of the edges and the border
  would have floated above the strip's baseline.

Verified in a browser against a seeded instance, measured off the live DOM:
idle tabs carry their session hue on the micro-caps line and glyph with a
transparent border and a 0.06 wash; the active tab carries the same hue at 0.14
plus a 2px border in it and a `--text` name. No `--accent` anywhere in the
strip, at 1500px and at 420px.
Applying a /simplify finding. The two tab alphas were magic numbers shadowing
`--wash-row` inside SessionTab's scoped CSS, under a name that means "sidebar
row" everywhere else. app.css already generalises "the alpha scales with the
surface" as named `:root` tokens, and its comment claimed there were two of
them — so a pass over wash intensity would have edited those two and silently
missed the tab.

Named `--wash-tab` / `--wash-tab-active` next to `--wash-row` / `--wash-tile`;
the component now feeds them into `--wash-row` for `.washed` rather than
carrying the numbers itself. Rendered output is unchanged — measured off the
live DOM, idle tabs still resolve to 0.06 and the active tab to 0.14.
fix(ui): lift the muted text tokens to WCAG AA
All checks were successful
ci / nexus (pull_request) Successful in 9m13s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 10s
ci / images (pull_request) Successful in 11m31s
e9cf2ffa9a
--muted-2 measured 2.03:1 on --bg and 1.69:1 on a washed row — below every
threshold WCAG defines, including the 3:1 floor for controls, which is what
the ⋯ button on a session row was using. --muted was no better than 3.66:1 /
3.05:1, so most secondary text in the app failed AA as well. The wash makes it
worse rather than better: it LIGHTENS the ground under already-dim text, so
contrast is worst on exactly the rows that have something going on.

Measured against the four surfaces text actually lands on (--bg, --surface, a
menu's #0e0e0e, and a washed row's #162029):

              bg  surface   menu   washed
    #444444  2.03    1.94   1.98   1.69
    #6a6a6a  3.66    3.49   3.57   3.05
    #8a8a8a  5.73    5.47   5.59   4.78

--muted -> #8a8a8a clears AA on all four. --muted-2 cannot follow it: a second
tier that also cleared AA on a washed row would have to sit within a shade of
the first, so it takes --muted's old value and is now explicitly the tier for
what you skim rather than parse.

That makes the tier boundary a real decision, so the call sites were sorted
against it rather than left where they happened to be:

- CONTROLS off --muted-2 entirely (3:1 at rest is a floor it clears nowhere):
  .icobtn, WorkspaceRail's .foot-btn, and the dock tab's close button — which
  is the only way to close a panel and is permanently visible on the active
  tab, so being near-invisible cost the most there.
- Prose and labels to --muted: menu and palette section headers, keyboard
  hints, empty-state copy, RailHoverCard's field labels, SettingsPanel's notes,
  the provisioning detail line, InstallSkillModal's picker metadata.
- Left on --muted-2, which the shift alone lifts from ~2:1 to ~3.5:1:
  the `·` between facts, breadcrumb separators, a file extension beside a
  full-contrast filename, input placeholders, and non-text decoration.

Verified in a browser against a seeded instance, ratios computed from the live
computed styles rather than from the palette: .icobtn, .facts and .card-meta
all read 5.47:1 on --surface, .section-title 5.73:1 on --bg.
docs(dock): the tab-colour vars are not inert — one drives dockview's own chrome
All checks were successful
ci / nexus (pull_request) Successful in 8m37s
ci / images (pull_request) Successful in 14m21s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 17s
4ef8f48dda
Applying a /simplify finding. The comment argued the four `--dv-*-tab-color`
vars only mirror what SessionTab sets, on the grounds that every text node
inside the tab overrides them. True of the tabs, and false of the block: the
"+N more" control dockview renders once the strip overflows
(`.dv-tabs-overflow-dropdown-default`) reads
`--dv-activegroup-hiddenpanel-tab-color` directly, with nothing of ours in
between and no overflow list disabled in Dock.svelte.

The rationale as written invited a later editor to delete the block as provably
inert, which would drop that control onto dockview's own default against a dark
palette. Values unchanged; the comment now names the real reason to keep it.

Verified live rather than by reading: five panels docked at 900px, the control
renders "2" and computes to `--muted`.
feat(ui): the sidebar is a list, and colour only marks what varies
All checks were successful
ci / nexus (pull_request) Successful in 11m37s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 15s
ci / images (pull_request) Successful in 14m20s
5ed754efde
The workspace card said "running and fine" four ways at once — a status LED, a
coloured left stripe, a `healthy` chip, and (whenever anything under it was
docked, which is most of the time) a wash across the whole card. Around them sat
facts that never change: the container name, the creation date, and the image's
capabilities.

A workspace is now a section of a list rather than a card: full pane width, no
border, no radius, no margin, no wash. What is left of status is presence —
greyed means not running, plain means fine, a coloured chip means read me.

- The LED, the `healthy` chip, the container name and the creation date are
  gone, as are the dind/playwright chips: an image capability is frozen at spawn
  and said nothing about THIS workspace. All four already appear in the
  workspace Settings panel, so nothing became unreachable.
- The repo moved directly under the name, and the ⋯ took the corner the LED had.
- The "Sessions (N)" heading, "No sessions yet" and "Sessions appear when the
  workspace is running" are gone. The first restated the rows; the second was
  false whenever it showed, since the workspace shell sits directly above it;
  the third told a greyed workspace what its greying already said.
- Chips carry only what varies: `unhealthy`, `degraded`, `provision failed`, and
  the operator's own mount count.

The coloured left edge moved DOWN to the session rows, at x=0 with no margin,
lit for exactly the sessions carrying the wash — the ones with panels docked —
so the sidebar edge and the dock tabs agree without a legend. Measured: rows
start at pane x=0 and end 1px short of the right (the pane's own border).

`.card.running` / `.card.exited` / `.card.stopped` / `.card.errored` are deleted
from app.css: this was their only consumer. `.card` itself stays — ten other
components use it.

Verified in a browser against a seeded instance covering running-with-chips,
dormant, pulling-with-progress and provision-error, at 1500px and 420px. Two
defects the screenshot caught and the suite could not: a duplicated failure
message (ProvisionProgress already renders `Failed: <error>`), and a comment
claiming the row text lands at 15px when the status dot puts it at 33.
feat(api): count a repo's env vars, and pin WorkerDTO's two declarations
All checks were successful
ci / nexus (pull_request) Successful in 8m51s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 8s
ci / images (pull_request) Successful in 15m21s
37406d63e7
Adds `env_var_count` to WorkerDTO so the sidebar can chip a workspace with the
operator state it carries, alongside the mount count it already showed.

repo_env_vars is keyed on repo_full_name, so this is ONE grouped query for the
whole list rather than one per workspace, and it selects counts only —
`encrypted_value` is never read, so it needs no master key and works while the
vault is locked. Two workspaces on one repo therefore report the same number,
which is what the field means.

`workerRowToDTO`'s new parameter is REQUIRED rather than defaulting to 0. A
default lets a future call site compile while reporting "no env vars" for a repo
that has them — the same silent-wrong-value trap the mounts_json restatement in
spawnWorker already carries a comment about, and spawnWorker is exactly where it
would have bitten: a new workspace inherits its repo's env immediately.

WorkerDTO is declared on BOTH sides of the wire, like MountSpec, and only one
drift direction is caught by the compiler: workerRowToDTO constructs the SERVER
type, so a field the server lacks fails the build, while a field the CLIENT
declares and the server never sets fails nothing and arrives `undefined` through
a non-optional type. worker-dto-parity.test.ts asserts mutual assignability, so
either direction is now a compile error. Mirrors mount-parity.test.ts, which
AGENTS.md names as what makes that duplication safe.

Negative-controlled: adding a client-only field made the parity assertion fail
and name the property.

Split out of the sidebar restyle, which is styling and should not carry a
backend field, a new query and a parity test to render one chip.
Merge PR #115: count a repo's env vars, and pin WorkerDTO's two declarations
All checks were successful
ci / nexus (pull_request) Successful in 11m15s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 1m19s
ci / images (pull_request) Successful in 16m50s
b07d86f7ac
lz manually merged commit 713356ca90 into main 2026-09-04 17:36:56 +02:00
Sign in to join this conversation.
No reviewers
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!116
No description provided.