From 15f503c920380b2409c7e2a104b2ee6394bfa8ad Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 27 Jul 2026 12:38:30 +0800
Subject: [PATCH 1/4] feat: render collaboration fleets as one unit in the web
session list
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`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
`:-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.
---
web/src/components/SessionListPane.test.tsx | 113 ++++++++
web/src/components/SessionListPane.tsx | 298 +++++++++++++++++---
web/src/lib/fleet.test.ts | 172 +++++++++++
web/src/lib/fleet.ts | 149 ++++++++++
4 files changed, 685 insertions(+), 47 deletions(-)
create mode 100644 web/src/lib/fleet.test.ts
create mode 100644 web/src/lib/fleet.ts
diff --git a/web/src/components/SessionListPane.test.tsx b/web/src/components/SessionListPane.test.tsx
index 63242524..5b8e7e01 100644
--- a/web/src/components/SessionListPane.test.tsx
+++ b/web/src/components/SessionListPane.test.tsx
@@ -30,6 +30,31 @@ function sess(id: string, name: string, workdir = "/tmp"): SessionInfo {
} as SessionInfo;
}
+/** An orchestrator: a session carrying a `collaboration` config. */
+function orchestrator(id: string, name: string, goal: string): SessionInfo {
+ return {
+ ...sess(id, name, "/repo/fleet"),
+ collaboration: { goal, roles: [{ name: "orchestrator", providerId: "claude" }] },
+ } as SessionInfo;
+}
+
+/** A role-child: a session carrying `collaborationRole` pointing at its parent. */
+function roleChild(
+ parentId: string,
+ parentName: string,
+ roleName: string,
+ ordinal: number,
+ write: boolean,
+ providerId = "claude",
+): SessionInfo {
+ const suffix = ordinal > 1 ? `-${ordinal}` : "";
+ return {
+ ...sess(`${parentId}:${roleName}${suffix}`, `${parentName}:${roleName}${suffix}`, "/repo/fleet"),
+ providerId,
+ collaborationRole: { parentSessionId: parentId, roleName, ordinal, write },
+ } as SessionInfo;
+}
+
afterEach(() => {
cleanup();
_resetSessionsForTest();
@@ -82,3 +107,91 @@ describe("SessionListPane — session filter", () => {
expect(getByText("beta")).toBeTruthy();
});
});
+
+describe("SessionListPane — fleet rendering", () => {
+ const FLEET = [
+ orchestrator("p", "refactor-auth", "make auth boring again"),
+ roleChild("p", "refactor-auth", "review", 2, false, "gemini"),
+ roleChild("p", "refactor-auth", "search", 1, false, "claude"),
+ roleChild("p", "refactor-auth", "review", 1, false, "gemini"),
+ roleChild("p", "refactor-auth", "reasoning", 1, true, "openai"),
+ sess("solo", "unrelated", "/tmp/other"),
+ ];
+
+ it("renders children by role label, not by their prefixed session name", () => {
+ ingestSessionList(FLEET);
+ const { getByText, queryByText } = render(() => );
+
+ expect(getByText("refactor-auth")).toBeTruthy();
+ // `review` ×2 fan-out gets ordinals; singletons don't.
+ expect(getByText("review")).toBeTruthy();
+ expect(getByText("review#2")).toBeTruthy();
+ expect(getByText("search")).toBeTruthy();
+ expect(getByText("reasoning")).toBeTruthy();
+ // The daemon-generated name is in the tooltip, not the visible label.
+ expect(queryByText("refactor-auth:review-2")).toBeNull();
+ });
+
+ it("shows the goal on the orchestrator row", () => {
+ ingestSessionList(FLEET);
+ const { getByText } = render(() => );
+ expect(getByText(/make auth boring again/)).toBeTruthy();
+ });
+
+ it("badges read-only roles and marks the writer differently", () => {
+ ingestSessionList(FLEET);
+ const { getAllByTitle } = render(() => );
+ // search, review, review#2 are read-only; reasoning writes.
+ expect(getAllByTitle(/Read-only role/)).toHaveLength(3);
+ expect(getAllByTitle(/may write to the workspace/)).toHaveLength(1);
+ });
+
+ it("shows every child's backend, including claude", () => {
+ // A mixed fleet is the point; "claude" must be stated, not implied by the
+ // absence of a chip the way it is for standalone sessions.
+ ingestSessionList(FLEET);
+ const { getAllByTitle, queryAllByTitle } = render(() => );
+ expect(getAllByTitle(/Backend: gemini/)).toHaveLength(2);
+ expect(getAllByTitle(/Backend: openai/)).toHaveLength(1);
+ expect(getAllByTitle(/Backend: claude/)).toHaveLength(1);
+ // ...but the standalone session still suppresses the default-backend chip.
+ expect(queryAllByTitle(/Backend: claude/)).toHaveLength(1);
+ });
+
+ it("collapses and re-expands the fleet from the group toggle", () => {
+ ingestSessionList(FLEET);
+ const { getByLabelText, queryByText, getByText } = render(() => );
+
+ fireEvent.click(getByLabelText(/Collapse fleet \(4 roles\)/));
+ expect(queryByText("search")).toBeNull();
+ expect(queryByText("review#2")).toBeNull();
+ // The orchestrator itself stays put.
+ expect(getByText("refactor-auth")).toBeTruthy();
+
+ fireEvent.click(getByLabelText(/Expand fleet \(4 roles\)/));
+ expect(getByText("search")).toBeTruthy();
+ });
+
+ it("keeps the orchestrator visible as context when only a child matches", () => {
+ ingestSessionList(FLEET);
+ const { getByLabelText, getByText, queryByText } = render(() => );
+ fireEvent.input(getByLabelText("Filter sessions by name"), {
+ target: { value: "reasoning" },
+ });
+ // A bare role row with no indication of its goal would be unreadable.
+ expect(getByText("refactor-auth")).toBeTruthy();
+ expect(getByText("reasoning")).toBeTruthy();
+ expect(queryByText("search")).toBeNull();
+ expect(queryByText("unrelated")).toBeNull();
+ });
+
+ it("keeps an orphan child visible, at top level, with its role badges intact", () => {
+ // Parent destroyed while the child drains — the child must not vanish. It
+ // shows its full name (no parent row above it to supply the context) but
+ // still reads as a role-child.
+ ingestSessionList([roleChild("ghost", "gone", "search", 1, false)]);
+ const { getByText, getByTitle } = render(() => );
+ expect(getByText("gone:search")).toBeTruthy();
+ expect(getByTitle(/Read-only role/)).toBeTruthy();
+ });
+});
diff --git a/web/src/components/SessionListPane.tsx b/web/src/components/SessionListPane.tsx
index a93bdb3a..ab1e2903 100644
--- a/web/src/components/SessionListPane.tsx
+++ b/web/src/components/SessionListPane.tsx
@@ -5,9 +5,16 @@
* chat area dominates the viewport.
*/
-import { Component, createSignal, For, Show } from "solid-js";
+import { Component, createMemo, createSignal, For, Show } from "solid-js";
import { formatCostUsd, formatTokens, relativeTime } from "../lib/format";
+import {
+ countVisible,
+ filterFleet,
+ groupFleet,
+ roleLabel,
+ type FilteredFleetGroup,
+} from "../lib/fleet";
import { sessionAgentLabel, shortSub } from "../lib/identity";
import { nowTick } from "../state/clock";
import {
@@ -43,19 +50,38 @@ function newSession(): void {
openNewSessionModal();
}
+/**
+ * Fleets the user has folded shut, by orchestrator id. Collapsed is the
+ * exception, so absence means expanded — a fleet that spawns while you're
+ * looking at it opens rather than hiding its own arrival.
+ *
+ * Module-level so the choice survives the pane unmounting (mobile drawer,
+ * sidebar collapse) — a fold that reopens itself every time you close the
+ * drawer isn't a fold.
+ */
+const [collapsedFleets, setCollapsedFleets] = createSignal>(
+ new Set(),
+);
+
+function toggleFleet(parentId: string): void {
+ setCollapsedFleets((prev) => {
+ const next = new Set(prev);
+ if (!next.delete(parentId)) next.add(parentId);
+ return next;
+ });
+}
+
const SessionListPane: Component = () => {
const [showAnalytics, setShowAnalytics] = createSignal(false);
- // Instant client-side filter by session name/workdir. Complements the
+ // Instant client-side filter by session name/workdir/role. Complements the
// semantic cross-session content search (Ctrl+K) — this is the fast
// "find the session called X" pass, and it works without the memory engine.
const [filter, setFilter] = createSignal("");
- const filtered = (): SessionInfo[] => {
- const q = filter().trim().toLowerCase();
- if (!q) return sessionList();
- return sessionList().filter(
- (s) => s.name.toLowerCase().includes(q) || s.workdir.toLowerCase().includes(q),
- );
- };
+ // Group BEFORE filtering: a filter that matches only a child still needs its
+ // orchestrator around to say which goal that child belongs to.
+ const groups = createMemo(() =>
+ filterFleet(groupFleet(sessionList()), filter()),
+ );
return (
{
);
+const RailButton: Component<{
+ session: SessionInfo;
+ label: string;
+ title: string;
+}> = (props) => (
+
+);
+
const SectionHeader: Component<{
title: string;
count: number;
@@ -228,24 +285,100 @@ const NoMatch: Component<{ query: string }> = (props) => (
);
-const SessionRow: Component<{ session: SessionInfo }> = (props) => {
+/**
+ * One top-level session and, when it orchestrates a collaboration, its
+ * role-children indented beneath it.
+ *
+ * The children are real sessions you can focus and read — they are long-lived,
+ * not transient dispatch workers — so they stay clickable rows rather than a
+ * summary line. What changes is that they're visibly *of* the orchestrator.
+ */
+const FleetGroupRows: Component<{ group: FilteredFleetGroup }> = (props) => {
+ const collapsed = () => collapsedFleets().has(props.group.lead.id);
+ const hasChildren = () => props.group.children.length > 0;
+
+ return (
+ <>
+ toggleFleet(props.group.lead.id),
+ }
+ : undefined
+ }
+ />
+
+
+ {/* The rail is the grouping: one continuous line down the left of
+ the fleet, so a child is unmistakably subordinate even when the
+ orchestrator has scrolled out of view. */}
+
+
+ {(c) => }
+
+
+
+
+ >
+ );
+};
+
+interface FleetLeadProps {
+ childCount: number;
+ collapsed: boolean;
+ onToggle: () => void;
+}
+
+const SessionRow: Component<{
+ session: SessionInfo;
+ /**
+ * Rendered indented under its orchestrator. Distinct from "is a role-child":
+ * an orphan child (parent destroyed or not yet delivered) is a role-child
+ * rendered at top level, and must keep its role badges while showing its
+ * full name — there's no parent row above it to supply the context.
+ */
+ nested?: boolean;
+ /** Present when this session orchestrates a collaboration. */
+ fleet?: FleetLeadProps;
+ /** Shown only to give matching children a parent — not a filter hit itself. */
+ dimmed?: boolean;
+}> = (props) => {
const isActive = () => focusedSessionId() === props.session.id;
+ const role = () => props.session.collaborationRole;
+ // A child's daemon-generated name is `:[-N]`; under the parent
+ // that prefix is pure repetition, so a nested row leads with the role and
+ // keeps the full name in the tooltip.
+ const title = () =>
+ (props.nested && roleLabel(props.session)) || props.session.name;
return (
-
+
+ {/* Sibling of the row, not nested inside it — a button inside a button is
+ invalid HTML and browsers resolve the click ambiguity differently. */}
+
+ {(f) => (
+
+ )}
+
);
};
+/**
+ * Whether a role-child may write. Read-only is the interesting state — it's the
+ * §6 independence property made visible (a scout's leaf identity carries no
+ * `tools:write` at all), so it gets the badge and write gets a quiet marker
+ * rather than both shouting equally.
+ */
+const WriteBadge: Component<{ write: boolean }> = (props) => (
+
+ ro
+
+ }
+ >
+
+ ✎
+
+
+);
+
const StatusDot: Component<{ status: SessionStatus }> = (props) => {
const cls = () => {
switch (props.status) {
diff --git a/web/src/lib/fleet.test.ts b/web/src/lib/fleet.test.ts
new file mode 100644
index 00000000..1a156963
--- /dev/null
+++ b/web/src/lib/fleet.test.ts
@@ -0,0 +1,172 @@
+import { describe, it, expect } from "vitest";
+
+import { countVisible, filterFleet, groupFleet, roleLabel } from "./fleet";
+import type { CollaborationConfig, SessionInfo } from "../protocol/types";
+
+function session(
+ id: string,
+ over: Partial = {},
+): SessionInfo {
+ return {
+ id,
+ name: id,
+ workdir: `/repo/${id}`,
+ status: "idle",
+ createdAt: "2026-07-27T00:00:00.000Z",
+ ...over,
+ } as SessionInfo;
+}
+
+const GOAL: CollaborationConfig = {
+ goal: "ship the thing",
+ roles: [{ name: "orchestrator", providerId: "claude" }],
+};
+
+function child(
+ id: string,
+ parentSessionId: string,
+ roleName: string,
+ ordinal = 1,
+ write = false,
+): SessionInfo {
+ return session(id, {
+ collaborationRole: { parentSessionId, roleName, ordinal, write },
+ });
+}
+
+describe("groupFleet", () => {
+ it("nests role-children under their orchestrator and keeps lead order", () => {
+ const parent = session("p", { collaboration: GOAL });
+ const groups = groupFleet([
+ session("standalone-b"),
+ parent,
+ child("c2", "p", "review", 2),
+ child("c1", "p", "review", 1),
+ session("standalone-a"),
+ ]);
+
+ expect(groups.map((g) => g.lead.id)).toEqual([
+ "standalone-b",
+ "p",
+ "standalone-a",
+ ]);
+ const fleet = groups[1]!;
+ expect(fleet.isFleet).toBe(true);
+ expect(fleet.children.map((c) => c.id)).toEqual(["c1", "c2"]);
+ });
+
+ it("orders children by role name then ordinal, not by list position", () => {
+ const parent = session("p", { collaboration: GOAL });
+ const groups = groupFleet([
+ parent,
+ child("r2", "p", "review", 2),
+ child("s1", "p", "search", 1),
+ child("r1", "p", "review", 1),
+ child("a1", "p", "architecture", 1),
+ ]);
+ expect(groups[0]!.children.map((c) => c.id)).toEqual(["a1", "r1", "r2", "s1"]);
+ });
+
+ it("marks a collaboration with no children yet as a fleet", () => {
+ // Between session.create and #spawnCollaborationChildren the parent exists
+ // alone; it must not render as a plain session and then become a fleet.
+ const groups = groupFleet([session("p", { collaboration: GOAL })]);
+ expect(groups).toHaveLength(1);
+ expect(groups[0]!.isFleet).toBe(true);
+ expect(groups[0]!.children).toEqual([]);
+ });
+
+ it("promotes an orphan child to a top-level group instead of dropping it", () => {
+ // Parent destroyed while the child drains, or not yet delivered to this
+ // client. Either way the child must stay visible.
+ const groups = groupFleet([child("c1", "gone", "search", 1)]);
+ expect(groups.map((g) => g.lead.id)).toEqual(["c1"]);
+ expect(groups[0]!.isFleet).toBe(false);
+ });
+
+ it("does not nest a child under a parent that is merely name-similar", () => {
+ const groups = groupFleet([
+ session("p", { collaboration: GOAL }),
+ child("c1", "p-other", "search", 1),
+ ]);
+ expect(groups.map((g) => g.lead.id)).toEqual(["p", "c1"]);
+ expect(groups[0]!.children).toEqual([]);
+ });
+
+ it("returns an empty list for no sessions", () => {
+ expect(groupFleet([])).toEqual([]);
+ });
+});
+
+describe("filterFleet", () => {
+ const parent = session("p", { name: "refactor-auth", collaboration: GOAL });
+ const groups = groupFleet([
+ parent,
+ child("c1", "p", "search", 1),
+ child("c2", "p", "review", 1),
+ session("unrelated", { name: "scratch", workdir: "/tmp/scratch" }),
+ ]);
+
+ it("passes everything through for an empty query", () => {
+ const out = filterFleet(groups, " ");
+ expect(out).toHaveLength(2);
+ expect(out.every((g) => g.leadMatched)).toBe(true);
+ expect(out[0]!.children).toHaveLength(2);
+ });
+
+ it("keeps the whole fleet when the lead matches", () => {
+ const out = filterFleet(groups, "refactor");
+ expect(out).toHaveLength(1);
+ expect(out[0]!.leadMatched).toBe(true);
+ expect(out[0]!.children.map((c) => c.id)).toEqual(["c2", "c1"]);
+ });
+
+ it("keeps the lead as unmatched context when only a child matches", () => {
+ const out = filterFleet(groups, "search");
+ expect(out).toHaveLength(1);
+ expect(out[0]!.lead.id).toBe("p");
+ expect(out[0]!.leadMatched).toBe(false);
+ expect(out[0]!.children.map((c) => c.id)).toEqual(["c1"]);
+ });
+
+ it("matches a child on its role name, which is not part of its session name", () => {
+ // The child's own `name` is daemon-generated; the role is what the user
+ // actually thinks in.
+ const out = filterFleet(groups, "review");
+ expect(out).toHaveLength(1);
+ expect(out[0]!.children.map((c) => c.id)).toEqual(["c2"]);
+ });
+
+ it("matches on workdir", () => {
+ const out = filterFleet(groups, "/tmp/scratch");
+ expect(out.map((g) => g.lead.id)).toEqual(["unrelated"]);
+ });
+
+ it("drops groups where nothing matches", () => {
+ expect(filterFleet(groups, "nothing-here")).toEqual([]);
+ });
+});
+
+describe("countVisible", () => {
+ it("counts leads plus their children", () => {
+ const groups = groupFleet([
+ session("p", { collaboration: GOAL }),
+ child("c1", "p", "search", 1),
+ child("c2", "p", "review", 1),
+ session("solo"),
+ ]);
+ expect(countVisible(groups)).toBe(4);
+ expect(countVisible([])).toBe(0);
+ });
+});
+
+describe("roleLabel", () => {
+ it("omits the ordinal for a singleton role and shows it for a fan-out", () => {
+ expect(roleLabel(child("c", "p", "search", 1))).toBe("search");
+ expect(roleLabel(child("c", "p", "review", 2))).toBe("review#2");
+ });
+
+ it("returns null for a session that is not a role-child", () => {
+ expect(roleLabel(session("solo"))).toBeNull();
+ });
+});
diff --git a/web/src/lib/fleet.ts b/web/src/lib/fleet.ts
new file mode 100644
index 00000000..5440093b
--- /dev/null
+++ b/web/src/lib/fleet.ts
@@ -0,0 +1,149 @@
+/**
+ * Fleet grouping — turning a flat session list into orchestrator + role-children.
+ *
+ * A collaborative session (docs/collaborative-session-design.md) is really N+1
+ * sessions: the orchestrator the user created, and one long-lived role-child per
+ * role binding. The daemon already tells us how they relate — the parent carries
+ * `collaboration`, each child carries `collaborationRole` — but a flat list
+ * renders them as N+1 unrelated sessions, which is exactly the wrong mental
+ * model for something whose whole point is that it's ONE unit of work.
+ *
+ * Pure functions, no Solid: grouping is the part worth testing, and the tests
+ * shouldn't need a reactive root to run.
+ */
+
+import type { SessionInfo } from "../protocol/types";
+
+/**
+ * One top-level row in the session list, plus whatever hangs under it.
+ *
+ * A standalone session is a group of one — the list is uniformly groups, so the
+ * renderer never branches on "is this a fleet" to decide its outer shape.
+ */
+export interface FleetGroup {
+ /** The orchestrator, or the standalone session. */
+ lead: SessionInfo;
+ /** Role-children of `lead`, role-then-ordinal ordered. Empty unless a fleet. */
+ children: SessionInfo[];
+ /**
+ * True when `lead` orchestrates a collaboration. Distinct from
+ * `children.length > 0`: between create and spawn a fleet legitimately has
+ * zero children, and it should still render as a fleet rather than blinking
+ * from plain session to fleet a second later.
+ */
+ isFleet: boolean;
+}
+
+/**
+ * Group sessions into fleets, preserving the caller's ordering for leads.
+ *
+ * Orphan children — `parentSessionId` names a session that isn't in the list —
+ * are promoted to their own top-level group rather than dropped. The parent can
+ * be legitimately absent (destroyed while children drain, or not yet delivered
+ * to this client), and a session that silently disappears from the sidebar is a
+ * far worse failure than one rendered without its group.
+ */
+export function groupFleet(sessions: readonly SessionInfo[]): FleetGroup[] {
+ const present = new Set(sessions.map((s) => s.id));
+ const childrenByParent = new Map();
+
+ for (const s of sessions) {
+ const parentId = s.collaborationRole?.parentSessionId;
+ // A child whose parent is missing is treated as a lead below, so only
+ // bucket the ones that actually have somewhere to go.
+ if (!parentId || !present.has(parentId)) continue;
+ const bucket = childrenByParent.get(parentId);
+ if (bucket) bucket.push(s);
+ else childrenByParent.set(parentId, [s]);
+ }
+
+ const groups: FleetGroup[] = [];
+ for (const s of sessions) {
+ const parentId = s.collaborationRole?.parentSessionId;
+ if (parentId && present.has(parentId)) continue; // rendered under its parent
+ const children = childrenByParent.get(s.id) ?? [];
+ children.sort(compareChildren);
+ groups.push({ lead: s, children, isFleet: Boolean(s.collaboration) });
+ }
+ return groups;
+}
+
+/**
+ * Children order: role name, then fan-out ordinal. Deliberately NOT createdAt —
+ * `review` ×3 spawn within milliseconds of each other, so creation order is
+ * effectively arbitrary and the list would reshuffle between renders.
+ */
+function compareChildren(a: SessionInfo, b: SessionInfo): number {
+ const ra = a.collaborationRole;
+ const rb = b.collaborationRole;
+ if (!ra || !rb) return 0;
+ if (ra.roleName !== rb.roleName) return ra.roleName < rb.roleName ? -1 : 1;
+ return ra.ordinal - rb.ordinal;
+}
+
+/**
+ * Apply the sidebar's name/workdir filter across a grouped list.
+ *
+ * Matching is per-session, but visibility is per-group, and the two directions
+ * differ on purpose:
+ *
+ * - lead matches → keep the whole group. You searched for the fleet; you want
+ * the fleet, not a fleet with its members hidden.
+ * - child matches → keep the lead as context, with only the matching children.
+ * Showing a bare `search#1` row with no indication of which
+ * goal it belongs to is worse than not filtering at all.
+ *
+ * A lead kept only as context is reported via `leadMatched: false` so the
+ * renderer can de-emphasise it instead of implying it matched the query.
+ */
+export interface FilteredFleetGroup extends FleetGroup {
+ /** False when the lead is present only to give matching children a parent. */
+ leadMatched: boolean;
+}
+
+export function filterFleet(
+ groups: readonly FleetGroup[],
+ query: string,
+): FilteredFleetGroup[] {
+ const q = query.trim().toLowerCase();
+ if (!q) return groups.map((g) => ({ ...g, leadMatched: true }));
+
+ const out: FilteredFleetGroup[] = [];
+ for (const g of groups) {
+ const leadMatched = matchesSession(g.lead, q);
+ if (leadMatched) {
+ out.push({ ...g, leadMatched: true });
+ continue;
+ }
+ const children = g.children.filter((c) => matchesSession(c, q));
+ if (children.length > 0) out.push({ ...g, children, leadMatched: false });
+ }
+ return out;
+}
+
+/** Name, workdir, or — for a child — its role name. */
+function matchesSession(s: SessionInfo, lowercaseQuery: string): boolean {
+ if (s.name.toLowerCase().includes(lowercaseQuery)) return true;
+ if (s.workdir.toLowerCase().includes(lowercaseQuery)) return true;
+ const role = s.collaborationRole?.roleName;
+ return role !== undefined && role.includes(lowercaseQuery);
+}
+
+/** How many sessions a filtered group puts on screen — for the header count. */
+export function countVisible(groups: readonly FleetGroup[]): number {
+ let n = 0;
+ for (const g of groups) n += 1 + g.children.length;
+ return n;
+}
+
+/**
+ * Display label for a role-child: `search` for a singleton, `review#2` for a
+ * member of a fan-out. Mirrors the blackboard's own slot naming
+ * (`RoleBlackboard#ownSlot`), so a `findings` slot in the artifact panel reads
+ * the same as the session that wrote it.
+ */
+export function roleLabel(s: SessionInfo): string | null {
+ const r = s.collaborationRole;
+ if (!r) return null;
+ return r.ordinal > 1 ? `${r.roleName}#${r.ordinal}` : r.roleName;
+}
From bc74d32b604ac7c97d8dc844a2ead9343ac68a01 Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 27 Jul 2026 12:47:47 +0800
Subject: [PATCH 2/4] =?UTF-8?q?feat:=20owner-facing=20goal=20blackboard=20?=
=?UTF-8?q?verbs=20=E2=80=94=20blackboard.index=20+=20blackboard.read?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
packages/protocol/src/schemas.test.ts | 9 +
packages/protocol/src/schemas.ts | 23 +++
packages/protocol/src/types.ts | 97 +++++++++++
src/daemon/blackboard/service.ts | 55 +++++++
src/daemon/server.ts | 1 +
src/daemon/session-manager.ts | 145 +++++++++++++++-
src/tests/blackboard.test.ts | 66 ++++++++
src/tests/collaboration.test.ts | 227 ++++++++++++++++++++++++++
src/tests/protocol.test.ts | 4 +
9 files changed, 626 insertions(+), 1 deletion(-)
diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts
index f81a3510..2f92702a 100644
--- a/packages/protocol/src/schemas.test.ts
+++ b/packages/protocol/src/schemas.test.ts
@@ -106,6 +106,15 @@ const samples: { [T in ClientTypes]: Extract } = {
"fs.read": { type: "fs.read", id: "r18", sessionId: "s1", path: "src/a.ts", maxBytes: 1024 },
"fs.browse_dir": { type: "fs.browse_dir", id: "r19", path: "/home" },
"claude.config": { type: "claude.config", id: "r20", sessionId: "s1" },
+ "blackboard.index": { type: "blackboard.index", id: "r60", sessionId: "s1" },
+ "blackboard.read": {
+ type: "blackboard.read",
+ id: "r61",
+ sessionId: "s1",
+ kind: "findings",
+ slot: "review#2",
+ version: 3,
+ },
"models.list": { type: "models.list", id: "r21", provider: "claude" },
"session.export": { type: "session.export", id: "r22", sessionId: "s1", includeMemory: true, toFile: false },
"session.import": {
diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts
index 84132a48..f8c13626 100644
--- a/packages/protocol/src/schemas.ts
+++ b/packages/protocol/src/schemas.ts
@@ -342,6 +342,27 @@ export const claudeConfigSchema = z.object({
sessionId: sessionIdField,
});
+/**
+ * Goal-blackboard inspection. `kind` is a bounded string rather than an enum
+ * for the same reason the role scoping fields are: `extra/` is an open
+ * namespace, and the daemon's `isValidArtifactKind` gives a specific error
+ * naming the valid core kinds instead of an opaque schema rejection.
+ */
+export const blackboardIndexSchema = z.object({
+ ...base,
+ type: z.literal("blackboard.index"),
+ sessionId: sessionIdField,
+});
+
+export const blackboardReadSchema = z.object({
+ ...base,
+ type: z.literal("blackboard.read"),
+ sessionId: sessionIdField,
+ kind: z.string().min(1).max(64),
+ slot: z.string().min(1).max(128).nullish(),
+ version: z.number().int().positive().optional(),
+});
+
export const modelsListSchema = z.object({
...base,
type: z.literal("models.list"),
@@ -587,6 +608,8 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
fsReadSchema,
fsBrowseDirSchema,
claudeConfigSchema,
+ blackboardIndexSchema,
+ blackboardReadSchema,
modelsListSchema,
sessionExportSchema,
sessionImportSchema,
diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts
index 11698136..f4efbf79 100644
--- a/packages/protocol/src/types.ts
+++ b/packages/protocol/src/types.ts
@@ -79,6 +79,13 @@ export const CAPABILITIES = {
* ids, never tool args) when one of that user's sessions blocks on approval.
*/
PUSH: "push",
+ /**
+ * Goal-blackboard inspection (`blackboard.index` / `blackboard.read`).
+ * Declared by the daemon; clients feature-detect before offering an artifact
+ * panel, so an older daemon simply doesn't grow the affordance rather than
+ * showing one that errors on click.
+ */
+ BLACKBOARD: "blackboard",
} as const;
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
@@ -812,6 +819,8 @@ export type ClientMessage =
| FsReadMsg
| FsBrowseDirMsg
| ClaudeConfigMsg
+ | BlackboardIndexMsg
+ | BlackboardReadMsg
| ModelsListMsg
| SessionExportMsg
| SessionImportMsg
@@ -1416,6 +1425,42 @@ export interface ClaudeConfigMsg extends BaseClientMsg {
sessionId: string;
}
+/**
+ * List what a collaboration's goal blackboard holds — kind, slot, version,
+ * author, size — without any artifact bodies
+ * (docs/collaborative-session-design.md §4).
+ *
+ * `sessionId` may name either the orchestrator or any of its role-children;
+ * the daemon resolves a child to its parent's goal. Every client would
+ * otherwise have to duplicate that hop, and getting it wrong yields a
+ * confusing empty board rather than an error.
+ */
+export interface BlackboardIndexMsg extends BaseClientMsg {
+ type: "blackboard.index";
+ sessionId: string;
+}
+
+/**
+ * Read one artifact body from a collaboration's goal blackboard.
+ *
+ * Unlike the agent-facing `blackboard_read` MCP tool, this is NOT filtered by
+ * a role's read scope: role scoping exists to keep the agents independent of
+ * each other (§6 — a reviewer that can read its peers is an echo, not a
+ * panel), which says nothing about the human who owns the goal and can already
+ * read every child's transcript.
+ */
+export interface BlackboardReadMsg extends BaseClientMsg {
+ type: "blackboard.read";
+ sessionId: string;
+ /** A core kind (`spec`, `research`, …) or `extra/`. */
+ kind: string;
+ /** Multi-writer slot as reported by the index; omit for the singleton. */
+ slot?: string | null;
+ /** A specific version; omit for the latest. Writes never overwrite, so
+ * older versions stay readable for auditing a handoff after the fact. */
+ version?: number;
+}
+
/**
* Ask the daemon for the model catalog the Claude Code backend actually
* supports (via the SDK's `supportedModels()`), rather than a hardcoded
@@ -1624,6 +1669,56 @@ export interface ClaudeConfigResultMsg {
hooks: ClaudeConfigHook[];
}
+/** One index row: what exists, at what version, by whom — never a body. */
+export interface BlackboardIndexEntry {
+ /** A core kind (`spec`, `research`, …) or `extra/`. */
+ kind: string;
+ /** Multi-writer discriminator (`review#2`); null = the singleton slot. */
+ slot: string | null;
+ /** Latest version present. Writes append, so this only ever grows. */
+ version: number;
+ /** ZeroID subject of the producing agent. */
+ authorSub: string;
+ /** Collaboration role that wrote it, when written by a role-child. */
+ authorRole: string | null;
+ /** Epoch ms of the latest version. */
+ updatedAt: number;
+ /** Body size of the latest version, so a client can warn before fetching. */
+ bytes: number;
+}
+
+export interface BlackboardIndexResultMsg {
+ type: "blackboard.index.result";
+ requestId: string;
+ /** The GOAL session — the orchestrator, even when a child was asked for. */
+ sessionId: string;
+ /** The collaboration's goal text, for labelling the board. */
+ goal: string;
+ entries: BlackboardIndexEntry[];
+}
+
+/** One artifact version, body included. */
+export interface BlackboardArtifact {
+ kind: string;
+ slot: string | null;
+ version: number;
+ content: string;
+ authorSub: string;
+ authorRole: string | null;
+ createdAt: number;
+}
+
+export interface BlackboardReadResultMsg {
+ type: "blackboard.read.result";
+ requestId: string;
+ /** The GOAL session — the orchestrator, even when a child was asked for. */
+ sessionId: string;
+ /** Null when nothing has been written to that kind/slot/version yet. Not an
+ * error: "the searcher hasn't produced research yet" is a normal state of a
+ * collaboration in flight, and clients render it as pending. */
+ artifact: BlackboardArtifact | null;
+}
+
export interface ModelsListResultMsg {
type: "models.list.result";
requestId: string;
@@ -1993,6 +2088,8 @@ export type DaemonMessage =
| FsReadResultMsg
| FsBrowseDirResultMsg
| ClaudeConfigResultMsg
+ | BlackboardIndexResultMsg
+ | BlackboardReadResultMsg
| ModelsListResultMsg
| SessionExportResultMsg
| SessionImportResultMsg
diff --git a/src/daemon/blackboard/service.ts b/src/daemon/blackboard/service.ts
index 7ed8a05b..2f0520e9 100644
--- a/src/daemon/blackboard/service.ts
+++ b/src/daemon/blackboard/service.ts
@@ -205,6 +205,52 @@ export class RoleBlackboard {
}
}
+/**
+ * The goal owner's view: the whole board, no role filter.
+ *
+ * This is the ONE handle that is not `reads`/`writes`-scoped, so it is a
+ * separate class rather than a flag on `RoleBlackboard` — a flag would make
+ * "unscoped" reachable by passing a boolean, and it must instead take an
+ * explicit `forOwner()` that grep finds.
+ *
+ * Why the exemption is sound rather than 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 human who created the collaboration is not a
+ * participant in it — they can already read every child's transcript, so
+ * withholding the artifacts those transcripts produced protects nothing and
+ * only makes the fleet unobservable. Tenant scoping still applies in full; the
+ * caller must prove ownership of the goal session before getting here.
+ *
+ * Read-only by construction: there is no `write`. Owner writes would enter the
+ * board with no role attribution and no scope check, and nothing needs them.
+ */
+export class OwnerBlackboard {
+ #store: BlackboardStore;
+ #scope: GoalScope;
+
+ constructor(store: BlackboardStore, scope: GoalScope) {
+ this.#store = store;
+ this.#scope = scope;
+ }
+
+ /** Every artifact at its latest version — kind, slot, size. No bodies. */
+ index(): ArtifactIndexEntry[] {
+ return this.#store.index(this.#scope);
+ }
+
+ /**
+ * One artifact body. `version` omitted = latest; a specific version reads
+ * back a superseded one, which is the point of the append-only contract.
+ * `null` = never written (a normal state mid-collaboration), not an error.
+ */
+ read(kind: string, slot?: string | null, version?: number): Artifact | null {
+ if (!isValidArtifactKind(kind)) return null;
+ return version === undefined
+ ? this.#store.latest(this.#scope, kind, slot ?? null)
+ : this.#store.version(this.#scope, kind, version, slot ?? null);
+ }
+}
+
/** The daemon-owned blackboard: one store, many goal-and-role-scoped views. */
export class Blackboard {
#store: BlackboardStore;
@@ -213,6 +259,15 @@ export class Blackboard {
this.#store = store;
}
+ /**
+ * The goal owner's unscoped-by-role view. Callers must have already checked
+ * that the requester owns `scope.goalSessionId`; this class does not and
+ * cannot verify that itself.
+ */
+ forOwner(scope: GoalScope): OwnerBlackboard {
+ return new OwnerBlackboard(this.#store, scope);
+ }
+
/**
* A role's handle on one goal.
*
diff --git a/src/daemon/server.ts b/src/daemon/server.ts
index 110b0542..8f066938 100644
--- a/src/daemon/server.ts
+++ b/src/daemon/server.ts
@@ -60,6 +60,7 @@ const SERVER_CAPABILITIES: string[] = [
CAPABILITIES.SEND_IDEMPOTENCY,
CAPABILITIES.UI_DIALOGS,
CAPABILITIES.DYNAMIC_COMMANDS,
+ CAPABILITIES.BLACKBOARD,
];
/**
diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts
index 49af9d8f..b1233f70 100644
--- a/src/daemon/session-manager.ts
+++ b/src/daemon/session-manager.ts
@@ -38,7 +38,7 @@ import {
resolveAgainstList,
resolveModelIdForProvider,
} from "./models.js";
-import { Blackboard } from "./blackboard/service.js";
+import { Blackboard, type OwnerBlackboard } from "./blackboard/service.js";
import { BlackboardStore } from "./blackboard/store.js";
import { BlackboardMcpHttp } from "./blackboard/mcp-http.js";
import {
@@ -674,6 +674,10 @@ mcpHub: this.#mcpHub,
return this.#fsBrowseDir(msg, auth);
case "claude.config":
return this.#claudeConfig(msg, auth);
+ case "blackboard.index":
+ return this.#blackboardIndex(msg, auth);
+ case "blackboard.read":
+ return this.#blackboardRead(msg, auth);
case "models.list":
return this.#modelsList(msg);
case "session.export":
@@ -1310,6 +1314,145 @@ mcpHub: this.#mcpHub,
}
}
+ // ---------- goal blackboard (owner-facing) ----------
+
+ /**
+ * Resolve a client-supplied session id to the collaboration goal it belongs
+ * to, or an error message naming why it doesn't belong to one.
+ *
+ * Accepts either the orchestrator or one of its role-children. Every client
+ * would otherwise have to walk `collaborationRole.parentSessionId` itself,
+ * and a client that got it wrong would see an EMPTY board rather than an
+ * error — the least debuggable possible outcome.
+ *
+ * The parent is re-fetched through `#getOwnedSession`, not trusted from the
+ * child's field: the ownership check must be made against the session whose
+ * artifacts are about to be read, not against the one that named it.
+ */
+ #resolveGoalSession(
+ sessionId: string,
+ auth: AuthContext,
+ ): { ok: true; goal: Session } | { ok: false; error: string; code: "not_found" | "invalid_request" } {
+ const session = this.#getOwnedSession(sessionId, auth);
+ if (!session) return { ok: false, error: "Session not found", code: "not_found" };
+ if (session.collaboration) return { ok: true, goal: session };
+
+ const parentId = session.collaborationRole?.parentSessionId;
+ if (!parentId) {
+ return {
+ ok: false,
+ error: `Session "${session.name}" is not part of a collaboration — it has no goal blackboard`,
+ code: "invalid_request",
+ };
+ }
+ const parent = this.#getOwnedSession(parentId, auth);
+ if (!parent?.collaboration) {
+ // The orchestrator was destroyed but this child hasn't finished draining.
+ // Teardown drops the artifacts with the goal, so there is nothing to show.
+ return {
+ ok: false,
+ error: "The orchestrator for this role-child is gone; its blackboard was torn down with it",
+ code: "not_found",
+ };
+ }
+ return { ok: true, goal: parent };
+ }
+
+ #ownerBlackboard(goal: Session): OwnerBlackboard {
+ return this.#goalBlackboard().forOwner({
+ accountId: goal.accountId,
+ projectId: goal.projectId,
+ goalSessionId: goal.id,
+ });
+ }
+
+ /**
+ * The board's contents at a glance. Gated on `session:list` rather than a new
+ * scope: an index is metadata about a session the holder can already
+ * enumerate, it carries no bodies, and inventing a scope would 403 every
+ * token minted before this shipped.
+ */
+ #blackboardIndex(
+ msg: Extract,
+ auth: AuthContext,
+ ): DaemonMessage {
+ if (!hasScope(auth.scopes as string[], SCOPES.SESSION_LIST)) {
+ return {
+ type: "response.error",
+ requestId: msg.id,
+ error: "Missing scope: session:list",
+ code: "forbidden",
+ };
+ }
+ const resolved = this.#resolveGoalSession(msg.sessionId, auth);
+ if (!resolved.ok) {
+ return {
+ type: "response.error",
+ requestId: msg.id,
+ error: resolved.error,
+ code: resolved.code,
+ };
+ }
+ return {
+ type: "blackboard.index.result",
+ requestId: msg.id,
+ sessionId: resolved.goal.id,
+ goal: resolved.goal.collaboration?.goal ?? "",
+ entries: this.#ownerBlackboard(resolved.goal).index(),
+ };
+ }
+
+ /**
+ * One artifact body. Gated on `session:watch` — a body is session CONTENT,
+ * the same class as the streamed output a watcher already receives, and a
+ * step above the `session:list` metadata the index exposes.
+ */
+ #blackboardRead(
+ msg: Extract,
+ auth: AuthContext,
+ ): DaemonMessage {
+ if (!hasScope(auth.scopes as string[], SCOPES.SESSION_WATCH)) {
+ return {
+ type: "response.error",
+ requestId: msg.id,
+ error: "Missing scope: session:watch",
+ code: "forbidden",
+ };
+ }
+ const resolved = this.#resolveGoalSession(msg.sessionId, auth);
+ if (!resolved.ok) {
+ return {
+ type: "response.error",
+ requestId: msg.id,
+ error: resolved.error,
+ code: resolved.code,
+ };
+ }
+ const found = this.#ownerBlackboard(resolved.goal).read(
+ msg.kind,
+ msg.slot ?? null,
+ msg.version,
+ );
+ return {
+ type: "blackboard.read.result",
+ requestId: msg.id,
+ sessionId: resolved.goal.id,
+ // `id`/`goalSessionId` are dropped: the id is an internal row key with no
+ // client use, and the goal is already on the envelope.
+ artifact: found
+ ? {
+ kind: found.kind,
+ slot: found.slot,
+ version: found.version,
+ content: found.content,
+ authorSub: found.authorSub,
+ authorRole: found.authorRole,
+ createdAt: found.createdAt,
+ }
+ : null,
+ };
+ }
+
#fsErr(requestId: string, err: unknown): DaemonMessage {
if (err instanceof FsAccessError) {
return { type: "response.error", requestId, error: err.message, code: err.code };
diff --git a/src/tests/blackboard.test.ts b/src/tests/blackboard.test.ts
index f95fcf25..436a7d29 100644
--- a/src/tests/blackboard.test.ts
+++ b/src/tests/blackboard.test.ts
@@ -316,3 +316,69 @@ describe("the default profile wires the §3 handoff chain", () => {
expect(bb.forRole(GOAL, ident("review")).read("research").ok).toBe(false);
});
});
+
+// ── Service: the owner's view ───────────────────────────────────────────────
+
+// The one handle that is not role-scoped. Its job is to be complete WITHOUT
+// becoming a bypass: it must still be tenant-scoped, and it must not be able
+// to write (an owner write would land with no role attribution and no scope
+// check on a board whose whole contract is attributable handoffs).
+describe("the goal owner's view", () => {
+ test("reads across every role's lane, which no single role can", () => {
+ bb.forRole(GOAL, ident("orchestrator")).write("spec", "SPEC");
+ bb.forRole(GOAL, ident("search")).write("research", "RESEARCH");
+ bb.forRole(GOAL, ident("reasoning")).write("diff", "DIFF");
+
+ const owner = bb.forOwner(GOAL);
+ expect(owner.read("spec")?.content).toBe("SPEC");
+ expect(owner.read("research")?.content).toBe("RESEARCH");
+ expect(owner.read("diff")?.content).toBe("DIFF");
+
+ // No role can do that: the reasoner reaches `diff` but never `research`.
+ expect(bb.forRole(GOAL, ident("reasoning")).read("research").ok).toBe(false);
+ });
+
+ test("still cannot cross a tenant or project boundary", () => {
+ bb.forRole(GOAL, ident("search")).write("research", "MINE");
+ expect(bb.forOwner(OTHER_TENANT).read("research")).toBeNull();
+ expect(bb.forOwner(OTHER_PROJECT).read("research")).toBeNull();
+ expect(bb.forOwner(OTHER_TENANT).index()).toHaveLength(0);
+ });
+
+ test("reaches each reviewer's own slot separately", () => {
+ bb.forRole(GOAL, ident("review", 1)).write("findings", "FIRST");
+ bb.forRole(GOAL, ident("review", 2)).write("findings", "SECOND");
+
+ const owner = bb.forOwner(GOAL);
+ expect(owner.read("findings", "review")?.content).toBe("FIRST");
+ expect(owner.read("findings", "review#2")?.content).toBe("SECOND");
+ });
+
+ test("reads a superseded version, so the append-only history is inspectable", () => {
+ const orch = bb.forRole(GOAL, ident("orchestrator"));
+ orch.write("spec", "v1");
+ orch.write("spec", "v2");
+
+ const owner = bb.forOwner(GOAL);
+ expect(owner.read("spec")?.content).toBe("v2"); // latest by default
+ expect(owner.read("spec")?.version).toBe(2);
+ expect(owner.read("spec", null, 1)?.content).toBe("v1");
+ expect(owner.read("spec", null, 99)).toBeNull();
+ });
+
+ test("an unwritten kind reads null — a normal mid-flight state, not an error", () => {
+ expect(bb.forOwner(GOAL).read("findings")).toBeNull();
+ });
+
+ test("a malformed kind reads null rather than reaching the store", () => {
+ expect(bb.forOwner(GOAL).read("Spec")).toBeNull();
+ expect(bb.forOwner(GOAL).read("extra/")).toBeNull();
+ expect(bb.forOwner(GOAL).read("../../etc/passwd")).toBeNull();
+ });
+
+ test("exposes no write path at all", () => {
+ // Not a style assertion: `write` on this handle would be an unattributed,
+ // unscoped mutation of an append-only, attributable board.
+ expect("write" in bb.forOwner(GOAL)).toBe(false);
+ });
+});
diff --git a/src/tests/collaboration.test.ts b/src/tests/collaboration.test.ts
index 61857887..b6b84457 100644
--- a/src/tests/collaboration.test.ts
+++ b/src/tests/collaboration.test.ts
@@ -30,6 +30,9 @@ import {
import { MockSessionProvider, mockResult } from "../daemon/providers/mock/session-provider.js";
import { ProviderRegistry } from "../daemon/providers/registry.js";
import type { ProviderEvent } from "../daemon/providers/interface.js";
+import { Blackboard } from "../daemon/blackboard/service.js";
+import { BlackboardStore } from "../daemon/blackboard/store.js";
+import { parseClientMessage } from "@codeoid/protocol/schemas";
import { SessionManager } from "../daemon/session-manager.js";
import { Store } from "../daemon/store.js";
import { TranscriptStore } from "../daemon/transcript.js";
@@ -875,3 +878,227 @@ describe("the session is its orchestrator", () => {
}
});
});
+
+// ── The owner-facing blackboard wire verbs ──────────────────────────────────
+
+// `blackboard.index` / `blackboard.read` are the only path by which a HUMAN
+// sees what their fleet produced. The agent-facing MCP tools are role-scoped
+// by design (§6); these are not, so what guards them is ownership of the goal
+// session — and that has to hold for every way a caller can name one.
+describe("blackboard.index / blackboard.read", () => {
+ const CONFIG: CollaborationConfig = {
+ goal: "Ship the blackboard panel",
+ roles: [
+ { name: "orchestrator", providerId: "claude" },
+ { name: "review", providerId: "gemini", count: 2 },
+ ],
+ };
+
+ /** Same daemon, a different tenant — the isolation probe. */
+ const OTHER_AUTH: AuthContext = {
+ ...AUTH,
+ sub: "user:other",
+ accountId: "acc-other",
+ projectId: "proj-other",
+ };
+
+ const runAs = (msg: ClientMessage, auth: AuthContext): Promise =>
+ manager.handle(msg, auth, { id: "c2", auth, send: () => {} });
+
+ /** Write through the REAL role-scoped path so slots + attribution are the
+ * ones agents actually produce, then read back over the wire. */
+ const board = () => new Blackboard(new BlackboardStore(store.database));
+ const writeAs = (goalId: string, role: string, ordinal: number, kind: string, content: string) =>
+ board()
+ .forRole(
+ { accountId: AUTH.accountId, projectId: AUTH.projectId, goalSessionId: goalId },
+ { roleName: role, ordinal, authorSub: `agent:${goalId}:${role}#${ordinal}` },
+ )
+ .write(kind, content);
+
+ const createCollab = async (id: string): Promise => {
+ manager.setBlackboardUrl("http://127.0.0.1:7400/mcp/blackboard");
+ const resp = await run({ type: "session.create", id, name: id, workdir, collaboration: CONFIG });
+ if (resp.type !== "response.ok") throw new Error(`create failed: ${JSON.stringify(resp)}`);
+ return resp.data as SessionInfo;
+ };
+
+ test("indexes what the roles wrote, with the goal text for labelling", async () => {
+ const parent = await createCollab("bb1");
+ writeAs(parent.id, "orchestrator", 1, "spec", "SPEC BODY");
+ writeAs(parent.id, "review", 1, "findings", "R1");
+ writeAs(parent.id, "review", 2, "findings", "R2");
+
+ const resp = await run({ type: "blackboard.index", id: "i1", sessionId: parent.id });
+ expect(resp.type).toBe("blackboard.index.result");
+ if (resp.type !== "blackboard.index.result") return;
+ expect(resp.goal).toBe(CONFIG.goal);
+ expect(resp.sessionId).toBe(parent.id);
+ // Each reviewer occupies its own slot — a panel collapsed to one entry is
+ // the exact failure MULTI_WRITER_KINDS exists to prevent.
+ expect(resp.entries.map((e) => `${e.kind}:${e.slot ?? "-"}`).sort()).toEqual([
+ "findings:review",
+ "findings:review#2",
+ "spec:-",
+ ]);
+ expect(resp.entries.find((e) => e.kind === "spec")?.bytes).toBe("SPEC BODY".length);
+ // Bodies never travel on the index.
+ expect(JSON.stringify(resp.entries)).not.toContain("SPEC BODY");
+ });
+
+ test("a role-child resolves to its parent's goal, not to an empty board", async () => {
+ // Clients focus children as often as orchestrators; making each one walk
+ // parentSessionId itself means a client that gets it wrong sees an EMPTY
+ // board rather than an error.
+ const parent = await createCollab("bb2");
+ writeAs(parent.id, "orchestrator", 1, "spec", "S");
+ const kid = childrenOf(await allSessions(), parent.id)[0]!;
+
+ const resp = await run({ type: "blackboard.index", id: "i2", sessionId: kid.id });
+ expect(resp.type).toBe("blackboard.index.result");
+ if (resp.type !== "blackboard.index.result") return;
+ expect(resp.sessionId).toBe(parent.id);
+ expect(resp.entries.map((e) => e.kind)).toEqual(["spec"]);
+ });
+
+ test("a plain session says it has no blackboard instead of returning nothing", async () => {
+ const resp0 = await run({ type: "session.create", id: "p1", name: "plain", workdir });
+ const plain = (resp0 as { data: SessionInfo }).data;
+ const resp = await run({ type: "blackboard.index", id: "i3", sessionId: plain.id });
+ expect(resp.type).toBe("response.error");
+ if (resp.type !== "response.error") return;
+ expect(resp.code).toBe("invalid_request");
+ expect(resp.error).toMatch(/not part of a collaboration/);
+ });
+
+ test("another tenant gets not_found, never someone else's board", async () => {
+ const parent = await createCollab("bb3");
+ writeAs(parent.id, "orchestrator", 1, "spec", "SECRET");
+
+ const idx = await runAs(
+ { type: "blackboard.index", id: "i4", sessionId: parent.id },
+ OTHER_AUTH,
+ );
+ expect(idx.type).toBe("response.error");
+ if (idx.type === "response.error") expect(idx.code).toBe("not_found");
+
+ const read = await runAs(
+ { type: "blackboard.read", id: "i5", sessionId: parent.id, kind: "spec" },
+ OTHER_AUTH,
+ );
+ expect(read.type).toBe("response.error");
+ expect(JSON.stringify(read)).not.toContain("SECRET");
+ });
+
+ test("index needs session:list and read needs session:watch", async () => {
+ const parent = await createCollab("bb4");
+ writeAs(parent.id, "orchestrator", 1, "spec", "S");
+
+ // A token holding everything EXCEPT the one scope each verb requires.
+ const without = (drop: string): AuthContext => ({
+ ...AUTH,
+ scopes: AUTH.scopes.filter((s) => s !== drop) as AuthContext["scopes"],
+ });
+
+ const idx = await runAs(
+ { type: "blackboard.index", id: "i6", sessionId: parent.id },
+ without("session:list"),
+ );
+ expect(idx.type).toBe("response.error");
+ if (idx.type === "response.error") expect(idx.code).toBe("forbidden");
+
+ const read = await runAs(
+ { type: "blackboard.read", id: "i7", sessionId: parent.id, kind: "spec" },
+ without("session:watch"),
+ );
+ expect(read.type).toBe("response.error");
+ if (read.type === "response.error") expect(read.code).toBe("forbidden");
+
+ // ...and the index still works for a watch-less token, since it carries
+ // no bodies. The two verbs are deliberately gated at different tiers.
+ const idxOk = await runAs(
+ { type: "blackboard.index", id: "i8", sessionId: parent.id },
+ without("session:watch"),
+ );
+ expect(idxOk.type).toBe("blackboard.index.result");
+ });
+
+ test("reads a body, including a specific reviewer's slot", async () => {
+ const parent = await createCollab("bb5");
+ writeAs(parent.id, "review", 1, "findings", "FIRST OPINION");
+ writeAs(parent.id, "review", 2, "findings", "SECOND OPINION");
+
+ const first = await run({
+ type: "blackboard.read", id: "r1", sessionId: parent.id, kind: "findings", slot: "review",
+ });
+ expect(first.type).toBe("blackboard.read.result");
+ if (first.type !== "blackboard.read.result") return;
+ expect(first.artifact?.content).toBe("FIRST OPINION");
+ expect(first.artifact?.authorRole).toBe("review");
+
+ const second = await run({
+ type: "blackboard.read", id: "r2", sessionId: parent.id, kind: "findings", slot: "review#2",
+ });
+ if (second.type !== "blackboard.read.result") return;
+ expect(second.artifact?.content).toBe("SECOND OPINION");
+ });
+
+ test("a superseded version is still readable — writes append, never overwrite", async () => {
+ const parent = await createCollab("bb6");
+ writeAs(parent.id, "orchestrator", 1, "spec", "v1 text");
+ writeAs(parent.id, "orchestrator", 1, "spec", "v2 text");
+
+ const latest = await run({
+ type: "blackboard.read", id: "r3", sessionId: parent.id, kind: "spec",
+ });
+ if (latest.type !== "blackboard.read.result") return;
+ expect(latest.artifact?.version).toBe(2);
+ expect(latest.artifact?.content).toBe("v2 text");
+
+ const old = await run({
+ type: "blackboard.read", id: "r4", sessionId: parent.id, kind: "spec", version: 1,
+ });
+ if (old.type !== "blackboard.read.result") return;
+ expect(old.artifact?.content).toBe("v1 text");
+ });
+
+ test("an unwritten artifact is null, not an error", async () => {
+ // A collaboration in flight legitimately has empty lanes; a client renders
+ // that as pending, which it cannot do if the daemon returns an error.
+ const parent = await createCollab("bb7");
+ const resp = await run({
+ type: "blackboard.read", id: "r5", sessionId: parent.id, kind: "research",
+ });
+ expect(resp.type).toBe("blackboard.read.result");
+ if (resp.type !== "blackboard.read.result") return;
+ expect(resp.artifact).toBeNull();
+ });
+
+ test("a malformed request is rejected at the schema, before any handler", () => {
+ const rejected = (over: Record) =>
+ parseClientMessage({
+ type: "blackboard.read", id: "r6", sessionId: "s", kind: "spec", ...over,
+ }).ok;
+ expect(rejected({ kind: "" })).toBe(false);
+ expect(rejected({ kind: "x".repeat(65) })).toBe(false);
+ expect(rejected({ version: 0 })).toBe(false);
+ expect(rejected({ version: -1 })).toBe(false);
+ expect(rejected({ slot: "s".repeat(129) })).toBe(false);
+ // `extra/` is an open namespace — the schema must let it through and
+ // leave validity to the daemon, which can name the valid core kinds back.
+ expect(rejected({ kind: "extra/bench-results" })).toBe(true);
+ });
+
+ test("an orphaned child reports the torn-down goal rather than an empty board", async () => {
+ const parent = await createCollab("bb8");
+ const kid = childrenOf(await allSessions(), parent.id)[0]!;
+ // Teardown deletes the goal's artifacts, so "empty" and "gone" would be
+ // indistinguishable to a client if this returned a result.
+ await run({ type: "session.destroy", id: "d1", sessionId: parent.id });
+
+ const resp = await run({ type: "blackboard.index", id: "i9", sessionId: kid.id });
+ expect(resp.type).toBe("response.error");
+ if (resp.type !== "response.error") return;
+ expect(resp.code).toBe("not_found");
+ });
+});
diff --git a/src/tests/protocol.test.ts b/src/tests/protocol.test.ts
index 01cc3e00..250e8fae 100644
--- a/src/tests/protocol.test.ts
+++ b/src/tests/protocol.test.ts
@@ -432,6 +432,10 @@ describe("DaemonMessage routing", () => {
case "fs.browse_dir.result": return `fs.browse:${msg.entries.length}`;
case "claude.config.result":
return `cc:${msg.agents.length}/${msg.skills.length}`;
+ case "blackboard.index.result":
+ return `bb.index:${msg.entries.length}`;
+ case "blackboard.read.result":
+ return `bb.read:${msg.artifact?.kind ?? "none"}`;
case "models.list.result":
return `models:${msg.models.length}`;
case "session.export.result":
From 86b60583ed42dfa16aed5241d0f808e7cc1bca39 Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 27 Jul 2026 12:53:39 +0800
Subject: [PATCH 3/4] =?UTF-8?q?feat:=20goal-blackboard=20drawer=20?=
=?UTF-8?q?=E2=80=94=20read=20what=20a=20collaboration=20actually=20produc?=
=?UTF-8?q?ed?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 `
` 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.
---
web/src/components/BlackboardDrawer.test.tsx | 191 +++++++++++
web/src/components/BlackboardDrawer.tsx | 317 +++++++++++++++++++
web/src/components/SessionControls.test.tsx | 53 +++-
web/src/components/SessionControls.tsx | 31 ++
web/src/components/Shell.tsx | 2 +
web/src/state/blackboard.test.ts | 241 ++++++++++++++
web/src/state/blackboard.ts | Bin 0 -> 6081 bytes
7 files changed, 834 insertions(+), 1 deletion(-)
create mode 100644 web/src/components/BlackboardDrawer.test.tsx
create mode 100644 web/src/components/BlackboardDrawer.tsx
create mode 100644 web/src/state/blackboard.test.ts
create mode 100644 web/src/state/blackboard.ts
diff --git a/web/src/components/BlackboardDrawer.test.tsx b/web/src/components/BlackboardDrawer.test.tsx
new file mode 100644
index 00000000..aff89a03
--- /dev/null
+++ b/web/src/components/BlackboardDrawer.test.tsx
@@ -0,0 +1,191 @@
+// @vitest-environment jsdom
+import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
+import { render, cleanup, fireEvent } from "@solidjs/testing-library";
+
+const requestMock = vi.hoisted(() =>
+ vi.fn<(msg: unknown, opts?: unknown) => Promise>(),
+);
+vi.mock("../state/connection", () => ({
+ getClient: () => ({ request: requestMock }),
+ newRequestId: () => `r-${Math.random()}`,
+}));
+
+import BlackboardDrawer from "./BlackboardDrawer";
+import {
+ closeBlackboard,
+ openBlackboard,
+ _resetBlackboardForTest,
+} from "../state/blackboard";
+import type { BlackboardIndexEntry } from "../protocol/types";
+
+function entry(
+ kind: string,
+ slot: string | null,
+ over: Partial = {},
+): BlackboardIndexEntry {
+ return {
+ kind,
+ slot,
+ version: 1,
+ authorSub: `agent:goal-1:${kind}`,
+ authorRole: slot?.split("#")[0] ?? kind,
+ updatedAt: Date.now() - 60_000,
+ bytes: 2048,
+ ...over,
+ };
+}
+
+const BOARD = [
+ entry("findings", "review#2"),
+ entry("diff", null, { authorRole: "reasoning", bytes: 300_000, version: 3 }),
+ entry("spec", null, { authorRole: "orchestrator", bytes: 900 }),
+ entry("findings", "review"),
+ entry("research", null, { authorRole: "search" }),
+];
+
+const indexResult = (entries: BlackboardIndexEntry[]) => ({
+ type: "blackboard.index.result" as const,
+ requestId: "x",
+ sessionId: "goal-1",
+ goal: "make auth boring again",
+ entries,
+});
+
+const readResult = (content: string | null, kind: string, slot: string | null) => ({
+ type: "blackboard.read.result" as const,
+ requestId: "x",
+ sessionId: "goal-1",
+ artifact:
+ content === null
+ ? null
+ : {
+ kind,
+ slot,
+ version: 2,
+ content,
+ authorSub: "a",
+ authorRole: "review",
+ createdAt: Date.now() - 30_000,
+ },
+});
+
+/** Open the drawer with a loaded board and wait for the first paint. */
+async function openWith(entries: BlackboardIndexEntry[]) {
+ requestMock.mockResolvedValue(indexResult(entries));
+ const view = render(() => );
+ openBlackboard("goal-1");
+ await vi.waitFor(() => expect(view.queryByText(/make auth boring again/)).toBeTruthy());
+ requestMock.mockReset();
+ return view;
+}
+
+beforeEach(() => _resetBlackboardForTest());
+afterEach(() => {
+ cleanup();
+ closeBlackboard();
+ requestMock.mockReset();
+ _resetBlackboardForTest();
+ vi.useRealTimers();
+});
+
+describe("BlackboardDrawer", () => {
+ it("renders nothing until opened", () => {
+ const { container } = render(() => );
+ expect(container.textContent).toBe("");
+ });
+
+ it("lists artifacts in SDLC flow order, not alphabetically", async () => {
+ // Alphabetical would put `diff` first and bury `spec` — the board should
+ // read as the pipeline it is.
+ const { container } = await openWith(BOARD);
+ const kinds = [...container.querySelectorAll("nav button")].map(
+ (b) => b.querySelector("span")?.textContent,
+ );
+ expect(kinds).toEqual(["spec", "research", "diff", "findings", "findings"]);
+ });
+
+ it("shows each writer's slot, so a panel never reads as one voice", async () => {
+ const { getAllByTitle } = await openWith(BOARD);
+ const slots = getAllByTitle("Writer slot").map((el) => el.textContent);
+ // Two `findings` rows, distinguishable. One row would mean the panel
+ // silently collapsed to a single opinion.
+ expect(slots).toEqual(["review", "review#2"]);
+ });
+
+ it("shows the goal and a human-readable size, and no bodies", async () => {
+ const { getByText, container } = await openWith(BOARD);
+ expect(getByText(/make auth boring again/)).toBeTruthy();
+ expect(getByText("293 KB")).toBeTruthy(); // the 300_000-byte diff
+ expect(getByText("900 B")).toBeTruthy();
+ expect(container.querySelector("pre")).toBeNull();
+ });
+
+ it("fetches a body only when an artifact is picked", async () => {
+ const { getByText, container } = await openWith(BOARD);
+ expect(requestMock).not.toHaveBeenCalled();
+
+ requestMock.mockResolvedValueOnce(readResult("REVIEWER TWO SAYS NO", "findings", "review#2"));
+ fireEvent.click(getByText("review#2"));
+ await vi.waitFor(() =>
+ expect(container.querySelector("pre")?.textContent).toBe("REVIEWER TWO SAYS NO"),
+ );
+ expect(requestMock.mock.calls[0]![0]).toMatchObject({
+ type: "blackboard.read",
+ kind: "findings",
+ slot: "review#2",
+ });
+ });
+
+ it("renders an unwritten artifact as pending rather than as a failure", async () => {
+ const { getByText, container } = await openWith(BOARD);
+ requestMock.mockResolvedValueOnce(readResult(null, "research", null));
+ fireEvent.click(getByText("research"));
+ await vi.waitFor(() => expect(getByText(/hasn't published a version/)).toBeTruthy());
+ expect(container.querySelector("pre")).toBeNull();
+ });
+
+ it("surfaces a read failure inline", async () => {
+ const { getByText } = await openWith(BOARD);
+ requestMock.mockRejectedValueOnce(new Error("Missing scope: session:watch"));
+ fireEvent.click(getByText("spec"));
+ await vi.waitFor(() => expect(getByText(/Missing scope: session:watch/)).toBeTruthy());
+ });
+
+ it("explains an empty board instead of showing a blank pane", async () => {
+ const { getByText } = await openWith([]);
+ expect(getByText(/Nothing written yet/)).toBeTruthy();
+ });
+
+ it("closes on Escape", async () => {
+ const { container } = await openWith(BOARD);
+ fireEvent.keyDown(window, { key: "Escape" });
+ await vi.waitFor(() => expect(container.textContent).toBe(""));
+ });
+
+ it("refreshes on demand", async () => {
+ const { getByLabelText, getByText } = await openWith(BOARD);
+ requestMock.mockResolvedValueOnce(
+ indexResult([...BOARD, entry("adr", null, { authorRole: "architecture" })]),
+ );
+ fireEvent.click(getByLabelText("Refresh blackboard"));
+ await vi.waitFor(() => expect(getByText("adr")).toBeTruthy());
+ });
+
+ it("stops polling once closed", async () => {
+ vi.useFakeTimers();
+ requestMock.mockResolvedValue(indexResult(BOARD));
+ render(() => );
+ openBlackboard("goal-1");
+ await vi.waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
+
+ await vi.advanceTimersByTimeAsync(9_000);
+ const whileOpen = requestMock.mock.calls.length;
+ expect(whileOpen).toBeGreaterThan(1); // it does poll
+
+ closeBlackboard();
+ await vi.advanceTimersByTimeAsync(20_000);
+ // A timer left running against a drawer nobody is looking at would keep
+ // hitting the daemon for the life of the tab.
+ expect(requestMock.mock.calls.length).toBe(whileOpen);
+ });
+});
diff --git a/web/src/components/BlackboardDrawer.tsx b/web/src/components/BlackboardDrawer.tsx
new file mode 100644
index 00000000..f20b477f
--- /dev/null
+++ b/web/src/components/BlackboardDrawer.tsx
@@ -0,0 +1,317 @@
+/**
+ * Goal-blackboard drawer — what a collaboration has actually produced.
+ *
+ * The session list shows the fleet; this shows its OUTPUT. Without it a
+ * collaboration is a set of sessions whose handoffs happen entirely off-screen
+ * (docs/collaborative-session-design.md §4: the orchestrator holds an index,
+ * never the bodies — so neither did the UI).
+ *
+ * Two panes: the index on the left (kind · slot · version · author · size),
+ * one artifact body on the right. Bodies are fetched on demand, never with the
+ * index — a `diff` can be 256 KB and the whole point of the index is that you
+ * can see what exists without paying for it.
+ */
+
+import {
+ Component,
+ For,
+ Show,
+ createEffect,
+ createMemo,
+ createSignal,
+ onCleanup,
+ onMount,
+} from "solid-js";
+
+import { relativeTime } from "../lib/format";
+import {
+ blackboard,
+ clearSelection,
+ closeBlackboard,
+ isBlackboardOpen,
+ refKey,
+ refreshBlackboard,
+ selectArtifact,
+ type ArtifactRef,
+} from "../state/blackboard";
+import { nowTick } from "../state/clock";
+import type { BlackboardIndexEntry } from "../protocol/types";
+
+/**
+ * Poll cadence while the drawer is open. A collaboration writes artifacts over
+ * minutes, and there is no push channel for the board — a stale panel would
+ * make a working fleet look stalled. Cheap: the index carries no bodies.
+ */
+const REFRESH_MS = 4_000;
+
+/** SDLC flow order (blackboard/types.ts CORE_ARTIFACT_KINDS), so the board
+ * reads as a pipeline rather than alphabetically. Unknown kinds sort last. */
+const KIND_ORDER = ["spec", "research", "adr", "task-list", "diff", "findings"];
+
+function kindRank(kind: string): number {
+ const i = KIND_ORDER.indexOf(kind);
+ return i === -1 ? KIND_ORDER.length : i;
+}
+
+function formatBytes(n: number): string {
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+const BlackboardDrawer: Component = () => {
+ onMount(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && isBlackboardOpen()) {
+ e.preventDefault();
+ closeBlackboard();
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ onCleanup(() => window.removeEventListener("keydown", onKey));
+ });
+
+ // Poll only while open, and tear the timer down on close — a background
+ // interval against a drawer nobody is looking at is pure waste.
+ createEffect(() => {
+ if (!isBlackboardOpen()) return;
+ const t = setInterval(() => void refreshBlackboard(), REFRESH_MS);
+ onCleanup(() => clearInterval(t));
+ });
+
+ const sorted = createMemo(() =>
+ [...blackboard().entries].sort(
+ (a, b) =>
+ kindRank(a.kind) - kindRank(b.kind) ||
+ a.kind.localeCompare(b.kind) ||
+ (a.slot ?? "").localeCompare(b.slot ?? ""),
+ ),
+ );
+
+ return (
+
+
+ >
+ )}
+
+
+ );
+};
+
+/**
+ * An artifact the index listed but that reads back empty. Normal mid-flight —
+ * the daemon returns `null` rather than an error precisely so this renders as
+ * pending instead of as a failure.
+ */
+const Unwritten: Component<{ loading: boolean }> = (props) => (
+
+
+ Not written yet — the role responsible for it hasn't published a version.
+