diff --git a/docs/architecture.md b/docs/architecture.md
index 315715c828..c3b2130fb3 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -54,6 +54,7 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d
| `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops |
| `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks |
| `ctx.compact` | `CompactService` (abstract) | dsh-compact | compaction seam: decide when history is too large, summarize an older range into a single surface node |
+| `ctx.sessionFork` | `SessionForkService` | dsh-session-fork | live-session fork seam: validate turn-boundary forks, snapshot seed events, create forked child sessions |
All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically.
@@ -88,7 +89,7 @@ A `Session` is an append-only log of typed `SessionEvent`s — the single source
- `tool/result` → user message carrying a `tool-result` block
- `context/message`, `steering/message` → user-role messages wrapped in a tagged envelope (`…`) at their chronological position — the "system-reminder" pattern; models distinguish them from real user prompts by the envelope. **TODO(review)**: the real adapters now exist (the original precondition); the envelope still wants a deliberate review against live model behavior (`TODO(review)` in dsh-session).
-Replay/fork = `ctx.sessions.create(id, { seed: seedEvents })`. Trace/telemetry = listen to `session/event`.
+Replay/fork = `ctx.sessions.create(id, { seed: seedEvents })`; user-facing live-session fork policy lives in the optional `ctx.sessionFork` service, which rejects non-boundary forks instead of changing the core store. Trace/telemetry = listen to `session/event`.
**Durability seam**: `session/event` is a synchronous notification; persistence plugins buffer (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. The durable backend is a real **capability seam**: the abstract `SessionPersistence` service (`dsh-session-persistence`, `ctx.sessionPersistence`) defines create/append/load/list over the existing `SessionEvent` (no parallel persisted type), and `dsh-session-persistence-jsonl` is the first implementation — an append-only JSONL log per session with crash-safe atomic writes, crash recovery that PRESERVES an interrupted turn (closing it with a synthetic `turn/end {interrupted}` rather than truncating — a turn can be huge), and a read/replay path. Session metadata (format version, cwd, lineage) travels separately as `SessionHeader`, attached to a `Session` via `session.header`. Resuming a persisted session into a live agent is `ctx.agents.resume({ resumeSessionId })`. A second backend, `dsh-session-persistence-sqlite` (`node:sqlite`, one row per `SessionEvent` — the row shape `(session_id, seq, type, time, data)` maps 1:1 onto it), passes the same `runPersistenceContract` suite, proving the seam is genuinely backend-agnostic.
@@ -194,6 +195,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl
| Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) |
| Queued + steering messages | core `Agent.send()` / `Agent.steer()` |
| Context compaction (auto + manual) | the `ctx.compact` seam ([dsh-compact](../packages/compact/compact)): a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure at turn boundaries, manual = a `/compact` tool. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) |
+| Session fork | the `ctx.sessionFork` seam ([dsh-session-fork](../packages/session-fork/session-fork)): validate the source is at a turn boundary, snapshot its seed, and create a child session with `parentSession`/`seedLength` metadata. |
| System prompt configurability | `ctx.systemPrompt.section()` with ordering |
| AGENTS.md (root) | a section provider reading the file |
| AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener |
@@ -223,4 +225,4 @@ Tracked here deliberately — each is designed-for but not implemented:
- **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events.
- **Compaction implementation** (auto thresholds, summarization prompts) on the `agent/request` seam, with its session-event types added by declaration merging.
- **Parallel tool execution** (concurrency-safety hints on ToolDefinition).
-- **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking.
+- **Session branching/tree** (pi-style entry tree) if needed beyond the current seed-based `ctx.sessionFork` service.
diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md
index 42c5f7440c..7b701a47c3 100644
--- a/docs/cordis-catalog/events-and-services.md
+++ b/docs/cordis-catalog/events-and-services.md
@@ -393,6 +393,17 @@ Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../co
Source: [`packages/llm/llm/src/index.ts:69`](../../packages/llm/llm/src/index.ts)
+### `ctx.sessionFork` — `SessionForkService`
+
+`ctx.sessionFork`: validates live session fork boundaries and creates seeded child sessions using the existing `ctx.sessions.create({ seed })` primitive.
+
+```ts cordis-catalog
+snapshot(source: SessionForkSource): SessionForkSeed
+fork(options: ForkSessionOptions): Session
+```
+
+Source: [`packages/session-fork/session-fork/src/index.ts:63`](../../packages/session-fork/session-fork/src/index.ts)
+
### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md
index 757e6fb400..2d1e60c916 100644
--- a/docs/core-data-structures/core.md
+++ b/docs/core-data-structures/core.md
@@ -22,6 +22,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
+| [session-fork.md](session-fork.md) | the session fork seam: live-session boundary validation, seed snapshot metadata, and child-session creation |
> Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts.
diff --git a/docs/core-data-structures/session-fork.md b/docs/core-data-structures/session-fork.md
new file mode 100644
index 0000000000..3bb02a9162
--- /dev/null
+++ b/docs/core-data-structures/session-fork.md
@@ -0,0 +1,19 @@
+# Session Fork
+
+The session fork service is an optional capability over the core session store. It does not add new log events or persisted record shapes; it packages the existing seed primitive into a safe service with a turn-boundary policy.
+
+Package: [`@deepseek-ai/dsh-session-fork`](../../packages/session-fork/session-fork) (`ctx.sessionFork`). The decision and rationale are recorded in [the session fork service RFC](../rfc/implemented/feature/2026-06-30-session-fork-service.md).
+
+## Service Shape
+
+`SessionForkService.snapshot(source)` accepts a live `Session` object or live `SessionId`, validates the source log is empty or ends at `turn/end`, then returns the resolved source, a deep-cloned `SessionEvent[]` seed, and child metadata: `parentSession`, `seedLength`, and optional inherited `cwd`.
+
+`SessionForkService.fork({ source, sessionId? })` is a convenience wrapper around `ctx.sessions.create(sessionId, { seed, meta })`. Consumers that create agents can use `snapshot()` directly and pass the returned seed/meta through the agent factory instead of creating a detached session first.
+
+## Boundary Policy
+
+The boundary rule is structural: every `turn/end` reason is forkable, and every non-empty log whose last event is not `turn/end` is rejected. This is intentionally stricter than the subagent fork backend, which clips to the parent's last completed-turn prefix because it is usually invoked from inside the parent's active tool turn.
+
+## Persistence
+
+No persistence method is added. A forked child is just a normal live session with seed events already present at creation time, so existing persistence backends persist the inherited prefix and header metadata through `session/created` and `session/flush`.
diff --git a/docs/module-graph.md b/docs/module-graph.md
index c2736abca1..ae971376b7 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -22,6 +22,7 @@ graph TD
compact --> session
llm-replay --> llm
llm-replay --> session
+ session-fork --> session
session-persistence --> session
invariants --> agent
invariants --> llm
@@ -108,6 +109,7 @@ graph TD
| `agent` | `brand`, `llm`, `session` |
| `compact` | `llm`, `session` |
| `llm-replay` | `llm`, `session` |
+| `session-fork` | `session` |
| `session-persistence` | `session` |
| `invariants` | `agent`, `llm`, `session` |
| `session-persistence-jsonl` | `session`, `session-persistence` |
diff --git a/docs/rfc/README.md b/docs/rfc/README.md
index 52676cdebf..e1e6075aa8 100644
--- a/docs/rfc/README.md
+++ b/docs/rfc/README.md
@@ -86,6 +86,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 |
| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
+| [Session fork service](implemented/feature/2026-06-30-session-fork-service.md) | 2026-06-30 |
### Simplification
diff --git a/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md b/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md
new file mode 100644
index 0000000000..6b59f0a9f6
--- /dev/null
+++ b/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md
@@ -0,0 +1,53 @@
+# RFC: Session fork service
+
+Status: implemented (proposed 2026-06-30, accepted 2026-06-30)
+
+## Context
+
+The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level. It lives on `dsh-session` as `ctx.sessions.create(id, { seed })`, while durable metadata such as `parentSession` and `seedLength` is stored on the out-of-log `SessionHeader` introduced by [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md). The same mechanics already support in-process subagent fork children and replay routing for forked child logs.
+
+What is missing is a reusable product service for ordinary session forking. Putting that directly on `dsh-session` would make a derived workflow part of the core log API, even though the core session package should stay focused on append-only storage, derived history, and lifecycle events. The harness architecture prefers optional capability plugins over widening the core spine; [event-sourced sessions](../../implemented/architecture/2026-06-11-event-sourced-sessions.md) provide the log semantics, and [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) provide the extension pattern.
+
+The main semantic hazard is the fork boundary. A session event log is only a valid seed when it is contiguous and balanced. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates the turn-enclosure and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. The existing [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: a tool-triggered subagent fork usually happens while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should reject attempts made away from a boundary.
+
+## Decision
+
+The shipped design adds an optional product package, `@deepseek-ai/dsh-session-fork`, under `packages/session-fork/session-fork`. It registers `ctx.sessionFork` and depends only on `cordis` plus the `dsh-session` vocabulary/service. No new session event types, persistence methods, ACP methods, agent-loop hooks, or subagent behavior are added in the first cut.
+
+The service exposes two operations:
+
+```ts ignore-check
+type SessionForkSource = Session | SessionId
+
+interface SessionForkSeed {
+ source: Session
+ seed: SessionEvent[]
+ meta: {
+ parentSession: SessionId
+ seedLength: number
+ cwd?: string
+ }
+}
+
+interface ForkSessionOptions {
+ source: SessionForkSource
+ sessionId?: SessionId
+}
+
+class SessionForkService extends Service {
+ snapshot(source: SessionForkSource): SessionForkSeed
+ fork(options: ForkSessionOptions): Session
+}
+```
+
+`snapshot()` is the reusable half. It resolves only live sessions from `ctx.sessions`; v1 does not load unloaded persisted sessions by id. It validates the source is at a turn boundary, deep-clones the source events, and returns the seed plus metadata a caller can pass to a later session or agent creation path. This shape keeps the fork computation reusable for future ACP or agent-facing consumers without coupling this service to `ctx.agents`.
+
+`fork()` is the convenience half. It calls `snapshot()`, then creates a live child session via `ctx.sessions.create(sessionId, { seed, meta })`. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the seeded prefix length. When `sessionId` is omitted, `SessionStore` generates one using its existing id policy.
+
+The boundary rule is structural: an empty source log is forkable, and any source whose last event is `turn/end` is forkable regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). Any non-empty source whose last event is not `turn/end` is inside a turn or otherwise not at the boundary and is rejected with a typed `SessionForkError` code. This is intentionally stricter than `dsh-subagent-fork`, whose completed-prefix clipping remains unchanged because it serves tool-time delegation rather than user/session branching.
+
+## Consequences
+
+The feature is a small capability seam rather than a change to `dsh-session`: the core log keeps its low-level seed primitive, while `dsh-session-fork` owns policy, error taxonomy, and convenience creation. Persistence continues to work through existing `session/created` and `session/flush` behavior: a forked child starts life with seeded events, so existing backends persist that seed once and preserve `parentSession` / `seedLength` in the header.
+
+The v1 scope deliberately excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. Those can consume `snapshot()` later. If a future ACP method is added, it should advertise the capability only after it has transcript/snapshot coverage; this RFC adds no editor-facing updates, so no ACP snapshot is required now. Fork-child replay remains covered by the existing [seed-boundary testing RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md), while this service gets focused unit tests plus one persistence integration test.
diff --git a/packages/README.md b/packages/README.md
index 3997fd5190..4d1f8b6f32 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -14,6 +14,7 @@ Packages are grouped by modular role at `packages///`. The group dir
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (backend + tool deferred) | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
+| [`session-fork/`](session-fork/README.md) | Session fork capability family: live-session fork snapshots and child session creation | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
@@ -31,6 +32,7 @@ dsh-session ← dsh-llm, dsh-brand
dsh-system-prompt ← dsh-llm
dsh-agent ← dsh-llm, dsh-session, dsh-brand
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; backend + tool deferred)
+dsh-session-fork ← dsh-session (live-session fork snapshots + child session creation)
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
dsh-bash-local ← dsh-bash (BashExecutor impl)
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
@@ -70,6 +72,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
| `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
| `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` |
+| `session-fork/` | `session-fork` | Session fork service over live session seeds | `ctx.sessionFork` |
| `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
| `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
| `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` |
diff --git a/packages/session-fork/README.md b/packages/session-fork/README.md
new file mode 100644
index 0000000000..ea4e97e3bb
--- /dev/null
+++ b/packages/session-fork/README.md
@@ -0,0 +1,9 @@
+# session-fork/ — session fork capability family
+
+The session fork capability: a small optional service that validates a live session is at a turn boundary, snapshots its event log as a seed, and creates forked child sessions through the existing `dsh-session` seed primitive. All **product** packages.
+
+| Package | Role | ctx key |
+|---|---|---|
+| `session-fork/` | Session fork service: reusable seed snapshot + forked live-session creation | `ctx.sessionFork` |
+
+The interface and implementation live together at `session-fork/session-fork/` because v1 has no swappable backend: all durable behavior is delegated to the existing session store and persistence backends. The decision is recorded in [the session fork service RFC](../../docs/rfc/implemented/feature/2026-06-30-session-fork-service.md).
diff --git a/packages/session-fork/session-fork/README.md b/packages/session-fork/session-fork/README.md
new file mode 100644
index 0000000000..7f89d299c4
--- /dev/null
+++ b/packages/session-fork/session-fork/README.md
@@ -0,0 +1,29 @@
+# @deepseek-ai/dsh-session-fork
+
+Session fork service (`ctx.sessionFork`) for creating seeded child sessions from a live source session at a turn boundary.
+
+## Service: `SessionForkService`
+
+`SessionForkService` is an optional plugin over `dsh-session`; it does not add session events or persistence methods. It owns fork policy, while `ctx.sessions.create(id, { seed, meta })` remains the low-level replay/fork primitive.
+
+| Method | Purpose |
+|---|---|
+| `snapshot(source)` | Resolve a live `Session | SessionId`, reject non-boundary logs, and return a deep-cloned seed plus `parentSession` / `seedLength` metadata. |
+| `fork({ source, sessionId? })` | Create a live child session from `snapshot(source)`, using the caller-supplied child id or the session store's generated id. |
+
+## Boundary Rule
+
+A source is forkable only when its log is empty or its last event is `turn/end`. The service accepts any turn-end reason, including `aborted`, `error`, `disposed`, `max-tokens`, and crash-repaired `interrupted`; the boundary is structural, not a statement that the prior turn was successful.
+
+Forking inside a turn is rejected with `SessionForkError` code `OPEN_TURN`. The service intentionally does not clip to an older completed prefix; that behavior is specific to `dsh-subagent-fork`, where tool-time delegation normally happens while the parent turn is open.
+
+## Errors
+
+| Code | Meaning |
+|---|---|
+| `SESSION_NOT_FOUND` | A source id is not live in `ctx.sessions`, or a passed `Session` object is not the live store object for its id. |
+| `OPEN_TURN` | The source log is non-empty and does not end at `turn/end`. |
+
+## Persistence
+
+Forked sessions use existing session metadata: `parentSession` points to the source session id, `seedLength` is the number of inherited events, and `cwd` is inherited when present. Persistence backends observe the forked child through their existing `session/created` and `session/flush` write path, so no backend-specific fork API is needed.
diff --git a/packages/session-fork/session-fork/package.json b/packages/session-fork/session-fork/package.json
new file mode 100644
index 0000000000..73dd30b349
--- /dev/null
+++ b/packages/session-fork/session-fork/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@deepseek-ai/dsh-session-fork",
+ "description": "Session fork service for creating seeded child sessions at turn boundaries",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-session": "^0.0.1",
+ "cordis": "^4.0.0-rc.6"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+ "cordis": "^4.0.0-rc.6"
+ }
+}
diff --git a/packages/session-fork/session-fork/src/index.ts b/packages/session-fork/session-fork/src/index.ts
new file mode 100644
index 0000000000..0754e86ce1
--- /dev/null
+++ b/packages/session-fork/session-fork/src/index.ts
@@ -0,0 +1,128 @@
+/**
+ * Session forking as an optional service. The core session store exposes the
+ * low-level seed primitive; this plugin owns the policy for when a live session
+ * may be forked and the metadata stamped on the child.
+ *
+ * @module @deepseek-ai/dsh-session-fork
+ */
+
+import { Context, Service } from 'cordis'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+
+declare module 'cordis' {
+ interface Context {
+ sessionFork: SessionForkService
+ }
+}
+
+/** A fork source: either the live session object or its live store id. */
+export type SessionForkSource = Session | SessionId
+
+/** Metadata and seed events that can create a forked child session or agent. */
+export interface SessionForkSeed {
+ /** The resolved live source session. */
+ source: Session
+ /** Deep-cloned seed events copied from the source session at a turn boundary. */
+ seed: SessionEvent[]
+ /** Session creation metadata for the forked child. */
+ meta: {
+ /** The source session id. */
+ parentSession: SessionId
+ /** How many leading child events were inherited rather than produced. */
+ seedLength: number
+ /** The source session workspace, inherited by the child when present. */
+ cwd?: string
+ }
+}
+
+/** Inputs for the convenience session-creation path. */
+export interface ForkSessionOptions {
+ /** Live source session object or id. */
+ source: SessionForkSource
+ /** Optional child session id; omitted delegates to SessionStore's id policy. */
+ sessionId?: SessionId
+}
+
+export type SessionForkErrorCode =
+ | 'SESSION_NOT_FOUND'
+ | 'OPEN_TURN'
+
+/** Typed error for service-level fork rejections. */
+export class SessionForkError extends Error {
+ constructor(message: string, public readonly code: SessionForkErrorCode) {
+ super(message)
+ this.name = 'SessionForkError'
+ }
+}
+
+/**
+ * `ctx.sessionFork`: validates live session fork boundaries and creates seeded
+ * child sessions using the existing `ctx.sessions.create({ seed })` primitive.
+ */
+export class SessionForkService extends Service {
+ static inject = ['sessions']
+
+ constructor(ctx: Context) {
+ super(ctx, 'sessionFork')
+ }
+
+ /**
+ * Resolve and validate a live source session, then return a reusable deep-
+ * cloned fork seed. A non-empty source must end exactly at `turn/end`; this
+ * service rejects open turns rather than clipping to an older boundary.
+ */
+ snapshot(source: SessionForkSource): SessionForkSeed {
+ const session = this.resolve(source)
+ this.assertTurnBoundary(session)
+ const seed = session.events.map(event => structuredClone(event))
+ return {
+ source: session,
+ seed,
+ meta: {
+ ...session.header.cwd !== undefined ? { cwd: session.header.cwd } : {},
+ parentSession: session.id,
+ seedLength: seed.length,
+ },
+ }
+ }
+
+ /**
+ * Convenience path: create a live child session from a fork snapshot. Callers
+ * that create agents can use {@link snapshot} and pass its seed/meta through
+ * `ctx.agents.create` instead.
+ */
+ fork(options: ForkSessionOptions): Session {
+ const snapshot = this.snapshot(options.source)
+ return this.ctx.sessions.create(options.sessionId, {
+ seed: snapshot.seed,
+ meta: snapshot.meta,
+ })
+ }
+
+ private resolve(source: SessionForkSource): Session {
+ if (typeof source === 'string') {
+ const session = this.ctx.sessions.get(source)
+ if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND')
+ return session
+ }
+
+ const live = this.ctx.sessions.get(source.id)
+ if (live !== source) {
+ throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
+ }
+ return source
+ }
+
+ private assertTurnBoundary(session: Session): void {
+ const last = session.events.at(-1)
+ if (last !== undefined && last.type !== 'turn/end') {
+ throw new SessionForkError(
+ `cannot fork session "${session.id}" inside an open turn (last event: ${last.type})`,
+ 'OPEN_TURN',
+ )
+ }
+ }
+}
+
+export default SessionForkService
diff --git a/packages/session-fork/session-fork/tests/session-fork.spec.ts b/packages/session-fork/session-fork/tests/session-fork.spec.ts
new file mode 100644
index 0000000000..c5c931c732
--- /dev/null
+++ b/packages/session-fork/session-fork/tests/session-fork.spec.ts
@@ -0,0 +1,209 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionForkService, { SessionForkError } from '../src/index.ts'
+
+const tempDirs: string[] = []
+
+afterEach(async () => {
+ for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
+})
+
+async function tempRoot(): Promise {
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-session-fork-'))
+ tempDirs.push(dir)
+ return dir
+}
+
+async function setup(): Promise<{ ctx: Context; fork: SessionForkService }> {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionForkService)
+ return { ctx, fork: ctx.sessionFork }
+}
+
+function appendClosedTurn(session: Session, reason: TurnEndReason = { kind: 'completed' }): void {
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('user/message', {
+ content: [{ type: 'text', text: 'hello' }],
+ source: { kind: 'user' },
+ }, { surfaceOp: 'append' })
+ session.append('turn/end', { turn: 1, reason })
+}
+
+function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/message'> {
+ const event = events.find((e): e is SessionEvent<'user/message'> => e.type === 'user/message')
+ if (event === undefined) throw new Error('missing user/message')
+ return event
+}
+
+describe('SessionForkService', () => {
+ it('registers as ctx.sessionFork and unregisters on fiber disposal', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ const fiber = await ctx.plugin(SessionForkService)
+ expect(ctx.sessionFork).toBeInstanceOf(SessionForkService)
+
+ await fiber.dispose()
+
+ expect(ctx.sessionFork).toBeUndefined()
+ })
+
+ it('snapshots an empty live session as an empty seed with lineage metadata', async () => {
+ const { ctx, fork } = await setup()
+ const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } })
+
+ const snapshot = fork.snapshot(source)
+
+ expect(snapshot.source).toBe(source)
+ expect(snapshot.seed).toEqual([])
+ expect(snapshot.meta).toEqual({
+ cwd: '/workspace',
+ parentSession: SessionId('empty-parent'),
+ seedLength: 0,
+ })
+ })
+
+ it('snapshots a completed boundary by live session id and deep-clones seed events', async () => {
+ const { ctx, fork } = await setup()
+ const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
+ appendClosedTurn(source)
+
+ const snapshot = fork.snapshot(SessionId('parent'))
+
+ expect(snapshot.source).toBe(source)
+ expect(snapshot.seed).toEqual(source.events)
+ expect(snapshot.seed).not.toBe(source.events)
+ expect(snapshot.seed[1]).not.toBe(source.events[1])
+ firstUserMessage(snapshot.seed).data.content[0] = { type: 'text', text: 'mutated' }
+ expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
+ expect(snapshot.meta).toEqual({
+ cwd: '/workspace',
+ parentSession: SessionId('parent'),
+ seedLength: source.events.length,
+ })
+ })
+
+ it('accepts every turn/end reason as a fork boundary', async () => {
+ const { ctx, fork } = await setup()
+ const reasons: TurnEndReason[] = [
+ { kind: 'completed' },
+ { kind: 'aborted', reason: 'cancelled by user' },
+ { kind: 'error', step: 1, message: 'model failed', code: 'MODEL' },
+ { kind: 'disposed' },
+ { kind: 'max-tokens' },
+ { kind: 'interrupted' },
+ ]
+
+ for (const reason of reasons) {
+ const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`))
+ appendClosedTurn(source, reason)
+
+ const snapshot = fork.snapshot(source)
+
+ expect(snapshot.seed.at(-1)?.type).toBe('turn/end')
+ expect(snapshot.meta.seedLength).toBe(source.events.length)
+ }
+ })
+
+ it('rejects an unknown live session id', async () => {
+ const { fork } = await setup()
+
+ expect(() => fork.snapshot(SessionId('missing')))
+ .toThrow(new SessionForkError('session "missing" not found', 'SESSION_NOT_FOUND'))
+ })
+
+ it('rejects a detached Session object that is not live in ctx.sessions', async () => {
+ const { fork } = await setup()
+ const detached = new Session(SessionId('detached'))
+
+ expect(() => fork.snapshot(detached))
+ .toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
+ })
+
+ it('rejects non-empty logs whose last event is not turn/end', async () => {
+ const { ctx, fork } = await setup()
+ const cases: [string, (session: Session) => void][] = [
+ ['turn/start', (session) => {
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ }],
+ ['step/start', (session) => {
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('step/start', { turn: 1, step: 1 })
+ }],
+ ['user/message', (session) => {
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('user/message', { content: [{ type: 'text', text: 'open' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+ }],
+ ['assistant/message', (session) => {
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('step/start', { turn: 1, step: 1 })
+ session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'partial' }] }, { surfaceOp: 'append' })
+ }],
+ ['tool/call', (session) => {
+ const callId = CallId('call-open')
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('step/start', { turn: 1, step: 1 })
+ session.append('assistant/message', {
+ turn: 1,
+ step: 1,
+ content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
+ }, { surfaceOp: 'append' })
+ session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
+ }],
+ ]
+
+ for (const [lastType, build] of cases) {
+ const source = ctx.sessions.create(SessionId(`open-${lastType}`))
+ build(source)
+
+ expect(() => fork.snapshot(source))
+ .toThrow(new SessionForkError(`cannot fork session "open-${lastType}" inside an open turn (last event: ${lastType})`, 'OPEN_TURN'))
+ }
+ })
+
+ it('creates a forked child session with the seed and lineage metadata', async () => {
+ const { ctx, fork } = await setup()
+ const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
+ appendClosedTurn(source)
+
+ const child = fork.fork({ source, sessionId: SessionId('child') })
+
+ expect(child.id).toBe(SessionId('child'))
+ expect(child.events).toEqual(source.events)
+ expect(child.header.parentSession).toBe(source.id)
+ expect(child.header.seedLength).toBe(source.events.length)
+ expect(child.header.cwd).toBe('/workspace')
+ firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
+ expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
+ })
+
+ it('persists a forked child seed through the existing session write path', async () => {
+ const root = await tempRoot()
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionForkService)
+ await ctx.plugin(SessionPersistenceJsonl, { root })
+ const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
+ appendClosedTurn(source)
+
+ const child = ctx.sessionFork.fork({ source, sessionId: SessionId('persist-child') })
+ await ctx.parallel('session/flush', child)
+ const loaded = await ctx.sessionPersistence.load(child.id)
+
+ expect(loaded.events).toEqual(source.events)
+ expect(loaded.meta).toMatchObject({
+ id: SessionId('persist-child'),
+ cwd: '/workspace',
+ parentSession: SessionId('persist-parent'),
+ seedLength: source.events.length,
+ })
+ await ctx.fiber.dispose()
+ })
+})
diff --git a/packages/session-fork/session-fork/tsconfig.json b/packages/session-fork/session-fork/tsconfig.json
new file mode 100644
index 0000000000..e817086a6a
--- /dev/null
+++ b/packages/session-fork/session-fork/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../core/session"
+ }
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fc57472bef..873c02f0d1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -289,6 +289,21 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+ packages/session-fork/session-fork:
+ devDependencies:
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ '@deepseek-ai/dsh-session-persistence-jsonl':
+ specifier: workspace:^
+ version: link:../../session-persistence/session-persistence-jsonl
+ cordis:
+ specifier: ^4.0.0-rc.6
+ version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
packages/session-persistence/session-persistence:
devDependencies:
'@deepseek-ai/dsh-session':
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 3f2d828cb4..787d983dbf 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -45,6 +45,7 @@
"./packages/bash/*/src",
"./packages/compact/*/src",
"./packages/subagent/*/src",
+ "./packages/session-fork/*/src",
"./packages/todo/*/src",
"./packages/session-persistence/*/src",
"./packages/ui/*/src",
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 22ba7a0687..f1ece94409 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -23,6 +23,7 @@
{ "path": "./packages/core/agent-core" },
{ "path": "./packages/bash/bash" },
{ "path": "./packages/compact/compact" },
+ { "path": "./packages/session-fork/session-fork" },
{ "path": "./packages/llm/llm-deepseek" },
{ "path": "./packages/llm/llm-pi-ai" },
{ "path": "./packages/bash/bash-local" },
diff --git a/tsconfig.json b/tsconfig.json
index f0a4389df5..d27afdf866 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -38,6 +38,7 @@
{ "path": "./packages/bash/bash-local" },
{ "path": "./packages/bash/tool-bash" },
{ "path": "./packages/compact/compact" },
+ { "path": "./packages/session-fork/session-fork" },
{ "path": "./packages/support/invariants" },
{ "path": "./packages/ui/acp" },
{ "path": "./packages/ui/acp-agent" },