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
25 changes: 19 additions & 6 deletions docs/conductor-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,26 @@ Three additions, no architectural change:
| **`codeoid_fleet` MCP server** | In-process Agent-SDK MCP server exposing fleet tools (list / spawn / send / watch / summarize / interrupt sessions, recall across threads). Bound to the conductor session only. | `buildMemoryMcpServer` at `session.ts:810` |
| **Conductor identity grant** | The conductor's ZeroID agent identity additionally holds `session:*` scopes, so it can drive the fleet *as a first-class delegated authority* (see §4). | `AgentIdentityManager.registerSessionAgent` |

Injection point is already there: `session.ts:810` merges `codeoid_memory` into the
Injection point is already there: the Claude provider merges `codeoid_memory` into the
`mcpServers` passed to `query()`. The conductor adds `codeoid_fleet` the same way,
gated on `role === "conductor"`. One P3 gotcha: the Claude provider's
`allowedTools` currently allowlists only `mcp__codeoid_memory__*`
(`providers/claude/index.ts`) — it must be widened to admit
`mcp__codeoid_fleet__*` for the conductor session, or the mounted server's tools
stay unreachable.
gated on `role === "conductor"`. *(Implemented in P3:* the manager builds the
fleet server — its tools close over the live, tenant-scoped session population —
and passes it to the conductor's `Session`; the Claude provider's `allowedTools`
is widened with `mcp__codeoid_fleet__*` when a fleet server is present, and the
system-prompt append path no longer gates on memory so the conductor contract
rides the `claude_code` preset.*)*

**Read-only over targets, by construction (P3 scope).** The P3 fleet surface is
`fleet_list` / `fleet_find` / `fleet_summary` / `fleet_recall` / `machine_map` —
observation only. No send/spawn/interrupt tool exists yet (those are P4), and the
conductor identity (P2) carries only `session:read`/`session:dispatch`, never
`tools:write`/`tools:execute`, so nothing it delegates can mutate a target.

**Provider-agnostic conductor.** Which backend drives the conductor is
`config.conductor.provider` — any registered provider id, so an open-weight
backend can run it once its provider exists. Caveat: MCP tools are only surfaced
by the Claude provider today, so a conductor on another provider chats but can't
see the fleet until that provider grows MCP support (the daemon logs this).

---

Expand Down
14 changes: 14 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ export const sessionCreateSchema = z.object({
type: z.literal("session.create"),
name: nameField,
workdir: pathField,
/**
* Session role. "conductor" requests THE per-tenant conductor session —
* the daemon chooses its name/workdir itself, creates it on first request,
* and returns the existing one afterwards (idempotent). Absent = a normal
* coding session.
*
* Validated as a bounded string, not a literal, on purpose: the frame must
* PARSE even for a role this daemon doesn't implement (a newer client, a
* future P4 worker role) — the daemon then fail-closes with a clear
* "unsupported role" error rather than the schema opaquely rejecting the
* whole create. Matches the "accept the frame, act on what you understand"
* wire contract.
*/
role: z.string().max(LIMITS.NAME_MAX).optional(),
});

export const sessionListSchema = z.object({ ...base, type: z.literal("session.list") });
Expand Down
16 changes: 16 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ export interface SessionInfo {
createdBy: string;
createdAt: string;
attachedClients: number;
/**
* Session role. "conductor" marks the per-tenant conductor session (the
* fleet supervisor — one per account/project). Absent = normal session.
*/
role?: "conductor";
/** Id of the provider backing this session (e.g. "claude", "gemini"). */
providerId?: string;
/** Current execution mode (default "interactive"). */
mode?: SessionMode;
/** Remaining turns budget for autonomous mode (undefined = unbounded, 0 = exhausted). */
Expand Down Expand Up @@ -690,6 +697,15 @@ export interface SessionCreateMsg extends BaseClientMsg {
type: "session.create";
name: string;
workdir: string;
/**
* Session role. "conductor" requests THE per-tenant conductor session —
* the daemon chooses its name/workdir itself, creates it on first request,
* and returns the existing one afterwards (idempotent). Absent = a normal
* coding session. Typed as an open string (not the `"conductor"` literal)
* so a future role from a newer client still type-checks on the wire; the
* daemon rejects roles it doesn't implement.
*/
role?: string;
}

/**
Expand Down
9 changes: 8 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ const CODEOID_LOGIN_SCOPES = [
"session:interrupt",
"session:approve",
"session:destroy",
// Conductor scopes — the owner delegates these to its conductor identity
// (owner → conductor RFC 8693 exchange). Without them in the owner's token
// the delegation's scope intersection is empty and the conductor can't act.
"session:read",
"session:dispatch",
"fs:read",
"tools:read",
"tools:write",
Expand Down Expand Up @@ -315,7 +320,9 @@ program

program
.command("attach <session>")
.description("Attach to a session (interactive streaming)")
.description(
"Attach to a session by id or name (interactive streaming). Use 'conductor' to open the fleet supervisor (created on first use).",
)
.action(async (session: string) => {
const config = loadConfig();
const client = new TerminalClient(config);
Expand Down
33 changes: 33 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,26 @@ const AgentIdentitySchema = z
})
.default({ accountId: "personal", projectId: "dev" });

/**
* Conductor session — the per-tenant fleet supervisor (docs/conductor-design.md).
* `provider` selects which backend drives it (any registered provider id, so an
* open-weight backend can run the conductor once its provider exists); `model`
* overrides the provider's default. Note: fleet MCP tools currently surface
* only under the "claude" provider (the one provider with MCP support) — a
* conductor on another provider still chats but cannot see the fleet yet.
*/
const ConductorSchema = z
.object({
enabled: z.boolean().default(true),
/** Display name of the conductor session (also what `codeoid attach conductor` resolves). */
name: z.string().default("conductor"),
/** Provider id driving the conductor ("claude" | "gemini" | "openai" | future). */
provider: z.string().default("claude"),
/** Model override for the conductor (alias or full id). Empty = provider default. */
model: z.string().optional(),
})
.default({ enabled: true, name: "conductor", provider: "claude" });

const AuthSchemaFields = z
.object({
issuer: z.string().optional(),
Expand Down Expand Up @@ -334,6 +354,7 @@ const RootSchema = z.object({
telemetry: TelemetrySchema,
autoRotate: AutoRotateSchema,
session: SessionSchema,
conductor: ConductorSchema,
});

type ParsedConfig = z.infer<typeof RootSchema>;
Expand Down Expand Up @@ -413,6 +434,17 @@ export interface CodeoidConfig {
/** Per-call timeout (ms) for external MCP servers, surfaced as the SDK's per-server `timeout`. 0 = use SDK default. Defaults to 120000 when omitted. */
mcpToolTimeoutMs?: number;
};
/**
* The per-tenant conductor session (fleet supervisor). Optional in the
* type so hand-built test configs stay minimal; loadConfig always
* populates it (schema defaults). Absent = enabled with defaults.
*/
conductor?: {
enabled: boolean;
name: string;
provider: string;
model?: string;
};
}

// ── Env-var override map ─────────────────────────────────────────────────
Expand Down Expand Up @@ -636,6 +668,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig {
telemetry: { osc8: osc8Mode },
autoRotate: parsed.autoRotate,
session: parsed.session,
conductor: parsed.conductor,
};
}

Expand Down
Loading
Loading