Просмотр исходного кода

Merge pull request #119 from deepseek-harness/session-fork

Add session fork service
Tianyi Cui 2 месяцев назад
Родитель
Сommit
e477d76199

+ 1 - 1
AGENTS.md

@@ -96,7 +96,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR
 - **Symmetry is usually more correct**: parallel values get parallel form; asymmetry smells of a missed extraction.
 - **Tests document behavior, not golden truth**: a green test pins what the code DOES, not what it SHOULD do. Before preserving a behavior solely for its test, ask whether it is load-bearing; an artifact changes together with its test, with the why in the PR ([worked example](docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md)).
 - **RFCs are proposals, not golden truth**: validate its premise against current code before implementing; friction is evidence of over-reach — amend on the way to `implemented/` ([worked example](docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
-- **Testing policy** — tiers, with-key generosity, real-over-mock, world-verification, real-load-path and published-bin guards: [docs/testing.md](docs/testing.md). A transcript/UX-affecting change needs a snapshot test, or a PR note why none applies.
+- **Testing policy** — [docs/testing.md](docs/testing.md). Transcript/UX changes need snapshots or a PR note. Snapshot fixtures must replay on macOS/Linux; avoid GNU/BSD-only commands (e.g. `sed -i`); fix fixtures, not normalizers.
 - **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([render-intent RFC](docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md), [cookbook](docs/cookbook/adding-a-tool.md)).
 - **A new capability seam, lifecycle shape, or transcript surface names its coverage at every tier (unit, e2e, snapshot) at plan time** and verifies the harness can express it — a gap is scheduled work, not a mid-build surprise.
 - **Merge PRs with merge commits** (`gh pr merge --merge`), never squash/rebase. **Never rewrite a pushed branch**; update a child by merging its parent down. **A review fix lands on the PR that introduced the issue, as a separate commit**, then merges down ([stacked-review guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)).

+ 2 - 1
docs/architecture.md

@@ -108,7 +108,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the
 
 The session log is the source of truth. `deriveMessages()` projects session events into the `Message[]` sent to the model; raw `assistant/chunk` events stay in the log for replay and UI fidelity. Replay, fork, resume, transcript rendering, telemetry, and persistence all derive from the same event stream.
 
-**Model-visible ⟺ logged**: the log reconstructs every conversation request byte-for-byte — messages by derivation at the `step/start` boundary, the header (system prompt, tools, model + sampling) by folding `request/header` events — asserted per request by the dev invariant ([reconstructability RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)).
+**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start`, headers by folding `request/header` — and dev invariants assert this ([reconstructability RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)).
 
 Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite.
 
@@ -143,5 +143,6 @@ New behavior should attach to a documented seam; changing the shipped loop requi
 | Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall |
 | Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
 | Add durable session state | add a `SessionEventMap` member and render/replay from the log |
+| Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` |
 
 The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md).

+ 3 - 3
docs/config-catalog.md

@@ -218,7 +218,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/hooks/hooks-claude/src/index.ts:55`](../packages/hooks/hooks-claude/src/index.ts)
+Source: [`packages/hooks/hooks-claude/src/index.ts:56`](../packages/hooks/hooks-claude/src/index.ts)
 
 ## `@deepseek-ai/dsh-hooks-codex`
 
@@ -243,7 +243,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts)
+Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts)
 
 ## `@deepseek-ai/dsh-invariants`
 
@@ -329,7 +329,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/support/llm-replay/src/index.ts:411`](../packages/support/llm-replay/src/index.ts)
+Source: [`packages/support/llm-replay/src/index.ts:415`](../packages/support/llm-replay/src/index.ts)
 
 ## `@deepseek-ai/dsh-session-persistence-jsonl`
 

+ 2 - 1
docs/cordis-catalog/services.md

@@ -161,9 +161,10 @@ enter(session: Session): () => void
 announce(session: Session): void
 get(id: SessionId): Session | undefined
 list(): Session[]
+fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
 ```
 
-Source: [`packages/core/session/src/index.ts:371`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:389`](../../packages/core/session/src/index.ts)
 
 ## `ctx.subagents` — `SubagentService`
 

+ 8 - 0
docs/core-data-structures/session.md

@@ -200,6 +200,14 @@ export interface SurfaceNode {
 
 Everything else (`turn/*`, `step/*`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`.
 
+## Live-session fork API
+
+`ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` exposes one policy API:
+
+- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `boundary` seq (default: current last event), requires the boundary event to be `turn/end`, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`).
+
+An explicit `boundary` lets callers fork from a previous completed turn even if the source has newer events or an open current turn. The API rejects non-`turn/end` boundaries instead of clipping silently. Broader turn-enclosure sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit.
+
 ## What started a turn: `TurnTriggerMap`
 
 ```ts type-equiv

+ 1 - 0
docs/rfc/INDEX.md

@@ -58,6 +58,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 |
 | [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
 | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
+| [SessionStore fork API](implemented/feature/2026-06-30-session-store-fork-api.md) | 2026-06-30 |
 | [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
 
 ### Simplification

+ 41 - 0
docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md

@@ -0,0 +1,41 @@
+# RFC: SessionStore fork API
+
+Status: implemented
+
+## Problem
+
+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: `ctx.sessions.create(id, { seed, meta })` accepts any valid seed, but ordinary live-session branching needs policy around which prefix can be copied, which metadata is stamped on the child, and how errors are classified.
+
+The semantic hazard is the fork boundary. A valid user-visible fork seed must be contiguous and turn-enclosed. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates 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: tool-triggered subagent forks usually happen 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 either fork the requested boundary or reject it.
+
+## Decision
+
+`dsh-session` owns ordinary live-session forking directly on `ctx.sessions`. There is no separate `dsh-session-fork` package or `ctx.sessionFork` service: the API has no independent backend, event vocabulary, lifecycle, or persistence behavior, and all durable work delegates to the existing session store and persistence backends.
+
+The store exposes one operation:
+
+```ts ignore-check
+type SessionForkSource = Session | SessionId
+
+class SessionStore extends Service {
+  fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
+}
+```
+
+`boundary` is the inclusive source event `seq` to copy through. When omitted, it defaults to the source session's current last event; on an empty source, omitted `boundary` creates an empty child. Fork-specific validation only checks that the requested boundary exists and is a `turn/end`. The selected prefix is then deep-cloned into the child seed. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the copied prefix length. When `childSessionId` is omitted, `SessionStore` generates one using its existing id policy.
+
+The boundary rule is structural: an empty selected prefix is forkable, and any non-empty selected prefix must end at `turn/end`, regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). A boundary that is not an existing event seq, is not a safe integer, or does not point at `turn/end` is rejected with a typed `SessionForkError` code. Broader session-log sanity remains in the existing invariant/repair layers: `dsh-invariants` checks turn enclosure and richer event ordering in dev, while persistence repair handles the valid crash-tail case of a final interrupted turn. The API also classifies non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), duplicate requested child ids (`SESSION_ALREADY_EXISTS`), and invalid boundary values (`INVALID_BOUNDARY`).
+
+## Alternatives considered
+
+**Separate `ctx.sessionFork` service.** This was the first implementation, but review showed it overfit the capability-seam pattern. The code had no swappable backend, no extra event surface, no independent ownership lifecycle, and no durable behavior beyond `ctx.sessions.create({ seed, meta })`. Keeping a separate package would make callers discover and install a second service just to perform policy around a session-store primitive.
+
+**Two functions: `snapshot()` plus `fork()`.** This preserved a reusable seed/metadata computation, but the only supported consumer created a session immediately. It also made the surface feel more abstract than the concrete operation users need. A single `fork()` with an explicit `boundary` keeps the API direct while still supporting previous-point forks.
+
+**Silently clip open turns to the last completed boundary.** That is correct for `dsh-subagent-fork`, where delegation often starts while the parent turn is open and the child should inherit only the completed prefix. It is wrong for ordinary user/session branching because it hides that the requested fork point was not actually a valid boundary and silently drops the parent turn tail.
+
+## Consequences
+
+The public surface stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service or a two-step helper pair. 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 still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. 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 API gets focused `dsh-session` unit tests plus JSONL persistence coverage.

+ 40 - 40
examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl

@@ -180,7 +180,7 @@
 {"type":"assistant/chunk","seq":178,"time":1783279386389,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}}
 {"type":"assistant/chunk","seq":179,"time":1783279386416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}}
 {"type":"assistant/chunk","seq":180,"time":1783279386416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
-{"type":"assistant/chunk","seq":181,"time":1783279386416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sed"}}}
+{"type":"assistant/chunk","seq":181,"time":1783279386416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" awk"}}}
 {"type":"assistant/chunk","seq":182,"time":1783279386416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
 {"type":"assistant/chunk","seq":183,"time":1783279386444,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}}
 {"type":"assistant/chunk","seq":184,"time":1783279386444,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
@@ -197,47 +197,47 @@
 {"type":"assistant/chunk","seq":195,"time":1783279386528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
 {"type":"assistant/chunk","seq":196,"time":1783279386614,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","seq":197,"time":1783279386615,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":""}}}
-{"type":"assistant/chunk","seq":198,"time":1783279386615,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"{"}}}
-{"type":"assistant/chunk","seq":199,"time":1783279386615,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":200,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"command"}}}
-{"type":"assistant/chunk","seq":201,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":202,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":": "}}}
-{"type":"assistant/chunk","seq":203,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":204,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"sed"}}}
-{"type":"assistant/chunk","seq":205,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" -"}}}
-{"type":"assistant/chunk","seq":206,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"i"}}}
-{"type":"assistant/chunk","seq":207,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" '"}}}
-{"type":"assistant/chunk","seq":208,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"s"}}}
-{"type":"assistant/chunk","seq":209,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"/"}}}
-{"type":"assistant/chunk","seq":210,"time":1783279386699,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"blue"}}}
-{"type":"assistant/chunk","seq":211,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"/g"}}}
-{"type":"assistant/chunk","seq":212,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"reen"}}}
-{"type":"assistant/chunk","seq":213,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"/g"}}}
-{"type":"assistant/chunk","seq":214,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"'"}}}
-{"type":"assistant/chunk","seq":215,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" settings"}}}
-{"type":"assistant/chunk","seq":216,"time":1783279386726,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":".txt"}}}
-{"type":"assistant/chunk","seq":217,"time":1783279386727,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":218,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":", "}}}
-{"type":"assistant/chunk","seq":219,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":220,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"description"}}}
-{"type":"assistant/chunk","seq":221,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":222,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":": "}}}
-{"type":"assistant/chunk","seq":223,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":224,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"Replace"}}}
-{"type":"assistant/chunk","seq":225,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" blue"}}}
-{"type":"assistant/chunk","seq":226,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" with"}}}
-{"type":"assistant/chunk","seq":227,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" green"}}}
-{"type":"assistant/chunk","seq":228,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" in"}}}
-{"type":"assistant/chunk","seq":229,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" settings"}}}
-{"type":"assistant/chunk","seq":230,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":".txt"}}}
-{"type":"assistant/chunk","seq":231,"time":1783279386837,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":232,"time":1783279386838,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"}"}}}
-{"type":"assistant/chunk","seq":233,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit tool requires reading the file first due to the default fs-policy. However, the user explicitly instructed me NOT to use the read tool. I need to follow the user's instructions. The edit tool returned an error saying it requires reading the file first. I cannot comply with the user's request to not read the file because the tool enforces it. Let me try a different approach - maybe I can use subagent to do this, or use bash to do the replacement. Let me use bash with sed to replace \"blue\" with \"green\" without reading the file."}}}}
-{"type":"assistant/chunk","seq":234,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\": \"sed -i 's/blue/green/g' settings.txt\", \"description\": \"Replace blue with green in settings.txt\"}"}}}}
+{"type":"assistant/chunk","seq":198,"time":1783279386615,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"{\"com"}}}
+{"type":"assistant/chunk","seq":199,"time":1783279386615,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"mand\""}}}
+{"type":"assistant/chunk","seq":200,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":":\"awk"}}}
+{"type":"assistant/chunk","seq":201,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" '{gs"}}}
+{"type":"assistant/chunk","seq":202,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ub(/b"}}}
+{"type":"assistant/chunk","seq":203,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"lue/,"}}}
+{"type":"assistant/chunk","seq":204,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\\\"gre"}}}
+{"type":"assistant/chunk","seq":205,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"en\\\")"}}}
+{"type":"assistant/chunk","seq":206,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"}1' s"}}}
+{"type":"assistant/chunk","seq":207,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ettin"}}}
+{"type":"assistant/chunk","seq":208,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"gs.tx"}}}
+{"type":"assistant/chunk","seq":209,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"t > s"}}}
+{"type":"assistant/chunk","seq":210,"time":1783279386699,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ettin"}}}
+{"type":"assistant/chunk","seq":211,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"gs.tm"}}}
+{"type":"assistant/chunk","seq":212,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"p && "}}}
+{"type":"assistant/chunk","seq":213,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"mv se"}}}
+{"type":"assistant/chunk","seq":214,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"tting"}}}
+{"type":"assistant/chunk","seq":215,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"s.tmp"}}}
+{"type":"assistant/chunk","seq":216,"time":1783279386726,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" sett"}}}
+{"type":"assistant/chunk","seq":217,"time":1783279386727,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ings."}}}
+{"type":"assistant/chunk","seq":218,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"txt\","}}}
+{"type":"assistant/chunk","seq":219,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\"des"}}}
+{"type":"assistant/chunk","seq":220,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"crip"}}}
+{"type":"assistant/chunk","seq":221,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"tion"}}}
+{"type":"assistant/chunk","seq":222,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\":\"R"}}}
+{"type":"assistant/chunk","seq":223,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"epla"}}}
+{"type":"assistant/chunk","seq":224,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ce b"}}}
+{"type":"assistant/chunk","seq":225,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"lue "}}}
+{"type":"assistant/chunk","seq":226,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"with"}}}
+{"type":"assistant/chunk","seq":227,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" gre"}}}
+{"type":"assistant/chunk","seq":228,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"en i"}}}
+{"type":"assistant/chunk","seq":229,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"n se"}}}
+{"type":"assistant/chunk","seq":230,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ttin"}}}
+{"type":"assistant/chunk","seq":231,"time":1783279386837,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"gs.t"}}}
+{"type":"assistant/chunk","seq":232,"time":1783279386838,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"xt\"}"}}}
+{"type":"assistant/chunk","seq":233,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit tool requires reading the file first due to the default fs-policy. However, the user explicitly instructed me NOT to use the read tool. I need to follow the user's instructions. The edit tool returned an error saying it requires reading the file first. I cannot comply with the user's request to not read the file because the tool enforces it. Let me try a different approach - maybe I can use subagent to do this, or use bash to do the replacement. Let me use bash with awk to replace \"blue\" with \"green\" without reading the file."}}}}
+{"type":"assistant/chunk","seq":234,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\":\"awk '{gsub(/blue/,\\\"green\\\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt\",\"description\":\"Replace blue with green in settings.txt\"}"}}}}
 {"type":"assistant/chunk","seq":235,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":227,"outputTokens":195,"cacheReadTokens":2048,"reasoningTokens":118}}}}
 {"type":"assistant/chunk","seq":236,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":237,"time":1783279386897,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The edit tool requires reading the file first due to the default fs-policy. However, the user explicitly instructed me NOT to use the read tool. I need to follow the user's instructions. The edit tool returned an error saying it requires reading the file first. I cannot comply with the user's request to not read the file because the tool enforces it. Let me try a different approach - maybe I can use subagent to do this, or use bash to do the replacement. Let me use bash with sed to replace \"blue\" with \"green\" without reading the file."},{"type":"tool-call","id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\": \"sed -i 's/blue/green/g' settings.txt\", \"description\": \"Replace blue with green in settings.txt\"}"}],"usage":{"inputTokens":227,"outputTokens":195,"cacheReadTokens":2048,"reasoningTokens":118}},"sourceEventSeqs":[77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236],"surfaceOp":"append"}
-{"type":"tool/call","seq":238,"time":1783279386897,"data":{"turn":1,"step":2,"callId":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\": \"sed -i 's/blue/green/g' settings.txt\", \"description\": \"Replace blue with green in settings.txt\"}"}}
+{"type":"assistant/message","seq":237,"time":1783279386897,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The edit tool requires reading the file first due to the default fs-policy. However, the user explicitly instructed me NOT to use the read tool. I need to follow the user's instructions. The edit tool returned an error saying it requires reading the file first. I cannot comply with the user's request to not read the file because the tool enforces it. Let me try a different approach - maybe I can use subagent to do this, or use bash to do the replacement. Let me use bash with awk to replace \"blue\" with \"green\" without reading the file."},{"type":"tool-call","id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\":\"awk '{gsub(/blue/,\\\"green\\\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt\",\"description\":\"Replace blue with green in settings.txt\"}"}],"usage":{"inputTokens":227,"outputTokens":195,"cacheReadTokens":2048,"reasoningTokens":118}},"sourceEventSeqs":[77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236],"surfaceOp":"append"}
+{"type":"tool/call","seq":238,"time":1783279386897,"data":{"turn":1,"step":2,"callId":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\":\"awk '{gsub(/blue/,\\\"green\\\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt\",\"description\":\"Replace blue with green in settings.txt\"}"}}
 {"type":"tool/result","seq":239,"time":1783279386915,"data":{"turn":1,"step":2,"callId":"call_00_SvvpTh6bWybYXoO77NHg8535","content":[{"type":"text","text":"(no output)"}],"isError":false},"sourceEventSeqs":[238],"surfaceOp":"append"}
 {"type":"step/end","seq":240,"time":1783279386916,"data":{"turn":1,"step":2}}
 {"type":"step/start","seq":241,"time":1783279386916,"data":{"turn":1,"step":3}}

+ 2 - 2
examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl

@@ -137,7 +137,7 @@
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}}
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}}
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sed"}}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" awk"}}}}
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}}
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
@@ -152,7 +152,7 @@
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_SvvpTh6bWybYXoO77NHg8535","title":"sed -i 's/blue/green/g' settings.txt","kind":"execute","status":"in_progress","rawInput":"sed -i 's/blue/green/g' settings.txt","content":[{"type":"content","content":{"type":"text","text":"Replace blue with green in settings.txt"}}]}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_SvvpTh6bWybYXoO77NHg8535","title":"awk '{gsub(/blue/,\"green\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt","kind":"execute","status":"in_progress","rawInput":"awk '{gsub(/blue/,\"green\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt","content":[{"type":"content","content":{"type":"text","text":"Replace blue with green in settings.txt"}}]}}}
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_SvvpTh6bWybYXoO77NHg8535","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\n(no output)\n```"}}]}}}
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}}

+ 3 - 2
packages/core/session/README.md

@@ -9,6 +9,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
 ### Public API
 
 - `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
+- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
 - `ctx.sessions.get(id: SessionId): Session | undefined`
 - `ctx.sessions.list(): Session[]`
 
@@ -72,9 +73,9 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
 ### Extension points
 
 - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
-- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume.
+- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME always-on invariants `append` enforces — contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through.
 - Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
 
 ### What is NOT here (TODO)
 
-- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond seed-based forking.
+- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.

+ 104 - 0
packages/core/session/src/index.ts

@@ -362,6 +362,24 @@ export class Session {
   }
 }
 
+/** A fork source: either the live session object or its live store id. */
+export type SessionForkSource = Session | SessionId
+
+export type SessionForkErrorCode =
+  | 'SESSION_NOT_FOUND'
+  | 'SESSION_NOT_LIVE'
+  | 'SESSION_ALREADY_EXISTS'
+  | 'INVALID_BOUNDARY'
+  | 'OPEN_TURN'
+
+/** Typed error for session fork rejections. */
+export class SessionForkError extends Error {
+  constructor(message: string, public readonly code: SessionForkErrorCode) {
+    super(message)
+    this.name = 'SessionForkError'
+  }
+}
+
 /**
  * In-memory session store (`ctx.sessions`).
  *
@@ -496,6 +514,92 @@ export class SessionStore extends Service {
   list(): Session[] {
     return [...this.store.values()]
   }
+
+  /**
+   * Create a live child session from a turn-enclosed prefix of a live source.
+   * `boundary` is an inclusive source event seq; omitted means the source's
+   * current last event. A non-empty selected slice must end at `turn/end`.
+   *
+   * @param source - Live source session object or id.
+   * @param boundary - Inclusive source event seq to fork through; omitted means
+   *   the source's current last event, and omitted on an empty source forks an
+   *   empty child.
+   * @param childSessionId - Optional child session id; omitted delegates to
+   *   `SessionStore`'s id policy.
+   * @returns The created live child session.
+   */
+  fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session {
+    if (childSessionId !== undefined && this.get(childSessionId) !== undefined) {
+      throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
+    }
+    const liveSource = this._resolveForkSource(source)
+    const seed = this._forkSeed(liveSource, boundary)
+    return this.create(childSessionId, {
+      seed,
+      meta: {
+        ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
+        parentSession: liveSource.id,
+        seedLength: seed.length,
+      },
+    })
+  }
+
+  private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] {
+    const events = session.events
+    const lastEvent = events.at(-1)
+    let boundary: number
+    if (requestedBoundary !== undefined) {
+      boundary = requestedBoundary
+    } else {
+      if (lastEvent === undefined) return []
+      boundary = lastEvent.seq
+    }
+    if (!Number.isSafeInteger(boundary) || boundary < 0) {
+      throw new SessionForkError(
+        `fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`,
+        'INVALID_BOUNDARY',
+      )
+    }
+    if (boundary >= events.length) {
+      const lastSeq = events.at(-1)?.seq
+      throw new SessionForkError(
+        `fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`,
+        'INVALID_BOUNDARY',
+      )
+    }
+
+    const boundaryEvent = events[boundary]
+    if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
+      throw new SessionForkError(
+        `fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`,
+        'INVALID_BOUNDARY',
+      )
+    }
+    if (boundaryEvent.type !== 'turn/end') {
+      throw new SessionForkError(
+        `fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`,
+        'OPEN_TURN',
+      )
+    }
+
+    return events.slice(0, boundary + 1).map(event => structuredClone(event))
+  }
+
+  private _resolveForkSource(source: SessionForkSource): Session {
+    if (typeof source === 'string') {
+      const session = this.get(source)
+      if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND')
+      return session
+    }
+
+    const live = this.get(source.id)
+    if (live === undefined) {
+      throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
+    }
+    if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE')
+    return source
+  }
+
 }
 
 export default SessionStore

+ 240 - 0
packages/core/session/tests/fork.spec.ts

@@ -0,0 +1,240 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
+
+async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  return { ctx, sessions: ctx.sessions }
+}
+
+function appendClosedTurn(
+  session: Session,
+  turn: number,
+  text = `hello ${turn}`,
+  reason: TurnEndReason = { kind: 'completed' },
+): void {
+  session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
+  session.append('user/message', {
+    content: [{ type: 'text', text }],
+    source: { kind: 'user' },
+  }, { surfaceOp: 'append' })
+  session.append('turn/end', { turn, reason })
+}
+
+function appendOpenTurn(session: Session, turn: number): void {
+  session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
+  session.append('user/message', {
+    content: [{ type: 'text', text: `open ${turn}` }],
+    source: { kind: 'user' },
+  }, { surfaceOp: 'append' })
+}
+
+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
+}
+
+function lastSeq(session: Session): number {
+  const event = session.events.at(-1)
+  if (event === undefined) throw new Error('missing last event')
+  return event.seq
+}
+
+describe('SessionStore.fork', () => {
+  it('forks an empty live session as an empty child with lineage metadata', async () => {
+    const { ctx, sessions } = await setup()
+    const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } })
+
+    const child = sessions.fork(source, undefined, SessionId('empty-child'))
+
+    expect(child.events).toEqual([])
+    expect(child.header).toMatchObject({
+      id: SessionId('empty-child'),
+      cwd: '/workspace',
+      parentSession: SessionId('empty-parent'),
+      seedLength: 0,
+    })
+  })
+
+  it('forks the latest completed boundary by default and deep-clones seed events', async () => {
+    const { ctx, sessions } = await setup()
+    const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
+    appendClosedTurn(source, 1, 'hello')
+
+    const child = sessions.fork(SessionId('parent'), undefined, SessionId('child'))
+
+    expect(child.events).toEqual(source.events)
+    expect(child.events).not.toBe(source.events)
+    expect(child.events[1]).not.toBe(source.events[1])
+    firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
+    expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
+    expect(child.header).toMatchObject({
+      id: SessionId('child'),
+      cwd: '/workspace',
+      parentSession: SessionId('parent'),
+      seedLength: source.events.length,
+    })
+  })
+
+  it('forks from an earlier turn boundary even when the source currently has an open tail', async () => {
+    const { ctx, sessions } = await setup()
+    const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
+    appendClosedTurn(source, 1, 'first')
+    const firstBoundary = lastSeq(source)
+    appendClosedTurn(source, 2, 'second')
+    appendOpenTurn(source, 3)
+
+    const child = sessions.fork(source, firstBoundary, SessionId('child-from-first'))
+
+    expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
+    expect(child.header.seedLength).toBe(firstBoundary + 1)
+    expect(child.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'first' }] }])
+  })
+
+  it('accepts every turn/end reason as an explicit fork boundary', async () => {
+    const { ctx, sessions } = 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, 1, reason.kind, reason)
+
+      const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`))
+
+      expect(child.events.at(-1)?.type).toBe('turn/end')
+      expect(child.header.seedLength).toBe(source.events.length)
+    }
+  })
+
+  it('rejects invalid boundaries before creating a child', async () => {
+    const { ctx, sessions } = await setup()
+    const empty = ctx.sessions.create(SessionId('empty'))
+    expect(() => sessions.fork(empty, 0, SessionId('empty-child')))
+      .toThrow(new SessionForkError('fork boundary 0 does not exist in session "empty" (last seq: none)', 'INVALID_BOUNDARY'))
+    expect(ctx.sessions.get(SessionId('empty-child'))).toBeUndefined()
+
+    const source = ctx.sessions.create(SessionId('parent'))
+    appendClosedTurn(source, 1)
+    expect(() => sessions.fork(source, -1, SessionId('negative')))
+      .toThrow(/non-negative safe integer/)
+    expect(() => sessions.fork(source, 0.5, SessionId('fraction')))
+      .toThrow(/non-negative safe integer/)
+    expect(() => sessions.fork(source, Number.MAX_SAFE_INTEGER + 1, SessionId('unsafe')))
+      .toThrow(/non-negative safe integer/)
+    expect(() => sessions.fork(source, source.seq, SessionId('past-end')))
+      .toThrow(new SessionForkError(`fork boundary ${source.seq} does not exist in session "parent" (last seq: ${source.seq - 1})`, 'INVALID_BOUNDARY'))
+  })
+
+  it('rejects a corrupted live source whose array index no longer matches event seq', async () => {
+    const { ctx, sessions } = await setup()
+    const source = ctx.sessions.create(SessionId('corrupt-parent'))
+    appendClosedTurn(source, 1)
+    const mutableLog = (source as unknown as { log: SessionEvent[] }).log
+    mutableLog[2] = { ...mutableLog[2]!, seq: 99 }
+
+    expect(() => sessions.fork(source, 2, SessionId('corrupt-child')))
+      .toThrow(new SessionForkError('fork boundary 2 does not match a contiguous event seq in session "corrupt-parent"', 'INVALID_BOUNDARY'))
+    expect(ctx.sessions.get(SessionId('corrupt-child'))).toBeUndefined()
+  })
+
+  it('rejects an unknown live session id', async () => {
+    const { sessions } = await setup()
+
+    expect(() => sessions.fork(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 { sessions } = await setup()
+    const detached = new Session(SessionId('detached'))
+
+    expect(() => sessions.fork(detached))
+      .toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
+  })
+
+  it('rejects a stale Session object whose id is live on a different instance', async () => {
+    const { ctx, sessions } = await setup()
+    ctx.sessions.create(SessionId('same-id'))
+    const stale = new Session(SessionId('same-id'))
+
+    expect(() => sessions.fork(stale))
+      .toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE'))
+  })
+
+  it('rejects selected slices whose boundary is inside an open turn', async () => {
+    const { ctx, sessions } = await setup()
+    const cases: [string, (session: Session) => number][] = [
+      ['turn/start', (session) => {
+        session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+        return lastSeq(session)
+      }],
+      ['step/start', (session) => {
+        session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+        session.append('step/start', { turn: 1, step: 1 })
+        return lastSeq(session)
+      }],
+      ['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' })
+        return lastSeq(session)
+      }],
+      ['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' })
+        return lastSeq(session)
+      }],
+      ['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: '{}' })
+        return lastSeq(session)
+      }],
+    ]
+
+    for (const [lastType, build] of cases) {
+      const source = ctx.sessions.create(SessionId(`open-${lastType}`))
+      const boundary = build(source)
+
+      expect(() => sessions.fork(source, boundary))
+        .toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" must be turn/end, got ${lastType}`, 'OPEN_TURN'))
+    }
+  })
+
+  it('rejects a child session id that is already live with a typed fork error', async () => {
+    const { ctx, sessions } = await setup()
+    const source = ctx.sessions.create(SessionId('parent'))
+    appendClosedTurn(source, 1)
+    ctx.sessions.create(SessionId('child'))
+
+    expect(() => sessions.fork(source, undefined, SessionId('child')))
+      .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
+  })
+
+  it('rejects a duplicate child session id before validating the boundary', async () => {
+    const { ctx, sessions } = await setup()
+    const source = ctx.sessions.create(SessionId('open-parent'))
+    source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    ctx.sessions.create(SessionId('child'))
+
+    expect(() => sessions.fork(source, undefined, SessionId('child')))
+      .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
+  })
+})

+ 4 - 0
packages/core/session/tests/session.spec.ts

@@ -61,8 +61,10 @@ describe('Session', () => {
 
   it('replays identically from a seeded event log', () => {
     const original = new Session(SessionId('s3'))
+    original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
     original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
+    original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
 
     const replayed = new Session(SessionId('s3-replay'), [...original.events])
     expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
@@ -423,7 +425,9 @@ describe('todo/write event', () => {
 
   it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
     const original = new Session(SessionId('t4'))
+    original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
+    original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
     // Seeding a non-surface event with no surfaceOp must not throw.
     const replayed = new Session(SessionId('t4-replay'), [...original.events])
     expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)

+ 26 - 0
packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -23,6 +23,15 @@ afterEach(async () => {
   for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
 })
 
+function appendClosedTurn(session: Session): 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: { kind: 'completed' } })
+}
+
 // Run the shared backend contract against the real JSONL backend.
 runPersistenceContract('jsonl', async () => {
   const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
@@ -131,6 +140,23 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
     expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs
   })
 
+  it('persists a forked child seed through the existing session write path', async () => {
+    const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
+    appendClosedTurn(source)
+
+    const child = ctx.sessions.fork(source, undefined, 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,
+    })
+  })
+
   it('crash recovery: load preserves the interrupted turn and closes it with a synthetic turn/end {interrupted}', async () => {
     const m = meta('crash', '/proj')
     await ctx.sessionPersistence.create(m)

+ 2 - 13
pnpm-lock.yaml

@@ -2360,9 +2360,6 @@ packages:
   '@types/tough-cookie@4.0.5':
     resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
 
-  '@types/trusted-types@2.0.7':
-    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
-
   '@types/unist@3.0.3':
     resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
 
@@ -5093,9 +5090,6 @@ snapshots:
 
   '@types/tough-cookie@4.0.5': {}
 
-  '@types/trusted-types@2.0.7':
-    optional: true
-
   '@types/unist@3.0.3': {}
 
   '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
@@ -5189,10 +5183,7 @@ snapshots:
       '@typescript-eslint/types': 8.61.0
       eslint-visitor-keys: 5.0.1
 
-  '@upsetjs/venn.js@2.0.0':
-    optionalDependencies:
-      d3-selection: 3.0.0
-      d3-transition: 3.0.1(d3-selection@3.0.0)
+  '@upsetjs/venn.js@2.0.0': {}
 
   '@vitest/coverage-v8@4.1.8(vitest@4.1.8)':
     dependencies:
@@ -5582,9 +5573,7 @@ snapshots:
 
   diff@9.0.0: {}
 
-  dompurify@3.4.11:
-    optionalDependencies:
-      '@types/trusted-types': 2.0.7
+  dompurify@3.4.11: {}
 
   dts-resolver@3.0.0(oxc-resolver@11.20.0):
     optionalDependencies: