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
32 changes: 32 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,27 @@ const nameField = z.string().min(1).max(LIMITS.NAME_MAX);

const base = { id: idField };

// ── Collaboration ─────────────────────────────────────────────────────────────

/**
* A role→backend binding (`CollaborationRole`). `name` and `providerId` are
* bounded strings rather than enums: role taxonomy is data (§3), and an
* unknown provider must reach the daemon so it can name the registered ones
* back to the caller.
*/
export const collaborationRoleSchema = z.object({
name: nameField,
providerId: z.string().min(1).max(64),
model: z.string().max(LIMITS.MODEL_MAX).optional(),
count: z.number().int().min(1).max(LIMITS.COLLABORATION_ROLE_COUNT_MAX).optional(),
purpose: z.string().max(500).optional(),
});

export const collaborationConfigSchema = z.object({
goal: z.string().min(1).max(LIMITS.COLLABORATION_GOAL_MAX),
roles: z.array(collaborationRoleSchema).min(1).max(LIMITS.COLLABORATION_ROLES_MAX),
});

// ── Attachments ───────────────────────────────────────────────────────────────

export const attachmentSchema = z
Expand Down Expand Up @@ -76,6 +97,17 @@ export const sessionCreateSchema = z.object({
* schema opaquely rejecting the whole create.
*/
providerId: z.string().min(1).max(64).optional(),
/**
* Collaborative session config (docs/collaborative-session-design.md §9).
*
* Shape only, here. The SEMANTIC rules — provider registered, exactly one
* orchestrator, orchestrator on claude in v1, model belongs to its role's
* backend — live in the daemon, not the schema, for the same reason
* `providerId` is a bounded string rather than an enum: the frame must
* PARSE so the daemon can answer with a specific, actionable error
* instead of the schema opaquely rejecting the whole create.
*/
collaboration: collaborationConfigSchema.optional(),
/**
* Activate an installed SDLC pack on this session (ambient mode —
* docs/pack-loading.md): its constitution is injected into the system
Expand Down
75 changes: 75 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ export const LIMITS = {
UI_TEXT_MAX: 65_536,
/** Max number of options on a `session.ui_request` select. */
UI_OPTIONS_MAX: 64,
/** Max `CollaborationConfig.goal` length. A goal is a brief, not a spec. */
COLLABORATION_GOAL_MAX: 8192,
/** Max distinct roles in one collaboration. */
COLLABORATION_ROLES_MAX: 16,
/**
* Max children a single role may fan out to (`CollaborationRole.count`).
* A schema-level backstop only — the live-worker cap (P3) is what actually
* governs concurrency at run time.
*/
COLLABORATION_ROLE_COUNT_MAX: 8,
} as const;

// =============================================================================
Expand Down Expand Up @@ -230,6 +240,12 @@ export interface SessionInfo {
* workdir with no git isolation.
*/
worktree?: SessionWorktree;
/**
* Collaboration this session orchestrates, when it was created with the
* Collaborative toggle. Absent = a normal session. Persisted, so it
* survives a daemon restart the way `role`/`providerId` already do.
*/
collaboration?: CollaborationConfig;
}

/** A git worktree backing a session's workdir (see SessionInfo.worktree). */
Expand Down Expand Up @@ -795,10 +811,69 @@ interface BaseClientMsg {
id: string;
}

/**
* One role in a collaborative session — a `{backend, model}` binding chosen
* per purpose (docs/collaborative-session-design.md §3).
*
* `name` is deliberately a free-form string, not an enum: "a role is data,
* not an enum". The five defaults (orchestrator / search / reasoning /
* architecture / review) are a starting profile, so adding
* "security-reviewer" or "test-author" stays a config change, never a code
* change.
*/
export interface CollaborationRole {
/** Role name, unique within the collaboration. */
name: string;
/**
* Backend this role's children run on. Must be an id the daemon
* advertised in `AuthOkMsg.providers`; an unregistered id is rejected with
* `invalid_request` rather than silently falling back — the same
* fail-closed rule as `SessionCreateMsg.providerId`.
*/
providerId: string;
/** Model within that backend. Absent = that backend's own default. */
model?: string;
/**
* How many children to fan out for this role. >1 is what makes a review
* panel a panel (§7). Absent = 1.
*/
count?: number;
/** What this role is for; surfaced in the child's brief. */
purpose?: string;
}

/** The role name that must be present exactly once in a collaboration, and
* which drives dispatch for the goal. */
export const ORCHESTRATOR_ROLE = "orchestrator";

/**
* Collaborative-session config: one goal worked by several role-children on
* possibly different backends. Set on `session.create` behind the
* Collaborative toggle, which compiles it to an ephemeral one-goal pack
* (§9) — pack vocabulary stays hidden on this path.
*/
export interface CollaborationConfig {
/** The single goal this collaboration works. */
goal: string;
/**
* Role→backend bindings. Exactly one role must be named "orchestrator";
* in v1 it must sit on the claude backend, the only one that mounts the
* fleet MCP server (non-Claude orchestrators tracked in #245).
*/
roles: CollaborationRole[];
}

export interface SessionCreateMsg extends BaseClientMsg {
type: "session.create";
name: string;
workdir: string;
/**
* Turn this into a collaborative session: one goal, several role-children
* on their own backends (docs/collaborative-session-design.md). Validated
* fail-closed — unknown provider, missing/duplicate orchestrator, or a
* model that doesn't belong to its role's backend all reject the create.
*/
collaboration?: CollaborationConfig;
/**
* Session role. "conductor" requests THE per-tenant conductor session —
* the daemon chooses its name/workdir itself, creates it on first request,
Expand Down
56 changes: 54 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { program } from "commander";
// string to drift). release-smoke asserts these two stay equal.
import pkg from "../package.json" with { type: "json" };
import { DaemonServer } from "./daemon/server.js";
import { parseRoleSpec } from "./daemon/collaboration.js";
import type { CollaborationConfig } from "./protocol/types.js";
import { TerminalClient } from "./terminal/client.js";
import {
getConfigDir,
Expand Down Expand Up @@ -306,11 +308,29 @@ program
"--pack-role <role>",
"Run the session under a capability role the pack declares (e.g. reviewer = read-only). Requires --pack.",
)
.option(
"--collaborate <goal>",
"Make this a collaborative session working <goal> with several role-children on their own backends. Requires at least one --role, including an orchestrator.",
)
.option(
"--role <spec>",
'Role→backend binding, repeatable: "name:provider[:model][*count]" (e.g. orchestrator:claude, reasoning:openai:gpt-5-codex, review:gemini*3). Requires --collaborate.',
(value: string, previous: string[] = []) => [...previous, value],
[] as string[],
)
.action(
async (
name: string,
workdir: string | undefined,
opts: { worktree?: string; repo?: string; worktreeDir?: string; pack?: string; packRole?: string },
opts: {
worktree?: string;
repo?: string;
worktreeDir?: string;
pack?: string;
packRole?: string;
collaborate?: string;
role: string[];
},
) => {
const config = loadConfig();
let resolvedWorkdir = workdir;
Expand All @@ -327,9 +347,41 @@ program
console.error("workdir is required (pass as argument or use --worktree).");
process.exit(1);
}

// Collaborative session (docs/collaborative-session-design.md). Parsed
// here for a fast, local error; the daemon re-validates the semantics
// (provider registered, exactly one orchestrator, claude-only
// orchestrator in v1) so the CLI and the wire path fail identically.
let collaboration: CollaborationConfig | undefined;
const roleSpecs = opts.role ?? [];
if (opts.collaborate) {
if (roleSpecs.length === 0) {
console.error(
'--collaborate requires at least one --role (e.g. --role orchestrator:claude --role review:gemini*3).',
);
process.exit(1);
}
try {
collaboration = {
goal: opts.collaborate,
roles: roleSpecs.map(parseRoleSpec),
};
} catch (e) {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
}
} else if (roleSpecs.length > 0) {
console.error("--role requires --collaborate <goal>.");
process.exit(1);
}

const client = new TerminalClient(config);
await client.connect();
await client.createSession(name, resolvedWorkdir, { pack: opts.pack, packRole: opts.packRole });
await client.createSession(name, resolvedWorkdir, {
pack: opts.pack,
packRole: opts.packRole,
collaboration,
});
client.disconnect();
},
);
Expand Down
Loading
Loading