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 @@ -99,6 +99,7 @@ const samples: { [T in ClientTypes]: Extract<ClientMessage, { type: T }> } = {
sessionId: "s1",
providerId: "pi",
},
"session.fork": { type: "session.fork", id: "r16f", sessionId: "s1", name: "branch", providerId: "codex" },
"session.rename": { type: "session.rename", id: "r16", sessionId: "s1", name: "renamed" },
"fs.list": { type: "fs.list", id: "r17", sessionId: "s1", path: "src" },
"fs.read": { type: "fs.read", id: "r18", sessionId: "s1", path: "src/a.ts", maxBytes: 1024 },
Expand Down
9 changes: 9 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,14 @@ export const sessionSetModelSchema = z.object({
fallbackModel: z.string().max(LIMITS.MODEL_MAX).nullable().optional(),
});

export const sessionForkSchema = z.object({
...base,
type: z.literal("session.fork"),
sessionId: sessionIdField,
name: nameField.optional(),
providerId: z.string().min(1).max(64).optional(),
});

export const sessionRenameSchema = z.object({
...base,
type: z.literal("session.rename"),
Expand Down Expand Up @@ -306,6 +314,7 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
sessionSearchSchema,
sessionSetModelSchema,
sessionSetProviderSchema,
sessionForkSchema,
sessionRenameSchema,
fsListSchema,
fsReadSchema,
Expand Down
28 changes: 28 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@ export type ClientMessage =
| SessionSearchMsg
| SessionSetModelMsg
| SessionSetProviderMsg
| SessionForkMsg
| SessionRenameMsg
| FsListMsg
| FsReadMsg
Expand Down Expand Up @@ -736,6 +737,33 @@ export interface SessionCreateMsg extends BaseClientMsg {
providerId?: string;
}

/**
* Fork a session — branch its conversation into a brand-new session. The
* fork gets a fresh id and starts with a COPY of the parent's canonical
* history and scrollback, so it continues the same conversation from the
* same point; the parent is untouched and both evolve independently.
*
* Optionally forks onto a DIFFERENT backend in one step (`providerId`) —
* "branch this claude conversation and continue it on codex". The fork's
* backing agent is always fresh (a claude session id means nothing to
* codex), so history crosses via the same seed mechanism as
* `session.set_provider`: stateless backends replay it natively, warm
* backends receive a rendered transcript.
*
* Returns the new session's `SessionInfo` (like `session.create`). Rejected
* with `not_found` for an unknown/foreign session and `invalid_request` for
* an unknown `providerId` (fail-closed, same rule as create/set_provider).
*/
export interface SessionForkMsg extends BaseClientMsg {
type: "session.fork";
/** Session to branch from. */
sessionId: string;
/** Name for the fork. Absent = the parent's name + " (fork)". */
name?: string;
/** Backend for the fork. Absent = the parent's current backend. */
providerId?: string;
}

/**
* Rename a session. Daemon updates `SessionInfo.name` in-memory + in the
* transcript store and broadcasts `session.info_update` so every attached
Expand Down
12 changes: 12 additions & 0 deletions src/daemon/providers/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,18 @@ export class CanonicalHistoryAccumulator {
this.#currentThinking = "";
this.#currentTools.clear();
}

/**
* Replace the history with a DEEP copy of `turns` — used to prime a FORK
* from its parent's canonical history (`session.fork`). Clears any
* in-progress turn state, like reset(). structuredClone (not a spread) so
* the fork and parent never share nested `toolCalls` / `input` objects —
* a mutation on either side can't leak to the other.
*/
seed(turns: readonly CanonicalTurn[]): void {
this.reset();
this.#history = turns.map((t) => structuredClone(t));
}
Comment thread
saucam marked this conversation as resolved.
}

// ── History seeding (provider switch) ─────────────────────────────────────────
Expand Down
97 changes: 97 additions & 0 deletions src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,8 @@ export class SessionManager {
return this.#setModel(msg, auth);
case "session.set_provider":
return this.#setProvider(msg, auth);
case "session.fork":
return this.#fork(msg, auth);
case "session.rename":
return this.#rename(msg, auth);
case "fs.list":
Expand Down Expand Up @@ -1090,6 +1092,101 @@ export class SessionManager {
};
}

/**
* Fork a session (`session.fork`) — branch its conversation into a new,
* independent session seeded with a COPY of the parent's canonical history
* and scrollback. Optionally onto a different backend (`providerId`), so
* "branch this claude conversation and continue it on codex" is one call.
* The parent is untouched.
*
* Fails closed: a foreign/unknown parent is `not_found`; an unknown
* providerId is `invalid_request` (same rule as create/set_provider).
*/
async #fork(
msg: Extract<ClientMessage, { type: "session.fork" }>,
auth: AuthContext,
): Promise<DaemonMessage> {
if (!hasScope(auth.scopes as string[], SCOPES.SESSION_CREATE)) {
return { type: "response.error", requestId: msg.id, error: "Missing scope: session:create", code: "forbidden" };
}

const parent = this.#getOwnedSession(msg.sessionId, auth);
if (!parent) {
return { type: "response.error", requestId: msg.id, error: "Session not found", code: "not_found" };
}
// Forking THE conductor would mint a second fleet supervisor — refuse.
if (parent.role) {
return { type: "response.error", requestId: msg.id, error: `Cannot fork a ${parent.role} session`, code: "invalid_request" };
}

const providerId = msg.providerId ?? parent.providerId;
if (providerId && !this.#providers.has(providerId)) {
return {
type: "response.error",
requestId: msg.id,
error: `Unknown provider "${providerId}" — available: ${this.#providers.ids().join(", ")}`,
code: "invalid_request",
};
}

const rateCheck = this.#rateLimiter.check(auth.sub);
if (!rateCheck.allowed) {
return { type: "response.error", requestId: msg.id, error: rateCheck.reason, code: "rate_limited" };
}

// Snapshot the parent's state BEFORE building the fork. Canonical history
// is the source of truth for the conversation; the transcript rows are
// replayed into the fork's scrollback for UI visibility.
const history = parent.canonicalHistory.map((t) => ({ ...t }));
const parentInfo = parent.toInfo();
let transcriptRows: DaemonMessage[] = [];
let sizeHints: Array<number | undefined> = [];
try {
const entries = await this.#transcriptStore.loadTranscript(msg.sessionId, {
maxBytes: RESUME_TRANSCRIPT_MAX_BYTES,
});
transcriptRows = entries.map((e) => e.message);
sizeHints = entries.map((e) => e.bytes);
} catch (err) {
// Scrollback replay is best-effort — the fork's CONVERSATION is carried
// by the canonical history above, which is already in memory.
console.error(
`[codeoid/fork] transcript load failed for ${msg.sessionId} (fork keeps history, drops scrollback): ${err instanceof Error ? err.message : String(err)}`,
);
}

const fork = new Session({
name: msg.name ?? `${parentInfo.name} (fork)`,
workdir: parent.workdir,
auth,
store: this.#store,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
hooks: this.#hooks,
providerId,
identityManager: this.#identityManager,
memory: this.#memory,
config: this.#config,
compressionRegistry: this.#compressionRegistry,
_testProvider: this.#testProviderFactory?.(),
onStatusChange: this.#statusObserver,
onModels: (pid, m) => this._cacheModels(pid, m),
});

await fork.primeFromFork(history, transcriptRows, sizeHints);

this.#sessions.set(fork.id, fork);
this.#rateLimiter.recordCreation(auth.sub);
this.#store.audit(
auth.sub,
"session.fork",
fork.id,
`from=${msg.sessionId} provider=${providerId ?? "default"} turns=${history.length}`,
);

return { type: "response.ok", requestId: msg.id, data: fork.toInfo() };
}

/**
* Create — or return — THE conductor session for the caller's tenant
* (design §3, build plan P3). Idempotent: one conductor per
Expand Down
88 changes: 76 additions & 12 deletions src/daemon/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* - Scrollback stores merged SessionMessage (not deltas)
*/

import { CanonicalHistoryAccumulator } from "./providers/canonical.js";
import { CanonicalHistoryAccumulator, type CanonicalTurn } from "./providers/canonical.js";
import { CONDUCTOR_SYSTEM_PROMPT_APPEND, isFleetSendTool } from "./fleet.js";
import type { McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk";
import {
Expand Down Expand Up @@ -784,17 +784,7 @@ export class Session {
// Offer the canonical history to the incoming provider. Best-effort by
// contract: a seed failure degrades to an unseeded switch, never a
// wedged session.
let seeded = false;
if (this.#provider.seedFromHistory && this.#accumulator.history.length > 0) {
try {
await this.#provider.seedFromHistory(this.#accumulator.history);
seeded = true;
} catch (err) {
console.error(
`[codeoid/session ${this.id}] seedFromHistory failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
const seeded = await this.#seedProviderFromHistory();

this.#store.audit(
sender.sub,
Expand Down Expand Up @@ -1674,6 +1664,80 @@ export class Session {
await this.#transcriptStore.delete(this.id);
}

/**
* Offer the accumulated canonical history to the current provider —
* shared by `switchProvider` and `session.fork`. Stateless backends no-op
* (they consume TurnOpts.history every turn); warm backends (claude, pi,
* codex) prepend a rendered transcript to their first prompt. Best-effort
* by contract: a throw degrades to an unseeded start, never a wedge.
* Returns whether a seed was actually applied.
*/
async #seedProviderFromHistory(): Promise<boolean> {
if (!this.#provider.seedFromHistory || this.#accumulator.history.length === 0) {
return false;
}
try {
await this.#provider.seedFromHistory(this.#accumulator.history);
return true;
} catch (err) {
console.error(
`[codeoid/session ${this.id}] seedFromHistory failed: ${err instanceof Error ? err.message : String(err)}`,
);
return false;
}
}

/**
* The provider-neutral conversation history. A FORK is seeded from this
* (`session.fork` copies the parent's history into the child's
* accumulator), so the branch continues the same conversation on whatever
* backend it runs.
*/
get canonicalHistory(): readonly CanonicalTurn[] {
return this.#accumulator.history;
}
Comment thread
saucam marked this conversation as resolved.

/**
* Prime a freshly-constructed FORK from its parent: seed the canonical
* history (so the fork's provider — same or different backend — continues
* the conversation), replay the parent's transcript into scrollback (so
* the fork's UI shows the prior exchange), and offer the history to the
* provider. Called once by SessionManager#fork before the fork is
* registered; never on a normal session. The fork already carries a fresh
* id + fresh backing id, so it can never resume the parent's native state.
*/
async primeFromFork(
history: readonly CanonicalTurn[],
transcript: DaemonMessage[],
sizeHints?: ReadonlyArray<number | undefined>,
): Promise<void> {
this.#accumulator.seed(history);
// Replay the parent's transcript into the fork for visibility (reusing
// restoreScrollback's frozen-tool reconciliation), but DON'T let its
// "prior scrollback ⇒ setHasQueried(true)" fire: a fork's backend is
// brand new and must run its first turn as a create, not a resume — so
// handle scrollback directly here.
//
// Two things the naive replay gets wrong (Gemini review): the copied
// rows still carry the PARENT's sessionId, and they aren't written to
// the fork's (empty) transcript — so clients see foreign ids and the
// scrollback vanishes on the next daemon restart. Restamp each row with
// the fork's id and persist it, so the fork is a genuinely independent
// session with its own durable history.
for (let i = 0; i < transcript.length; i++) {
const row = transcript[i]!;
if (row.type !== "session.message") continue;
const msg = reconcileResumedMessage({ ...row, sessionId: this.id });
this.#scrollback.push(msg, sizeHints?.[i]);
this.#transcriptStore.append(this.id, msg, this.#seq++).catch((e) => {
console.error(
`[codeoid/fork ${this.id}] transcript prime append failed: ${e instanceof Error ? e.message : String(e)}`,
);
});
}
await this.#seedProviderFromHistory();
}

restoreScrollback(
messages: DaemonMessage[],
nextSeq?: number,
Expand Down
Loading
Loading