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
1 change: 1 addition & 0 deletions packages/protocol/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ const samples: { [T in ClientTypes]: Extract<ClientMessage, { type: T }> } = {
"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" },
"collaboration.panels": { type: "collaboration.panels", id: "r62", sessionId: "s1" },
"blackboard.read": {
type: "blackboard.read",
id: "r61",
Expand Down
7 changes: 7 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ export const claudeConfigSchema = z.object({
* namespace, and the daemon's `isValidArtifactKind` gives a specific error
* naming the valid core kinds instead of an opaque schema rejection.
*/
export const collaborationPanelsSchema = z.object({
...base,
type: z.literal("collaboration.panels"),
sessionId: sessionIdField,
});

export const blackboardIndexSchema = z.object({
...base,
type: z.literal("blackboard.index"),
Expand Down Expand Up @@ -610,6 +616,7 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
claudeConfigSchema,
blackboardIndexSchema,
blackboardReadSchema,
collaborationPanelsSchema,
modelsListSchema,
sessionExportSchema,
sessionImportSchema,
Expand Down
60 changes: 60 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ export const CAPABILITIES = {
* showing one that errors on click.
*/
BLACKBOARD: "blackboard",
/**
* Live collaboration panel state (`collaboration.panels`). Declared by the
* daemon so a client only offers panel UI when the data exists behind it.
*/
PANELS: "collaboration.panels",
/**
* Push via NATIVE device tokens (APNs/FCM) rather than Expo. Advertised by
* the daemon when the `native` or `relay` transport is configured; a client
Expand Down Expand Up @@ -860,6 +865,7 @@ export type ClientMessage =
| ClaudeConfigMsg
| BlackboardIndexMsg
| BlackboardReadMsg
| CollaborationPanelsMsg
| ModelsListMsg
| SessionExportMsg
| SessionImportMsg
Expand Down Expand Up @@ -1479,6 +1485,23 @@ export interface BlackboardIndexMsg extends BaseClientMsg {
sessionId: string;
}

/**
* Live panel state for a collaboration — which fan-outs are in flight and how
* far along each member is (docs/collaborative-session-design.md §7).
*
* Exists because nothing else on the wire carries it. A panel's whole point is
* that N agents work AT ONCE, and a client could previously see the fleet and
* the joined result but never the parallelism itself — the most legible part of
* the feature was the one part invisible to the UI.
*
* `sessionId` may name the orchestrator or any of its role-children; the daemon
* resolves a child to its parent's goal, exactly as `blackboard.index` does.
*/
export interface CollaborationPanelsMsg extends BaseClientMsg {
type: "collaboration.panels";
sessionId: string;
}

/**
* Read one artifact body from a collaboration's goal blackboard.
*
Expand Down Expand Up @@ -1708,6 +1731,42 @@ export interface ClaudeConfigResultMsg {
hooks: ClaudeConfigHook[];
}

/** One member of a dispatch group, as a client sees it. */
export interface CollaborationPanelMember {
/** Target session id. Null for a member with no session (a spawn-shaped one). */
sessionId: string | null;
/** 1-based position within the fan-out, as dispatched — stable across reads. */
ordinal: number;
/**
* Queue state. Terminal values are `done` / `failed` / `blocked`; the barrier
* fires once every member reaches one, which is why a client can render
* "2 of 3 settled" without knowing the barrier's rules.
*/
status: "queued" | "claimed" | "running" | "done" | "failed" | "blocked";
}

/** One fan-out (a dispatch group) and how far along it is. */
export interface CollaborationPanel {
groupId: string;
/** Epoch ms the fan-out was queued. */
createdAt: number;
/** Members in fan-out order. */
members: CollaborationPanelMember[];
/** How many members have reached a terminal state. */
settled: number;
/** True once every member is terminal — i.e. the barrier has joined. */
joined: boolean;
}

export interface CollaborationPanelsResultMsg {
type: "collaboration.panels.result";
requestId: string;
/** The GOAL session — the orchestrator, even when a child was asked for. */
sessionId: string;
/** Newest fan-out first. */
panels: CollaborationPanel[];
}

/** One index row: what exists, at what version, by whom — never a body. */
export interface BlackboardIndexEntry {
/** A core kind (`spec`, `research`, …) or `extra/<key>`. */
Expand Down Expand Up @@ -2129,6 +2188,7 @@ export type DaemonMessage =
| ClaudeConfigResultMsg
| BlackboardIndexResultMsg
| BlackboardReadResultMsg
| CollaborationPanelsResultMsg
| ModelsListResultMsg
| SessionExportResultMsg
| SessionImportResultMsg
Expand Down
93 changes: 93 additions & 0 deletions src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import type {
ClientMessage,
CollaborationConfig,
CollaborationCost,
CollaborationPanel,
CollaborationRole,
DaemonMessage,
McpServerStatus,
Expand Down Expand Up @@ -193,6 +194,19 @@ function normalizeWorkdir(input: string): string | null {
* unbounded resume can block startup or OOM. Cap to the newest-N sessions and
* stop past a deadline (applied WITHIN each transcript parse too, not just
* between sessions); the rest stay on disk (loadable on a future restart). */
/**
* Recent fan-outs a client is shown per goal. A sidebar needs the live one plus
* a little history for context, not an orchestrator's whole dispatch career.
*/
const PANEL_HISTORY_LIMIT = 5;

/** Task statuses that will never change again — mirrors the barrier's rule. */
const TERMINAL_TASK_STATUS: ReadonlySet<string> = new Set([
"done",
"failed",
"blocked",
]);

const RESUME_MAX_SESSIONS = 50;
const RESUME_DEADLINE_MS = 20_000;
/** Per-session transcript read budget on resume. Scrollback keeps at most
Expand Down Expand Up @@ -851,6 +865,8 @@ mcpHub: this.#mcpHub,
return this.#blackboardIndex(msg, auth);
case "blackboard.read":
return this.#blackboardRead(msg, auth);
case "collaboration.panels":
return this.#collaborationPanels(msg, auth);
case "models.list":
return this.#modelsList(msg);
case "session.export":
Expand Down Expand Up @@ -1626,6 +1642,83 @@ mcpHub: this.#mcpHub,
};
}

/**
* Live panel state for one goal (§7) — what a client needs to SHOW a fan-out
* while it is running.
*
* Gated on `session:list`, the same tier as `blackboard.index`: this is
* metadata about sessions the holder can already enumerate, carrying no
* prompts and no artifact bodies. Scoped to the goal's OWN dispatches via
* `createdBy`, so one collaboration's panels never surface in another's UI.
*/
#collaborationPanels(
msg: Extract<ClientMessage, { type: "collaboration.panels" }>,
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,
};
}
const goal = resolved.goal;
const rows = this.#store.dispatchRecentGroups(
goal.accountId,
goal.projectId,
orchestratorCreatedBy(goal.id),
PANEL_HISTORY_LIMIT,
);

// Group in insertion order of first appearance: the store already returns
// newest-group-first, so a Map preserves that without a second sort.
const byGroup = new Map<string, CollaborationPanel>();
for (const t of rows) {
if (!t.groupId) continue;
let panel = byGroup.get(t.groupId);
if (!panel) {
panel = {
groupId: t.groupId,
createdAt: t.createdAt,
members: [],
settled: 0,
joined: false,
};
byGroup.set(t.groupId, panel);
}
panel.members.push({
sessionId: t.targetSession,
ordinal: t.groupOrdinal ?? panel.members.length + 1,
status: t.status,
});
if (TERMINAL_TASK_STATUS.has(t.status)) panel.settled++;
}
const panels = [...byGroup.values()].map((p) => ({
...p,
members: [...p.members].sort((a, b) => a.ordinal - b.ordinal),
// `joined` is derived from the members rather than from a stored flag, so
// it cannot disagree with what the same payload shows.
joined: p.members.length > 0 && p.settled === p.members.length,
}));

return {
type: "collaboration.panels.result",
requestId: msg.id,
sessionId: goal.id,
panels,
};
}

#fsErr(requestId: string, err: unknown): DaemonMessage {
if (err instanceof FsAccessError) {
return { type: "response.error", requestId, error: err.message, code: err.code };
Expand Down
41 changes: 41 additions & 0 deletions src/daemon/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,47 @@ export class Store {
.immediate();
}

/**
* Members of the most recent dispatch GROUPS created by one dispatcher, newest
* group first and ordinal-ordered within each — the client-facing panel view.
*
* Returns member rows rather than pre-grouped shapes so the caller decides how
* many groups to keep; `limitGroups` bounds the work in SQL via a subquery on
* distinct group ids, because an orchestrator that has run fifty panels should
* not stream fifty groups to a sidebar.
*/
dispatchRecentGroups(
accountId: string,
projectId: string,
createdBy: string,
limitGroups = 5,
): DispatchTaskRow[] {
const rows = this.#db
.prepare(
`SELECT * FROM dispatch_tasks
WHERE account_id = ? AND project_id = ? AND created_by = ?
AND group_id IN (
SELECT group_id FROM dispatch_tasks
WHERE account_id = ? AND project_id = ? AND created_by = ?
AND group_id IS NOT NULL
GROUP BY group_id
ORDER BY MAX(created_at) DESC
LIMIT ?
)
ORDER BY created_at DESC, group_ordinal ASC, id ASC`,
)
.all(
accountId,
projectId,
createdBy,
accountId,
projectId,
createdBy,
limitGroups,
) as RawDispatchRow[];
return rows.map(rowToDispatchTask);
}

/**
* Every member of one dispatch group, oldest first — the barrier's read
* (docs/collaborative-session-design.md §7 step 3).
Expand Down
Loading
Loading