Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/protocol/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ const samples: { [T in ClientTypes]: Extract<ClientMessage, { type: T }> } = {
"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": {
Expand Down
23 changes: 23 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<key>` 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"),
Expand Down Expand Up @@ -587,6 +608,8 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
fsReadSchema,
fsBrowseDirSchema,
claudeConfigSchema,
blackboardIndexSchema,
blackboardReadSchema,
modelsListSchema,
sessionExportSchema,
sessionImportSchema,
Expand Down
97 changes: 97 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -812,6 +819,8 @@ export type ClientMessage =
| FsReadMsg
| FsBrowseDirMsg
| ClaudeConfigMsg
| BlackboardIndexMsg
| BlackboardReadMsg
| ModelsListMsg
| SessionExportMsg
| SessionImportMsg
Expand Down Expand Up @@ -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/<key>`. */
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
Expand Down Expand Up @@ -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/<key>`. */
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;
Expand Down Expand Up @@ -1993,6 +2088,8 @@ export type DaemonMessage =
| FsReadResultMsg
| FsBrowseDirResultMsg
| ClaudeConfigResultMsg
| BlackboardIndexResultMsg
| BlackboardReadResultMsg
| ModelsListResultMsg
| SessionExportResultMsg
| SessionImportResultMsg
Expand Down
55 changes: 55 additions & 0 deletions src/daemon/blackboard/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
Expand Down
1 change: 1 addition & 0 deletions src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const SERVER_CAPABILITIES: string[] = [
CAPABILITIES.SEND_IDEMPOTENCY,
CAPABILITIES.UI_DIALOGS,
CAPABILITIES.DYNAMIC_COMMANDS,
CAPABILITIES.BLACKBOARD,
];

/**
Expand Down
Loading
Loading