Skip to content

feat: render collaboration fleets and their goal blackboard in the web UI - #262

Merged
saucam merged 4 commits into
mainfrom
feat/web-ui-fleet-rendering
Jul 27, 2026
Merged

feat: render collaboration fleets and their goal blackboard in the web UI#262
saucam merged 4 commits into
mainfrom
feat/web-ui-fleet-rendering

Conversation

@saucam

@saucam saucam commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Closes the largest gap between "collaborative sessions work" and "collaborative sessions are legible": the fleet and its output are both visible now.

collaborationRole has been on the wire since #256 and the web UI referenced it zero times — an N-role collaboration rendered as N+1 unrelated sessions, the exact opposite of the mental model the feature exists to create. And the goal blackboard from #260 was reachable only through the role-scoped MCP tools, so the person who created the collaboration could not see what their fleet produced.

Design reference: docs/collaborative-session-design.md §4 (artifacts), §6 (independence).


1. Fleet grouping (15f503c)

web/src/lib/fleet.ts is a pure grouping/filtering module — no Solid, so the interesting logic is testable without a reactive root. The pane consumes it.

  • Children nest under their orchestrator behind a continuous left rail, keyed by role (review#2) rather than by their daemon-generated <parent>:<role>-N name, whose parent prefix is pure repetition once the row is already nested. Full name moves to the tooltip.
  • The orchestrator shows its goal and folds its fleet from a toggle that is a sibling of the row button, not a child — a button inside a button is invalid HTML and browsers resolve the click ambiguity differently.
  • Read-only roles are badged ro. That badge is §6's independence property made visible: a scout's leaf identity carries no tools:write at all.
  • Children always show their backend chip, including claude. Standalone rows suppress the default-backend chip, but which model sits behind which role is the entire point of a mixed fleet and must not be inferable only from a chip's absence.

Two cases that are easy to get wrong, both pinned by tests:

  • Orphan children are promoted, not dropped. A parentSessionId naming a session not in the list is normal — the parent was destroyed while children drain, or hasn't reached this client yet. A session that silently disappears from the sidebar is a far worse failure than one rendered without its group. It keeps its role badges but shows its full name, since no parent row is there to supply context.
  • Grouping happens before filtering. A query matching only a child keeps the orchestrator as dimmed context. A bare reasoning row with no indication of its goal is less useful than no filter at all.

2. Owner-facing blackboard verbs (bc74d32)

blackboard.index (metadata, no bodies) and blackboard.read (one body).

The exemption from §6, stated plainly because it looks like a hole: role scoping keeps the agents independent of each other — a reviewer that can read its peers is an echo, not a panel. The goal's owner is not a participant. They can already read every child's transcript, so withholding the artifacts those transcripts produced protects nothing and only makes the fleet unobservable.

That reasoning is load-bearing, so it is encoded in the type system rather than in a comment alone. Blackboard#forOwner returns a distinct OwnerBlackboard, not a RoleBlackboard with a flag — a flag makes "unscoped" reachable by passing a boolean, whereas an explicit forOwner() is greppable and cannot be handed where a role handle is expected. It exposes no write (an owner write would land unattributed and unscoped on a board whose entire contract is attributable handoffs), and tenant scoping still applies in full.

  • Ownership is checked first, and sessionId may name either the orchestrator or one of its role-children. Clients focus children as often as parents, and a client that walked parentSessionId wrongly would get an empty board rather than an error — the least debuggable outcome available. The parent is re-fetched through #getOwnedSession rather than trusted from the child's field, so the check binds to the session whose artifacts are about to be read.
  • No new scope minted. index takes session:list (metadata about a session the holder can already enumerate); read takes session:watch (a body is session content, the same class as streamed output). Both already sit in WATCHER_SCOPES and OPERATOR_SCOPES, so every token minted before this keeps working — a new scope would have 403'd them all.
  • An unwritten artifact returns null, not an error: a collaboration in flight legitimately has empty lanes, and a client can't render that as "pending" if the daemon calls it a failure. An orphaned child whose orchestrator was destroyed does error, because teardown drops the artifacts and "empty" would otherwise be indistinguishable from "gone".

3. The drawer (86b6058)

Index left, one artifact body right, fetched only when picked. That split is the design's, not a layout preference: a diff can be 256 KB, and the index exists precisely so you can see what's on the board without paying to load it. A test asserts no <pre> renders until you click.

  • Rows sort in SDLC flow order (spec → research → adr → task-list → diff → findings), not alphabetically, so the board reads as the pipeline it is.
  • Every writer slot is shown. Two findings rows reading review and review#2 is the visible form of MULTI_WRITER_KINDS; one row would mean a panel had silently collapsed into a single voice.
  • Polls every 4s while open and tears the timer down on close. There is no push channel for the board and a stale panel makes a working fleet look stalled; a timer surviving the close would hit the daemon for the life of the tab, so a test pins that too.

The trigger is a board chip in SessionControls, shown for an orchestrator or any role-child, gated on the daemon advertising the new blackboard capability — against an older daemon the affordance doesn't appear rather than appearing and erroring on click.

4. A bug I introduced and caught (a553383)

refKey's separator was written as a literal NUL byte instead of an escape. Nothing that normally catches a mistake said a word: it typechecked, it linted, all 370 web tests passed. The only symptom was git diff --stat reporting Bin 0 -> 6081 bytes — git classifies a file containing a NUL as binary, so web/src/state/blackboard.ts stopped producing diffs and would have arrived here unreviewable.

The separator itself was the right idea and stays, now as an explicit \u0000 in a named constant: kind is an open namespace (extra/<key>) and slot is daemon-generated, so any printable delimiter is one future naming choice away from collapsing two distinct artifacts onto one key — which would render the wrong body under the right row.

src/tests/source-hygiene.test.ts now walks every source file in the repo and fails on a raw NUL. The walker asserts its own file count first, so a broken walk can't pass by finding nothing.


Wire compatibility

Additive only. Two new ClientMessage variants, two new DaemonMessage variants, one new capability id. Older clients (including the Rust codeoid-ui) ignore what they don't know and simply don't grow the panel — no lockstep change is required in codeoid-ui for this PR.

Verification

before after
daemon suite 2044 2064
web suite 319 370

biome + eslint clean; eslint warning count is byte-identical to origin/main (30, all pre-existing, none in new files). Both bundles build.

Mutation-checked — the three security-relevant handler tests were verified against deliberately broken implementations, each failing exactly one test:

mutation test that caught it
drop the tenant check on the goal session another tenant gets not_found, never someone else's board
gate index on session:watch instead of session:list index needs session:list and read needs session:watch
let a plain session through as a goal a plain session says it has no blackboard...

The NUL guard was mutation-checked too (planting a NUL in lib/fleet.ts fails it).

Live run against a real daemon, because green CI is not a working feature — three separate times on this feature it wasn't. Isolated XDG_CONFIG_HOME, --local, port 7411. This closes a gap the unit tests structurally cannot: they call manager.handle() directly, so a wrong Zod schema would still pass while the real socket rejected everything.

18/18 checks passed, including:

  • the daemon advertises the blackboard capability
  • both verbs survive the real parseClientMessage
  • collaborationRole arrives on the wire for every child (review#1 write=false, review#2 write=false)
  • a child id resolves to its parent's goal id
  • the index lists both reviewer slots separately, reports latest-version-only, and carries sizes but no bodies
  • each reviewer's slot reads back its own opinion, not its peer's
  • a superseded version is still readable; latest is the default
  • an unwritten artifact reads null; a malformed kind is rejected at the transport
  • a plain session is told it has no board
  • teardown drops the goal's artifacts (0 rows left)

Not in this PR

Panel + barrier + v1 guards (the N-way join, live-worker cap, cost roll-up), mid-goal restart resume, and the stale MODEL_CATALOG in src/daemon/models.ts. Non-Claude orchestrators remain #245 — the blackboard is deliberately HTTP-mountable so it does not inherit that limit.

🤖 Generated with Claude Code

saucam added 4 commits July 27, 2026 12:38
`collaborationRole` has been on the wire since #256 and the UI ignored it
entirely, so an N-role collaboration rendered as N+1 unrelated sessions —
the exact opposite of the mental model a collaborative session exists to
create.

Group them. `lib/fleet.ts` is a pure grouping/filtering module (no Solid,
so the interesting logic is testable without a reactive root); the pane
consumes it.

What the rows now say:

- Children nest under their orchestrator behind a continuous left rail,
  keyed by role (`review#2`) rather than by their daemon-generated
  `<parent>:<role>-N` name, whose parent prefix is pure repetition once
  the row is already nested. Full name stays in the tooltip.
- The orchestrator shows its goal, and folds its fleet shut from a
  toggle that is a SIBLING of the row button, not a child of it —
  nesting a button inside a button is invalid HTML.
- Read-only roles are badged `ro`. That badge is §6's independence
  property made visible: a scout's leaf identity carries no `tools:write`
  at all.
- Children always show their backend chip, INCLUDING "claude". Standalone
  rows suppress the default-backend chip, but which model sits behind
  which role is the entire point of a mixed fleet and must not be
  inferable only from a chip's absence.

Two cases that are easy to get wrong, so both are pinned by tests:

- An orphan child — `parentSessionId` naming a session not in the list,
  because the parent was destroyed while children drain or hasn't reached
  this client yet — is promoted to top level rather than dropped. A
  session that silently disappears from the sidebar is a far worse
  failure than one rendered without its group. It keeps its role badges
  but shows its full name, since no parent row is there to supply context.
- Grouping happens BEFORE filtering. A query matching only a child keeps
  the orchestrator as (dimmed) context; a bare `reasoning` row with no
  indication of its goal is less useful than no filter at all.

Web suite 319 → 341.
…ard.read

The blackboard is only reachable through the role-scoped MCP tools, which
means the person who created the collaboration and is paying for it cannot
see what their fleet produced. Add two read verbs for the human.

The exemption, stated plainly because it looks like a hole in §6: role
scoping keeps the AGENTS independent of each other — a reviewer that can
read its peers is an echo, not a panel. The goal's owner is not a
participant. They can already read every child's transcript, so
withholding the artifacts those transcripts produced protects nothing
and only makes the fleet unobservable.

That reasoning is load-bearing, so it is encoded in the type system
rather than in a comment alone. `Blackboard#forOwner` returns a distinct
`OwnerBlackboard` class, not a `RoleBlackboard` with a flag: a flag makes
"unscoped" reachable by passing a boolean, whereas an explicit `forOwner()`
is greppable and cannot be handed where a role handle is expected. It
exposes no `write` — an owner write would land unattributed and
unscoped on a board whose entire contract is attributable handoffs — and
tenant scoping still applies in full.

Ownership is checked before any of that, and `sessionId` may name either
the orchestrator or one of its role-children. Clients focus children as
often as parents, and a client that walked `parentSessionId` wrongly
would get an EMPTY board rather than an error — the least debuggable
outcome available. The parent is re-fetched through `#getOwnedSession`
rather than trusted from the child's field, so the ownership check binds
to the session whose artifacts are about to be read.

Scopes, no new one minted: `index` takes `session:list` (metadata about a
session the holder can already enumerate, no bodies) and `read` takes
`session:watch` (a body is session content, the same class as streamed
output). Both already sit in WATCHER_SCOPES and OPERATOR_SCOPES, so every
token minted before this keeps working — a new scope would have 403'd
them all.

Also: an unwritten artifact returns `null`, not an error. A collaboration
in flight legitimately has empty lanes, and a client can't render that as
"pending" if the daemon calls it a failure. An orphaned child whose
orchestrator was destroyed DOES error, because teardown drops the
artifacts and "empty" would otherwise be indistinguishable from "gone".

Daemon suite 2044 → 2063. The three security-relevant tests were
mutation-checked — dropping the tenant check, swapping the two scope
tiers, and letting a plain session through each fail exactly one test.
…duced

The session list now shows the fleet; this shows its OUTPUT. §4 has the
orchestrator hold an index and never the bodies — and so, until now, did
the UI: every handoff between role-children happened entirely off-screen.

Two panes. The index (kind · slot · version · author · size) on the left,
one artifact body on the right, fetched only when picked. That split is
the design's, not a layout preference: a `diff` can be 256 KB, and the
index exists precisely so you can see what's on the board without paying
to load it. The test asserts no `<pre>` renders until you click.

Details that carry meaning rather than decoration:

- Rows sort in SDLC flow order (spec → research → adr → task-list → diff
  → findings), not alphabetically, so the board reads as the pipeline it
  is. Alphabetical buries `spec` under `diff`.
- Every writer slot is shown. Two `findings` rows reading `review` and
  `review#2` is the visible form of MULTI_WRITER_KINDS; one row would
  mean a panel had silently collapsed into a single voice.
- A `null` artifact renders as "not written yet", never as an error. The
  daemon returns null for exactly this reason — an empty lane is a normal
  state of a collaboration in flight.
- Polls every 4s while open and tears the timer down on close. There's no
  push channel for the board, and a stale panel makes a working fleet
  look stalled; a timer surviving the close would hit the daemon for the
  life of the tab, so a test pins that too.

The trigger is a `board` chip in SessionControls, shown for an
orchestrator OR any role-child (the daemon resolves the hop, so both land
on the same board) and gated on the daemon advertising the `blackboard`
capability — against an older daemon the affordance doesn't appear rather
than appearing and erroring on click.

The state slice takes `goalSessionId` from the RESULT, not from the id it
asked about, so focusing a child and focusing its orchestrator converge.
Both async paths drop replies for a board or artifact the user has since
navigated away from, and a refresh that no longer lists the selected
artifact clears the body pane instead of stranding it.

Web suite 341 → 370.
The artifact-ref key separator was written as a raw control byte instead
of an escape. Nothing that normally catches a mistake said a word: it
typechecked, it linted, all 370 web tests passed. The only symptom was
`git diff --stat` reporting `Bin 0 -> 6081 bytes` — git classifies a file
containing a NUL as binary, so the file stopped producing diffs and would
have reached the PR unreviewable.

The separator itself was the right idea and stays, now as an explicit
`\u0000` escape in a named constant. `kind` is an open namespace
(`extra/<key>`) and `slot` is daemon-generated, so any printable
delimiter is one future naming choice away from collapsing two distinct
artifacts onto one key — which would render the wrong body under the
right row.

Adds `src/tests/source-hygiene.test.ts` to make the class of mistake
loud: it walks every source file in the repo and fails on a raw NUL. The
walker asserts its own file count first, so a broken walk can't pass by
finding nothing, and the guard is mutation-checked: planting a NUL in
lib/fleet.ts fails it, removing it passes.

Daemon suite 2063 to 2064.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@saucam

saucam commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

pr-shepherd: green on iteration 1 — no fixes required.

CI attachment verified first (this repo's ci.yml triggers on pull_request: branches: [main], and a PR based off a non-main branch silently runs only the Socket checks while still reporting clean). Both real jobs are present and passing:

Check Result
daemon SUCCESS — lint 336 files clean, typecheck clean, 2064 pass / 0 fail, build OK
web SUCCESS — eslint 30 warnings / 0 errors (all pre-existing on main), 370 pass / 0 fail, vite build OK
Socket Security: Project Report SUCCESS
Socket Security: Pull Request Alerts SUCCESS

Branch hygiene: 4 commits ahead of origin/main, 0 behind — no rebase needed. All four subjects already match the commit-prefix regex. Single author, so no shared-branch rewrite concerns.

CI counts match a local run of the same suites exactly, so no environmental drift (lockfile/bun version/cache) to chase.

No commits were pushed and no files were changed by the shepherd.

gemini-code-assist[bot] left no review findings — its only comment is the notice that the consumer version of Gemini Code Assist on GitHub has been sunset and code review activity has ceased. Nothing to triage.

mergeStateStatus is BLOCKED solely on the missing human approval. Merging is a human decision.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@saucam
saucam merged commit 86cf96c into main Jul 27, 2026
4 checks passed
saucam added a commit that referenced this pull request Jul 27, 2026
…e back missing (#263)

`collaborationRole` has been persisted since P1b (#256) and `resumeSessions`
read `meta.collaboration` while silently ignoring it. The visible symptom
was cosmetic: role-children came back detached from their orchestrator, so
the fleet grouping shipped in #262 held only until the daemon bounced.

The invisible symptoms were the problem. A resumed child came back with:

- **no `workerShape`**, so its next turn registered a full session agent
  instead of a scope-capped `scout` leaf (`#ensureAgentIdentity`);
- **no capability role**, so `roleDeniesTool` had nothing to deny with and
  a read-only reviewer's `Write`/`Edit` degraded from denied to merely
  asked;
- **no blackboard mount**, so it could not publish a handoff; and
- **no autonomous budget** — it resumed `guarded`, and since nobody ever
  attaches to a child, its first non-safe tool call parks at
  `waiting_approval` with zero clients. The fleet rendered as a live
  collaboration and was dead.

Two of those are a privilege regression across a restart, not just a lost
feature. §6's "a reviewer that provably cannot write" held on the create
path and quietly stopped holding on the resume path.

## Derive, don't re-invent

No new persisted field was needed: a child's identity
(`collaborationRole`) plus its goal's config reproduces the plan it
spawned under. `plannedChildFor` recovers the `PlannedChild`, and it is
implemented BY calling `planChildren` rather than re-deriving
shape/write/reads/writes a second time — a resumed child that computed its
own shape would be one edit from disagreeing with the one it spawned
under, and the direction that drift fails is a read-only reviewer coming
back able to write. Sharing the derivation makes that unrepresentable.

`roleChildPosture` is now the single definition of a child's restrictions
(worker shape + capability role + brief + collaborationRole), called by
both `#spawnCollaborationChildren` and resume. `#blackboardMountFor` takes
a `GoalScope` instead of a live parent `Session`, because resume is capped
and time-boxed and a parent can legitimately miss the window its own
children made — while the board itself is keyed on (tenant, goal id) in
SQLite and needs no resident orchestrator. `#attachOrchestratorBlackboard`
is likewise shared, since an orchestrator's mount is scoped to its own id
either way.

Attribution already anticipated this: `authorSub` is keyed to the ROLE
within the goal, never the session id, so a resumed child writes under the
same subject its pre-restart versions carry.

## The torn case

Child transcript present, orchestrator's gone (teardown normally removes
both). It restores the FENCE from what the child itself carries — `write`
is on `collaborationRole` — and nothing else: no mount for artifacts that
died with the goal, and deliberately no autonomous budget, because an
agent that cannot coordinate should not burn turns unattended. It gets an
honest brief saying so, and resume logs it at warn level, since a silent
degrade is indistinguishable from a healthy fleet in the session list.

## Verification

Daemon suite 2064 to 2081. Adds `Session.hasBlackboardMount` and
`SessionManager._sessionForTest`.

All seven restart tests were mutation-checked against the pre-fix state
(dropping the posture spread) and all seven fail. Three further mutations
each fail exactly the tests that should catch them: minting a mount but
never attaching it, not tracking resumed tokens for revocation, and
minting a mount on the orphan path.

Mutation testing also caught two flaws in the tests themselves, both
fixed: `activeTokens` counts tokens ever MINTED, so it could not tell a
mount a session holds from one dropped on the floor (now asserted
per-session via `hasBlackboardMount`); and the per-child assertions sat
inside a loop over a list the pre-fix state empties, so that test passed
vacuously until a length check was added.

Verified live across a real process restart, not just a second
SessionManager in-process: boot, create a 3-role collaboration, publish an
artifact, SIGTERM, boot again on the same config dir. 15/15 — children
reattach with roles and ordinals, write authority stays per-role, the
capability role is active, they return autonomous with a fresh 50-turn
budget, the board and its attribution survive, a resumed child still
resolves to its goal and reads a body back, and cascade teardown still
collapses the whole resumed fleet.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants