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

Merge pull request #92 from deepseek-ai/worktree-subagent-seam-pr2

Subagent seam (PR2): in-process spawn + fork backends
Tianyi Cui 3 месяцев назад
Родитель
Сommit
e945219df4
39 измененных файлов с 1647 добавлено и 49 удалено
  1. 1 0
      AGENTS.md
  2. 1 1
      docs/architecture.md
  3. 15 15
      docs/cordis-catalog/events-and-services.md
  4. 6 5
      docs/core-data-structures/core.md
  5. 7 0
      docs/core-data-structures/subagent.md
  6. 13 0
      docs/module-graph.md
  7. 2 1
      docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md
  8. 1 1
      docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md
  9. 45 6
      examples/coding-agent/cordis.yml
  10. 4 0
      knip.json
  11. 4 0
      packages/README.md
  12. 10 8
      packages/core/agent-loop/src/index.ts
  13. 12 1
      packages/core/agent/src/index.ts
  14. 6 5
      packages/core/agent/src/types.ts
  15. 4 1
      packages/subagent/README.md
  16. 23 0
      packages/subagent/subagent-fork/README.md
  17. 48 0
      packages/subagent/subagent-fork/package.json
  18. 81 0
      packages/subagent/subagent-fork/src/index.ts
  19. 99 0
      packages/subagent/subagent-fork/tests/multi-subagent.spec.ts
  20. 186 0
      packages/subagent/subagent-fork/tests/subagent-fork.spec.ts
  21. 33 0
      packages/subagent/subagent-fork/tsconfig.json
  22. 28 0
      packages/subagent/subagent-inprocess/README.md
  23. 42 0
      packages/subagent/subagent-inprocess/package.json
  24. 192 0
      packages/subagent/subagent-inprocess/src/index.ts
  25. 85 0
      packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
  26. 30 0
      packages/subagent/subagent-inprocess/tsconfig.json
  27. 19 0
      packages/subagent/subagent-spawn/README.md
  28. 49 0
      packages/subagent/subagent-spawn/package.json
  29. 54 0
      packages/subagent/subagent-spawn/src/index.ts
  30. 49 0
      packages/subagent/subagent-spawn/tests/harness.ts
  31. 54 0
      packages/subagent/subagent-spawn/tests/spawn.e2e.ts
  32. 271 0
      packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
  33. 27 0
      packages/subagent/subagent-spawn/tsconfig.json
  34. 2 2
      packages/subagent/subagent/src/index.ts
  35. 1 1
      packages/subagent/subagent/src/types.ts
  36. 10 0
      packages/support/llm-replay/src/index.ts
  37. 125 0
      pnpm-lock.yaml
  38. 4 1
      tsconfig.build.json
  39. 4 1
      tsconfig.json

+ 1 - 0
AGENTS.md

@@ -208,6 +208,7 @@ Dev/test/demo run **unbuilt** via tsx + the source `paths` map in the root `tsco
 - **Tests**: vitest, colocated under `packages/<group>/<pkg>/tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive.
 - **Prefer the REAL implementation over a mock/stand-in in tests.** When the genuine collaborator is available in the repo, wire it up instead of hand-rolling a fake — a test that registers an inline `defineTool({ name: 'bash', … })` to stand in for `dsh-tool-bash` proves the *bridge* moves bytes but not that the *shipping tool* renders the way the test asserts; the two drift and the test passes while the product is wrong. Mock only the genuinely expensive/non-deterministic boundary (the LLM adapter, the network, the clock) and keep everything downstream real: a bridge tool-call test runs the scripted mock MODEL but the REAL tool + REAL executor (e.g. `makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`), so it verifies the actual `presentCall`/`presentResult` an editor sees. This is the unit-test echo of "verify the world, not a synthetic stand-in" (see § Defensive patterns) — a fake you wrote will agree with whatever you assumed; the real thing won't.
 - **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
+- **Designing a new subsystem includes designing its test infrastructure — END TO END, up front, as part of the same plan.** When you introduce a new capability seam, a new agent-lifecycle shape, or anything that produces an observable transcript (a new tool family, a subagent transport, a new UI surface), the plan must name how it will be covered at EVERY tier it touches — unit, real-API e2e, AND the full-transcript snapshot tier — and, critically, must check that the existing test infrastructure can actually express that coverage. Do not assume a snapshot/e2e harness built for one shape (e.g. a single top-level ACP session) transparently supports a new shape (e.g. a parent agent driving nested child agents): verify it, and if it cannot, the harness extension is in-scope work to plan and schedule, not a detail to discover mid-implementation. This rule exists because a real plan under-scoped exactly this: the subagent backends were planned with unit + e2e coverage but the snapshot tier turned out to assume one session per process (`dsh-llm-replay`'s single positional cursor, single-file harvest), so nested-agent snapshot coverage became unplanned net-new infrastructure (`TODO(subagent-snapshots)`). The cost of finding that during design is a paragraph; the cost of finding it mid-build is a re-plan. When the harness gap is large enough to be its own reviewable unit, schedule it as a dedicated stacked follow-up with its own RFC — but SAY SO in the originating plan, with the gap named, rather than letting it surface as a surprise.
 
 ## Defensive patterns (hard-won)
 

+ 1 - 1
docs/architecture.md

@@ -116,7 +116,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told
 - `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). A non-owner's quiescence-observation hook: it lets a consumer await the current work settling **without** disposing the agent. It is NOT teardown — it does not stop queued work, unregister the agent, or detach the session; a lifecycle owner tears an agent down with `await AgentHandle.dispose()` (which stops the loop, awaits its exit, and unregisters).
 - `session`, `status`, `options`
 
-**TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred.
+**Subagents**: `spawn`/`fork` are realized by the [`@deepseek-ai/dsh-subagent`](../packages/subagent/subagent) seam (a named-provider registry on `ctx.subagents`), not a method on `Agent`. The in-process backends create the child via `ctx.agents.create` — fork seeds the child Session with a balanced completed-turn prefix of the parent's log (`CreateAgentOptions.seed`), spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Out-of-process transports (ACP, and later A2A / Codex app-server / Claude Code SDK) register as sibling providers. See [docs/core-data-structures/subagent.md](core-data-structures/subagent.md) and [the subagent RFC](rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). Inter-agent channels beyond delegation remain deferred.
 
 ### Loop lifecycle (session / turn / step)
 

+ 15 - 15
docs/cordis-catalog/events-and-services.md

@@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages.
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:136`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:137`](../../packages/core/agent/src/types.ts)
 
 #### `agent/disposed` — emit
 
@@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:142`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts)
 
 #### `agent/error` — emit
 
@@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts)
 
 #### `agent/queued` — emit
 
@@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts)
 
 #### `agent/request` — waterfall
 
@@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo
 
 Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts)
 
 #### `agent/status` — emit
 
@@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts)
 
 #### `agent/steering` — emit
 
@@ -97,7 +97,7 @@ Steering content was injected into a running turn.
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-end` — emit
 
@@ -109,7 +109,7 @@ A step ended.
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-result` — waterfall
 
@@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
 
 Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-start` — emit
 
@@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts)
 
 #### `agent/stream-chunk` — emit
 
@@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed).
 
 Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-continuation` — waterfall
 
@@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-end` — emit
 
@@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on
 
 Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md)
 
-Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-start` — emit
 
@@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session.
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts)
 
 ### `llm/*`
 
@@ -332,7 +332,7 @@ list(): Agent[]
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/index.ts:105`](../../packages/core/agent/src/index.ts)
+Source: [`packages/core/agent/src/index.ts:116`](../../packages/core/agent/src/index.ts)
 
 ### `ctx.bash` — `BashExecutor` (abstract seam)
 

+ 6 - 5
docs/core-data-structures/core.md

@@ -273,11 +273,12 @@ interface Agent {
    */
   whenIdle(): Promise<void>
 
-  // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred.
-  // The intended shape: a creation option referencing a parent agent
-  // (fork = seed the child Session with the parent's event log; spawn =
-  // fresh Session), with the child returned as an Agent handle so steer()
-  // and event subscription work uniformly. See docs/architecture.md.
+  // Subagent delegation is realized on top of this interface by the
+  // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates
+  // the child through `ctx.agents.create` (fork seeds the child Session with a
+  // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn
+  // starts fresh) and drives it as an ordinary Agent handle, so steer() and
+  // event subscription work uniformly. See docs/core-data-structures/subagent.md.
 }
 ```
 

+ 7 - 0
docs/core-data-structures/subagent.md

@@ -86,3 +86,10 @@ interface SubagentProvider {
 ```
 
 The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it.
+
+## In-process backends: depth and seed
+
+The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same context via `ctx.agents.create`. Two pieces of vocabulary ride on the existing agent/session types rather than new core types:
+
+- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth from `parent.options.subagentDepth` and the `depthLimit` capability caps the tree by refusing a child whose depth would exceed `request.maxDepth`.
+- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded).

+ 13 - 0
docs/module-graph.md

@@ -60,6 +60,10 @@ graph TD
   agent-core --> system-prompt
   agent-core --> tool-bash
   agent-core --> tools
+  subagent-inprocess --> agent
+  subagent-inprocess --> llm
+  subagent-inprocess --> session
+  subagent-inprocess --> subagent
   subagent-mock --> agent
   subagent-mock --> llm
   subagent-mock --> subagent
@@ -75,6 +79,12 @@ graph TD
   stdio-agent --> session
   stdio-agent --> session-persistence-jsonl
   stdio-agent --> ui-stdio
+  subagent-fork --> agent
+  subagent-fork --> session
+  subagent-fork --> subagent
+  subagent-fork --> subagent-inprocess
+  subagent-spawn --> subagent
+  subagent-spawn --> subagent-inprocess
 ```
 
 | Package | Depends on |
@@ -100,7 +110,10 @@ graph TD
 | `subagent` | `agent`, `llm`, `tools` |
 | `tool-bash` | `agent`, `bash`, `llm`, `tools` |
 | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` |
+| `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` |
 | `subagent-mock` | `agent`, `llm`, `subagent` |
 | `tool-subagent` | `agent`, `llm`, `subagent`, `tools` |
 | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` |
 | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` |
+| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-inprocess` |
+| `subagent-spawn` | `subagent`, `subagent-inprocess` |

+ 2 - 1
docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md

@@ -6,7 +6,7 @@ Status: proposed
 
 ## Problem
 
-The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent is sketched in two `TODO(sub-agents)` markers ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. No service, vocabulary, or implementation exists yet.
+The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent was sketched in the `Agent`/`AgentLoop` interfaces ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. This RFC realizes that seam (see the implementation-status banner above for what has landed); the design below is the proposal it was argued from, when no service, vocabulary, or implementation yet existed.
 
 The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee:
 
@@ -70,3 +70,4 @@ The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's fin
 - **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own).
 - **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign.
 - **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process.
+- **Snapshot coverage of nested agents.** The snapshot tier (`pnpm run test:snapshot`) replays a recorded session through `dsh-llm-replay`, whose dispatch is a single GLOBAL positional cursor (the Nth `llm/stream` call serves the Nth recorded entry) and whose harness harvests a single session log file. A subagent runs as a *second* agent with its own session log, so a parent→child scenario needs per-session-keyed replay (or a call-ordered merge of both logs, sound because subagent execution is strictly nested/non-concurrent — the parent blocks on the child) plus harvest-all-logs and plural-session-id plumbing in the harness. This is self-contained infrastructure orthogonal to the backends, so it lands as a **dedicated stacked follow-up** rather than in the in-process-backends PR. Until it lands, in-process subagents are covered by real-loop unit tests (a parent driving a fork AND a spawn child) and a with-key e2e (a parent delegating to a child that writes a file), not by the snapshot transcript tier. Tracked by `TODO(subagent-snapshots)`.

+ 1 - 1
docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md

@@ -46,7 +46,7 @@ The genuine risks of collapsing the two ids into one (the case AGAINST this prop
 
 - **It forecloses a one-agent-resumes-many-sessions / one-session-driven-by-many-agents future.** Today the separate ids leave room for an agent (a stable actor) to detach from one session and attach to another, or for a handoff where a new agent process adopts an existing session under a new actor handle. Unifying makes "agent" and "session" the same lifetime, so any such future needs a NEW seam (e.g. an explicit `actorId` distinct from the session) — re-introducing the very separation we removed. We judge this generality currently unused, but it is a door this change closes.
 
-- **Sub-agents / fork / spawn (an explicitly deferred seam) may WANT a stable actor id across forked sessions.** `AgentLoop.create`'s `TODO(sub-agents)` envisions a child agent seeded from a parent's event log. If the design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id.
+- **Subagents / fork / spawn may WANT a stable actor id across forked sessions.** The [subagent seam](../feature/2026-06-21-subagent-capability-seam.md) runs a child agent seeded from a parent's event log (fork). If a future design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id. (As shipped, each subagent child mints its own distinct agent id — `parentSession` records lineage — so the seam does not currently rely on a shared actor id, but unifying would foreclose adding one.)
 
 - **The config-driven resume-or-create policy becomes load-bearing, not cosmetic.** Today the per-run-uuid session id quietly sidesteps the "a fixed id collides with its own on-disk log on the second run" problem. Once the id is unified and stable, a config agent restarting MUST decide resume-vs-fresh deliberately — there is no longer a throwaway session id to hide behind. Getting this wrong reintroduces the create-collision the uuid was avoiding (a durable backend refuses to re-create an id whose log exists). This is the one real design decision the implementing PR owns, and it is easy to get subtly wrong.
 

+ 45 - 6
examples/coding-agent/cordis.yml

@@ -44,16 +44,55 @@
     # under ./.sessions); unset starts a fresh session each run.
     resumeSessionId: !!js process.env.RESUME_SESSION_ID
     persistenceRoot: './.sessions'
-    welcome: 'coding-agent ready. Give it a coding task (bash is its only tool).'
+    welcome: 'coding-agent ready. Give it a coding task (its tools are bash and subagent).'
     systemPrompt: |
       You are coding-agent, a CLI coding assistant.
 
-      Your only tools are bash (plus bash_output/bash_kill for background
-      tasks). Do ALL file operations through bash: read with cat/sed/head,
-      search with grep, write with heredocs (cat <<'EOF' > file), edit
-      with sed or a rewrite. Each bash call runs in a fresh shell — pass
-      workdir instead of cd, and never rely on shell state between calls.
+      Your tools are bash (plus bash_output/bash_kill for background
+      tasks) and subagent. Do ALL file operations through bash: read with
+      cat/sed/head, search with grep, write with heredocs (cat <<'EOF' >
+      file), edit with sed or a rewrite. Each bash call runs in a fresh
+      shell — pass workdir instead of cd, and never rely on shell state
+      between calls.
+
+      Use the subagent tool to delegate a focused, self-contained subtask
+      to a fresh child agent (it works in its own context and returns only
+      its final result) — give it a complete, standalone instruction. Use
+      subagent_fork instead when the subtask needs THIS conversation's
+      context: the child inherits the log so far.
 
       Check the [exit code: N] marker on every command; investigate
       failures before moving on. Verify your work by running the code or
       tests. Keep answers brief and factual.
+
+# The subagent seam + BOTH in-process backends + two model-facing tools, as leaf
+# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh
+# child) and fork (a child seeded with the parent's completed-turn prefix) are
+# independent backends over the shared dsh-subagent-inprocess driver. Exposing
+# both transports is pure config: load each backend, then load dsh-tool-subagent
+# once per backend with a distinct toolName (the tool registry rejects a
+# duplicate name) — no code change.
+- id: subagent
+  name: '@deepseek-ai/dsh-subagent'
+
+- id: subagent-spawn
+  name: '@deepseek-ai/dsh-subagent-spawn'
+  config:
+    providerName: spawn
+
+- id: subagent-fork
+  name: '@deepseek-ai/dsh-subagent-fork'
+  config:
+    providerName: fork
+
+- id: tool-subagent
+  name: '@deepseek-ai/dsh-tool-subagent'
+  config:
+    provider: spawn
+    toolName: subagent
+
+- id: tool-subagent-fork
+  name: '@deepseek-ai/dsh-tool-subagent'
+  config:
+    provider: fork
+    toolName: subagent_fork

+ 4 - 0
knip.json

@@ -36,6 +36,10 @@
     "packages/ui/stdio-agent": {
       "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
       "project": ["src/**/*.ts", "tests/**/*.ts"]
+    },
+    "packages/subagent/subagent-spawn": {
+      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
+      "project": ["src/**/*.ts", "tests/**/*.ts"]
     }
   }
 }

+ 4 - 0
packages/README.md

@@ -40,6 +40,8 @@ dsh-ui-stdio      ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin)
 dsh-llm-replay    ← dsh-llm, dsh-session            (record/replay adapter for keyless snapshot tests)
 dsh-subagent      ← dsh-agent, dsh-llm, dsh-tools    (abstract subagent provider-registry seam)
 dsh-subagent-mock ← dsh-subagent                     (scripted provider for tests)
+dsh-subagent-spawn ← dsh-subagent, dsh-agent, dsh-session, dsh-llm  (in-process fresh child + shared run driver)
+dsh-subagent-fork ← dsh-subagent-spawn, dsh-agent, dsh-session      (in-process child seeded from parent log)
 dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent (model-facing delegation tool)
 dsh-agent-core    ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop  (the providerless spine, as one bundle plugin)
 dsh-stdio-agent   ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session  (stdio chat APP + bin)
@@ -74,6 +76,8 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
 | `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) |
 | `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
 | `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` |
+| `subagent-spawn/` | `subagent` | In-process backend: a fresh child agent (+ the shared in-process run driver) | (registers on `ctx.subagents`) |
+| `subagent-fork/` | `subagent` | In-process backend: a child agent seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
 | `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) |
 | `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
 | `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) |

+ 10 - 8
packages/core/agent-loop/src/index.ts

@@ -121,10 +121,6 @@ export class AgentLoop extends Service implements AgentFactory {
    * deliberate resume-or-create policy (resume the prior session if one exists,
    * else start fresh) or an explicit caller-chosen session id — revisit when the
    * UI/ACP path owns session selection.
-   *
-   * TODO(sub-agents): spawn/fork land here — accept a parent agent reference;
-   * fork seeds the new Session with the parent's event log, spawn starts
-   * fresh; the child is returned as a regular Agent handle.
    */
   create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent {
     this.assertAgentIdFree(id)
@@ -140,16 +136,22 @@ export class AgentLoop extends Service implements AgentFactory {
   /**
    * Programmatic factory create ({@link AgentFactory}): an agent on a
    * caller-supplied `sessionId` (NOT `${id}-session`), with optional session
-   * metadata (validated `cwd`, lineage). The ACP bridge uses this so the
-   * client-generated session id becomes the live/persisted session id. Returns
-   * an {@link AgentHandle} the owner disposes to tear down exactly this agent.
+   * metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The
+   * ACP bridge uses this so the client-generated session id becomes the
+   * live/persisted session id; the in-process FORK subagent backend passes a
+   * `seed` (a balanced completed-turn prefix of the parent's log) so the child
+   * starts with the parent's context. Returns an {@link AgentHandle} the owner
+   * disposes to tear down exactly this agent.
    */
   createAgent(options: CreateAgentOptions): AgentHandle {
     // Check the agent id BEFORE preparing the session: register() would reject a
     // duplicate id only AFTER the session enters the store, leaving an orphaned
     // live session (and lazy persistence state) that blocks reuse of that id.
     this.assertAgentIdFree(options.agentId)
-    const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} })
+    const session = this.ctx.sessions.prepare(options.sessionId, {
+      ...options.seed !== undefined ? { seed: options.seed } : {},
+      meta: options.meta ?? {},
+    })
     return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
   }
 

+ 12 - 1
packages/core/agent/src/index.ts

@@ -6,7 +6,7 @@
  */
 
 import { Context, Service } from 'cordis'
-import type { SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import type { Agent, AgentId, AgentOptions } from './types.ts'
 
 export * from './types.ts'
@@ -37,6 +37,17 @@ export interface CreateAgentOptions {
    * excluded — a factory caller never sets it).
    */
   meta?: { cwd?: string; parentSession?: SessionId }
+  /**
+   * Seed events to reconstruct the child session's log from (the fork lineage
+   * primitive). When present, the factory creates the session with this event
+   * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the
+   * in-process FORK subagent backend to seed a child with a balanced
+   * completed-turn prefix of the parent's log. The prefix MUST be contiguous
+   * from seq 0 and balanced (no open turn/step, no dangling tool-call), or the
+   * session constructor (and the dev-mode invariants replay) reject it. Absent
+   * for a fresh (spawn) child.
+   */
+  seed?: SessionEvent[]
   /** Per-agent options (model, system prompt). */
   agentOptions?: AgentOptions
 }

+ 6 - 5
packages/core/agent/src/types.ts

@@ -118,11 +118,12 @@ export interface Agent {
    */
   whenIdle(): Promise<void>
 
-  // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred.
-  // The intended shape: a creation option referencing a parent agent
-  // (fork = seed the child Session with the parent's event log; spawn =
-  // fresh Session), with the child returned as an Agent handle so steer()
-  // and event subscription work uniformly. See docs/architecture.md.
+  // Subagent delegation is realized on top of this interface by the
+  // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates
+  // the child through `ctx.agents.create` (fork seeds the child Session with a
+  // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn
+  // starts fresh) and drives it as an ordinary Agent handle, so steer() and
+  // event subscription work uniformly. See docs/core-data-structures/subagent.md.
 }
 
 declare module 'cordis' {

+ 4 - 1
packages/subagent/README.md

@@ -5,8 +5,11 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
 | Package | Role | ctx key |
 |---|---|---|
 | `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` |
+| `subagent-inprocess/` | Shared in-process run driver (pure lib; registers nothing) | — |
+| `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) |
+| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
 | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
 
-The interface lives at `subagent/subagent/`. Provider implementations live in their own packages — the in-process `dsh-subagent-spawn` / `dsh-subagent-fork` and the out-of-process `dsh-subagent-acp` — plus the test-only `dsh-subagent-mock` in [support](../support/README.md). All **product** packages except the mock.
+The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a pure library — both depend on it, neither on the other) and ship here; the out-of-process `dsh-subagent-acp` and the test-only `dsh-subagent-mock` (in [support](../support/README.md)) are separate. All **product** packages except the mock.
 
 The proposal and design rationale: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md).

+ 23 - 0
packages/subagent/subagent-fork/README.md

@@ -0,0 +1,23 @@
+# @deepseek-ai/dsh-subagent-fork
+
+The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed.
+
+## The seed boundary (the crux)
+
+At the moment a subagent tool's `execute` runs, the parent's CURRENT turn is open and unbalanced: the log holds the `assistant/message` carrying this spawn's tool-call and the dangling `tool/call` with no `tool/result` yet. Seeding that raw prefix would give the child an open turn that the session constructor and the dev-mode [invariants](../../support/invariants) replay **reject**.
+
+So the fork seeds only the **balanced completed-turn prefix** — the parent's log up to and including its last `turn/end`, excluding the in-flight turn entirely (`completedTurnPrefix`). Because the live log keeps `seq === index`, the slice is contiguous-from-0 and a valid seed. A parent on its very first (not-yet-complete) turn forks an *empty* seed — i.e. effectively a fresh child.
+
+The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`), the same primitive `resume` uses.
+
+## Capabilities
+
+`{ outputSchema: false, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/output behavior is the shared driver's).
+
+## Config
+
+| Key | Meaning |
+|---|---|
+| `providerName` | Registry name on `ctx.subagents` (default `fork`). |
+
+See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared.

+ 48 - 0
packages/subagent/subagent-fork/package.json

@@ -0,0 +1,48 @@
+{
+  "name": "@deepseek-ai/dsh-subagent-fork",
+  "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log",
+  "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-agent": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-subagent": "^0.0.1",
+    "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@deepseek-ai/dsh-subagent-inprocess": "workspace:^",
+    "@deepseek-ai/dsh-subagent-spawn": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@cordisjs/plugin-loader": "^1.0.0-rc.4",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 81 - 0
packages/subagent/subagent-fork/src/index.ts

@@ -0,0 +1,81 @@
+/**
+ * The in-process FORK subagent backend: registers a {@link SubagentProvider} on
+ * `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a
+ * prefix of the parent's session log — so the child inherits the parent's
+ * conversation context instead of starting fresh. The run mechanics live in
+ * `@deepseek-ai/dsh-subagent-inprocess` ({@link startInProcessRun}); this
+ * backend just computes the seed. The spawn backend is an independent peer over
+ * the same driver.
+ *
+ * The seed boundary is the crux: at the moment a subagent tool's `execute`
+ * runs, the parent's CURRENT turn is open and unbalanced (it holds the
+ * `assistant/message` with this spawn's tool-call, plus the dangling `tool/call`
+ * with no `tool/result`). Seeding that raw prefix gives the child an open turn
+ * the session constructor and the dev-mode invariants replay REJECT. So the
+ * fork seeds only the **balanced completed-turn prefix**: the parent's log up
+ * to and including its last `turn/end`, excluding the in-flight turn entirely.
+ *
+ * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
+ *
+ * @module @deepseek-ai/dsh-subagent-fork
+ */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
+
+export const name = 'subagent-fork'
+export const inject = ['subagents', 'agents']
+
+/** Config: the registry name to register the provider under. */
+export interface Config {
+  /** Provider name on `ctx.subagents` (default `fork`). */
+  providerName: string
+}
+
+export const Config: z<Config> = z.object({
+  providerName: z.string().default('fork'),
+})
+
+/**
+ * The balanced completed-turn prefix of `parent`'s log: every event up to and
+ * including the last `turn/end`. Empty if the parent has never completed a turn
+ * (the in-flight turn is excluded, so a parent on its very first turn forks an
+ * empty — i.e. fresh — child). The result is contiguous from seq 0 (the live
+ * log keeps `seq === index`), so it is a valid session seed; the in-flight,
+ * unbalanced turn is dropped so the invariants replay accepts it.
+ */
+export function completedTurnPrefix(parent: Agent): SessionEvent[] {
+  const events = parent.session.events
+  const lastEnd = events.findLast(e => e.type === 'turn/end')
+  if (lastEnd === undefined) return []
+  // seq === array index (the append contract), so slice up to and including it.
+  return events.slice(0, lastEnd.seq + 1)
+}
+
+/**
+ * The fork provider. Supports `depthLimit`; NOT `outputSchema`/`toolFilter` this
+ * cut (the service rejects a request needing either before `start` runs).
+ */
+class ForkProvider implements SubagentProvider {
+  readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
+
+  constructor(readonly name: string, private readonly ctx: Context) {}
+
+  start(request: SubagentStartRequest) {
+    const seed = completedTurnPrefix(request.parent)
+    return startInProcessRun(this.ctx, request, {
+      providerName: this.name,
+      // Only pass a seed when there's a completed turn to inherit; an empty seed
+      // is equivalent to a fresh child, so omit it to keep the session unseeded.
+      ...seed.length > 0 ? { seed } : {},
+    })
+  }
+}
+
+export function apply(ctx: Context, config: Config): void {
+  ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx))
+}

+ 99 - 0
packages/subagent/subagent-fork/tests/multi-subagent.spec.ts

@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import * as Invariants from '@deepseek-ai/dsh-invariants'
+import SubagentService from '@deepseek-ai/dsh-subagent'
+import * as Spawn from '@deepseek-ai/dsh-subagent-spawn'
+import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import * as fork from '../src/index.ts'
+
+type Script = ConstructorParameters<typeof MockAdapter>[0]
+
+/**
+ * The two in-process backends coexist on one context: the SAME parent agent
+ * delegates to a `spawn` child (fresh) and a `fork` child (seeded with its log),
+ * and keeps working itself. This is the multi-provider coexistence the seam
+ * exists for — the named registry lets one runtime hold both transports.
+ */
+async function setup(script: Script) {
+  const ctx = new Context()
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(Invariants)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(SubagentService)
+  await ctx.plugin(Spawn, { providerName: 'spawn' })
+  await ctx.plugin(fork, { providerName: 'fork' })
+  ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
+  const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
+  return { ctx, parent }
+}
+
+function text(blocks: { type: string; text?: string }[]): string {
+  return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
+}
+
+describe('multi-subagent coexistence (spawn + fork on one context)', () => {
+  it('both providers register and coexist', async () => {
+    const { ctx } = await setup([])
+    expect(ctx.subagents.list().sort()).toEqual(['fork', 'spawn'])
+  })
+
+  it('the same parent drives a spawn child AND a fork child, then keeps working', async () => {
+    // Script order: parent turn 1, spawn child, fork child, parent turn 2.
+    const { ctx, parent } = await setup([
+      textResponse('parent turn one'),
+      textResponse('spawn child reply'),
+      textResponse('fork child reply'),
+      textResponse('parent turn two'),
+    ])
+
+    // Parent does one real turn first, so the fork has a completed turn to seed.
+    parent.send([{ type: 'text', text: 'parent q1' }])
+    await parent.whenIdle()
+    const parentPrefixLen = parent.session.events.length
+
+    // Delegate to a fresh spawn child.
+    const spawnRun = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent })
+    const spawnResult = await spawnRun.result
+    expect(spawnResult.stopReason).toBe('completed')
+    expect(text(spawnResult.output)).toBe('spawn child reply')
+
+    // Delegate to a fork child (seeded with the parent's turn-1 prefix).
+    const forkRun = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'fork task' }], parent })
+    const forkResult = await forkRun.result
+    expect(forkResult.stopReason).toBe('completed')
+    expect(text(forkResult.output)).toBe('fork child reply')
+
+    // The two children are distinct sessions, both lineage-stamped to the parent.
+    const spawnChild = ctx.agents.get(spawnRun.id)!
+    const forkChild = ctx.agents.get(forkRun.id)!
+    expect(spawnChild.session.header.id).not.toBe(forkChild.session.header.id)
+    expect(spawnChild.session.header.parentSession).toBe(parent.session.header.id)
+    expect(forkChild.session.header.parentSession).toBe(parent.session.header.id)
+    // The fork child inherited the parent's prefix; the spawn child did not.
+    expect(forkChild.session.events.slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true)
+
+    await spawnRun.dispose()
+    await forkRun.dispose()
+
+    // The parent is unaffected and keeps working after both delegations.
+    parent.send([{ type: 'text', text: 'parent q2' }])
+    await parent.whenIdle()
+    const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message')
+    expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two')
+    // The parent's OWN log never recorded the children's internal steps — its
+    // only subagent-related entries would be tool/call+tool/result IF it had
+    // used the tool, but here we called the service directly, so the parent log
+    // is purely its own two turns.
+    expect(parent.session.events.filter(e => e.type === 'turn/end')).toHaveLength(2)
+  })
+})

+ 186 - 0
packages/subagent/subagent-fork/tests/subagent-fork.spec.ts

@@ -0,0 +1,186 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import * as Invariants from '@deepseek-ai/dsh-invariants'
+import SubagentService from '@deepseek-ai/dsh-subagent'
+import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import * as fork from '../src/index.ts'
+import { completedTurnPrefix } from '../src/index.ts'
+
+type Script = ConstructorParameters<typeof MockAdapter>[0]
+
+/** A bare `stop` finish that streams no content → the turn ends `completed`
+ * with NO `assistant/message` of its own. */
+const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }]
+
+/**
+ * Drives the REAL fork backend with a real loop + scripted mock MODEL + the
+ * real dsh-invariants plugin. The invariants plugin re-replays a seeded child
+ * log on `session/created` (its freeze-check), so a malformed (unbalanced) fork
+ * seed makes these tests THROW — that is the regression guard for the
+ * completed-turn-prefix boundary.
+ */
+async function setup(script: Script) {
+  const ctx = new Context()
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(Invariants)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(SubagentService)
+  await ctx.plugin(fork, { providerName: 'fork' })
+  ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
+  const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
+  return { ctx, parent }
+}
+
+function text(blocks: { type: string; text?: string }[]): string {
+  return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
+}
+
+describe('completedTurnPrefix', () => {
+  it('returns an empty prefix for a parent that has never completed a turn', async () => {
+    const { parent } = await setup([])
+    expect(completedTurnPrefix(parent)).toEqual([])
+  })
+
+  it('returns the balanced prefix up to and including the last turn/end', async () => {
+    const { parent } = await setup([textResponse('first'), textResponse('second')])
+    parent.send([{ type: 'text', text: 'q1' }])
+    await parent.whenIdle()
+    parent.send([{ type: 'text', text: 'q2' }])
+    await parent.whenIdle()
+
+    const prefix = completedTurnPrefix(parent)
+    // Ends exactly at the last turn/end; seq is contiguous from 0.
+    expect(prefix.at(-1)?.type).toBe('turn/end')
+    expect(prefix.map(e => e.seq)).toEqual(prefix.map((_, i) => i))
+    // Both completed turns are present.
+    expect(prefix.filter(e => e.type === 'turn/end')).toHaveLength(2)
+  })
+})
+
+describe('dsh-subagent-fork', () => {
+  it('forks an UNSEEDED (fresh) child when the parent has no completed turn', async () => {
+    // The parent has never completed a turn → empty prefix → the provider omits
+    // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm.
+    const { ctx, parent } = await setup([textResponse('fresh child')])
+    expect(completedTurnPrefix(parent)).toEqual([])
+    const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(text(result.output)).toBe('fresh child')
+    const child = ctx.agents.get(run.id)!
+    // Only the child's own turn — no seeded parent turns.
+    expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1)
+    await run.dispose()
+  })
+
+  it('seeds the child with the parent\'s completed-turn prefix (child inherits context)', async () => {
+    // Parent runs one turn, then we fork. The child's seeded log should contain
+    // the parent's first turn, and the child should run its own new turn on top.
+    const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
+    parent.send([{ type: 'text', text: 'parent question' }])
+    await parent.whenIdle()
+    const parentPrefixLen = parent.session.events.length
+
+    const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(text(result.output)).toBe('child answer')
+
+    const child = ctx.agents.get(run.id)!
+    // The child's log STARTS with the parent's prefix (seeded), then its own turn.
+    expect(child.session.events.length).toBeGreaterThan(parentPrefixLen)
+    // The seeded prefix carried the parent's user message.
+    const seededUser = child.session.events.slice(0, parentPrefixLen).find(e => e.type === 'user/message')
+    expect(seededUser).toBeDefined()
+    // Lineage stamped.
+    expect(child.session.header.parentSession).toBe(parent.session.header.id)
+    await run.dispose()
+  })
+
+  it('produces an invariant-CLEAN seed: forking mid-turn excludes the open turn', async () => {
+    // Drive the parent so it has ONE completed turn, then start a SECOND turn
+    // that is still open (a hanging model call), and fork while it's in flight.
+    // The fork must seed only the completed first turn — an unbalanced seed
+    // would make the invariants replay throw inside ctx.subagents.start.
+    const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
+    parent.send([{ type: 'text', text: 'q1' }])
+    await parent.whenIdle()
+    // Start a second turn that hangs (open turn/start + open step, never ends).
+    parent.send([{ type: 'text', text: 'q2' }])
+    await new Promise(r => setTimeout(r, 20)) // let the hanging turn open
+
+    // Forking now must NOT throw (the open second turn is excluded from the seed).
+    const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(text(result.output)).toBe('child')
+
+    const child = ctx.agents.get(run.id)!
+    // The child's seed has exactly the ONE completed parent turn (the open one excluded).
+    const seedTurnEnds = child.session.events.filter(e => e.type === 'turn/end')
+    // 1 from the seeded parent turn + 1 from the child's own completed turn.
+    expect(seedTurnEnds.length).toBe(2)
+
+    parent.cancel()
+    await run.dispose()
+  })
+
+  it('does NOT return the seeded parent output when the child produces no message of its own', async () => {
+    // Regression: readResult must scope to the child's OWN events (after the
+    // seed). The parent completes a turn with a distinctive assistant message,
+    // then the fork child's own turn finishes with a bare `stop` and NO
+    // assistant/message. Scanning the whole (seeded) log would return the
+    // parent's "parent stale" message with stopReason 'completed'; scoped to the
+    // child's own events the output is empty.
+    const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
+    parent.send([{ type: 'text', text: 'parent question' }])
+    await parent.whenIdle()
+
+    const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
+    const result = await run.result
+    // The child completed its own (empty) turn — completed, but with NO output
+    // borrowed from the seeded parent prefix.
+    expect(result.stopReason).toBe('completed')
+    expect(result.output).toEqual([])
+    await run.dispose()
+  })
+
+  it('advertises depthLimit but not outputSchema/toolFilter', async () => {
+    const { ctx } = await setup([])
+    expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false })
+  })
+
+  it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SubagentService)
+    await ctx.plugin(AgentRegistry)
+    const fiber = await ctx.plugin(fork, { providerName: 'fork' })
+    expect(ctx.subagents.list()).toEqual(['fork'])
+    await fiber.dispose()
+    expect(ctx.subagents.list()).toEqual([])
+  })
+
+  it('has the namespace-plugin export shape (no stray default)', () => {
+    expect('default' in fork).toBe(false)
+    expect(fork.name).toBe('subagent-fork')
+    expect(fork.inject).toEqual(['subagents', 'agents'])
+    const loader = Object.create(Loader.prototype) as Loader
+    const unwrapped = loader.unwrapExports(fork) as Record<string, unknown>
+    expect(unwrapped).toBe(fork)
+    expect(unwrapped.name).toBe('subagent-fork')
+    expect(unwrapped.inject).toEqual(['subagents', 'agents'])
+    expect(typeof unwrapped.apply).toBe('function')
+  })
+})

+ 33 - 0
packages/subagent/subagent-fork/tsconfig.json

@@ -0,0 +1,33 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../subagent"
+    },
+    {
+      "path": "../subagent-inprocess"
+    }
+  ]
+}

+ 28 - 0
packages/subagent/subagent-inprocess/README.md

@@ -0,0 +1,28 @@
+# @deepseek-ai/dsh-subagent-inprocess
+
+The shared **in-process subagent run driver**. A pure library (no provider, no registration) that the in-process backends — [spawn](../subagent-spawn/README.md) (a fresh child) and [fork](../subagent-fork/README.md) (a child seeded with a prefix of the parent's log) — both build on. The backends are thin shells that differ ONLY in the session seed they pass; everything downstream lives here, so neither backend depends on the other.
+
+## What it exports
+
+### `startInProcessRun(ctx, request, options): SubagentRun`
+
+Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
+
+1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability);
+2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited);
+3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts);
+4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`.
+
+`dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. A cancel landing before any `turn/end` (the pre-turn window) still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`.
+
+### `InProcessRunOptions`
+
+`{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed.
+
+### `depthOf(agent): number`
+
+Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` reads it (absent ⇒ 0).
+
+### `SubagentDepthError`
+
+Thrown by `startInProcessRun` when a spawn would exceed the request's `maxDepth` cap; carries `attemptedDepth` and `maxDepth`.

+ 42 - 0
packages/subagent/subagent-inprocess/package.json

@@ -0,0 +1,42 @@
+{
+  "name": "@deepseek-ai/dsh-subagent-inprocess",
+  "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)",
+  "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-agent": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-subagent": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 192 - 0
packages/subagent/subagent-inprocess/src/index.ts

@@ -0,0 +1,192 @@
+/**
+ * The shared in-process subagent run driver: run a child as a child
+ * {@link Agent} on the SAME cordis context (`ctx.agents`) — the cheapest
+ * transport, reusing the agent factory's quiescent {@link AgentHandle}
+ * teardown. The concrete in-process backends are thin shells over this driver,
+ * differing ONLY in the `seed` they pass (a fresh child vs. a child seeded with
+ * a prefix of the parent's log); everything downstream — drive the child, read
+ * its final output, map the stop reason, dispose — is identical and lives here.
+ *
+ * This package owns no provider and registers nothing; it is a pure library the
+ * backend packages depend on, so neither backend needs to know about the other.
+ *
+ * @module @deepseek-ai/dsh-subagent-inprocess
+ */
+
+import { randomUUID } from 'node:crypto'
+import type { Context } from 'cordis'
+import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
+import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
+
+declare module '@deepseek-ai/dsh-agent' {
+  interface AgentOptions {
+    /**
+     * The agent's delegation depth in the subagent tree — 0 for a top-level
+     * (config/ACP-created) agent, parent depth + 1 for a subagent. Set by the
+     * in-process backends on every child they create so a nested spawn reads its
+     * parent's depth from `parent.options.subagentDepth` and the `depthLimit`
+     * capability can cap the tree. Merge-extensible field (the seam owns it; the
+     * loop neither sets nor reads it).
+     */
+    subagentDepth?: number
+  }
+}
+
+/** Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0). */
+export function depthOf(agent: Agent): number {
+  return agent.options.subagentDepth ?? 0
+}
+
+/** Thrown when a spawn would exceed the request's `maxDepth` cap. */
+export class SubagentDepthError extends Error {
+  constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
+    super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`)
+    this.name = 'SubagentDepthError'
+  }
+}
+
+/** Map a session `turn/end` reason to a {@link SubagentStopReason}. */
+function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
+  switch (reason?.kind) {
+    case 'completed':
+      return 'completed'
+    case 'max-tokens':
+      return 'max-tokens'
+    case 'aborted':
+      return 'aborted'
+    // `disposed` (torn down mid-turn) and `interrupted` (crash-closed) both mean
+    // the turn did not finish cleanly; surface them as a generic failure rather
+    // than a clean completion. A missing reason (no turn ran) is also an error.
+    case 'error':
+    case 'disposed':
+    case 'interrupted':
+    default:
+      return 'error'
+  }
+}
+
+/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */
+export interface InProcessRunOptions {
+  /** The provider name (`spawn`/`fork`), for error context only. */
+  readonly providerName: string
+  /**
+   * The child session's seed: a balanced, contiguous-from-0 prefix of the
+   * parent's log (FORK), or `undefined` for a fresh child (SPAWN).
+   */
+  readonly seed?: SessionEvent[]
+}
+
+/**
+ * Start an in-process child agent for `request` and return a {@link SubagentRun}.
+ *
+ * Drives the child as a one-shot: `send(prompt)` then `whenIdle()` (the ordering
+ * matters — `send` enqueues synchronously, so `whenIdle` observes the queued
+ * work and resolves only on the child's `running → idle` transition, never
+ * before the turn starts). The final `assistant/message` is the result output,
+ * the matching `turn/end.reason` the stop reason. `dispose()` delegates to the
+ * factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove
+ * session); `cancel()` cancels the child's in-flight turn.
+ */
+export function startInProcessRun(
+  ctx: Context,
+  request: SubagentStartRequest,
+  options: InProcessRunOptions,
+): SubagentRun {
+  const childDepth = depthOf(request.parent) + 1
+  if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
+    throw new SubagentDepthError(childDepth, request.maxDepth)
+  }
+
+  const childId = AgentId(randomUUID())
+  // The child's OWN events begin after the seed (fork seeds the parent's
+  // completed-turn prefix; spawn seeds nothing). `readResult` scopes to this
+  // boundary so a child that produces no message of its own never returns the
+  // SEEDED parent's last assistant message as its result.
+  const seedLength = options.seed?.length ?? 0
+  const parentHeader = request.parent.session.header
+  // Inherit the parent's model by default (a child with no model cannot run);
+  // an explicit `request.agentOptions.model` overrides it. The parent's
+  // systemPrompt is NOT inherited — a fresh child is a clean specialist unless
+  // the caller supplies one.
+  const agentOptions: AgentOptions = {
+    ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
+    ...request.agentOptions,
+    subagentDepth: childDepth,
+  }
+
+  const handle: AgentHandle = ctx.agents.create({
+    agentId: childId,
+    sessionId: SessionId(randomUUID()),
+    meta: {
+      ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
+      parentSession: parentHeader.id,
+    },
+    ...options.seed !== undefined ? { seed: options.seed } : {},
+    agentOptions,
+  })
+  const child = handle.agent
+
+  // Bridge the request's abort signal to the child (the consumer also bridges
+  // its own exec.signal, but a backend-level bridge keeps the contract local).
+  // `cancelled` records that a cancel was requested at all, so the pre-turn
+  // cancel window — where the child clears the queued prompt before any
+  // `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
+  // rather than falling through to the no-turn `error` mapping.
+  let cancelled = false
+  const requestCancel = (reason: string): void => {
+    cancelled = true
+    child.cancel(reason)
+  }
+  const onAbort = (): void => { requestCancel('subagent cancelled') }
+  request.signal?.addEventListener('abort', onAbort, { once: true })
+
+  const result: Promise<SubagentResult> = (async () => {
+    try {
+      // A signal already aborted BEFORE the run starts never fires an `abort`
+      // event (`addEventListener` only fires on the transition), so the listener
+      // above won't catch it — settle `aborted` without running the child rather
+      // than completing an already-cancelled request.
+      if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
+      child.send(request.prompt)
+      await child.whenIdle()
+      return readResult(child, seedLength, cancelled)
+    } finally {
+      request.signal?.removeEventListener('abort', onAbort)
+    }
+  })()
+
+  return {
+    id: childId,
+    result,
+    cancel(reason?: string): void {
+      requestCancel(reason ?? 'subagent cancelled')
+    },
+    async dispose(): Promise<void> {
+      request.signal?.removeEventListener('abort', onAbort)
+      await handle.dispose()
+    },
+  }
+}
+
+/**
+ * Read a settled child's terminal result from its session log, scoped to the
+ * child's OWN events (everything at or after `seedLength` — fork seeds the
+ * parent's completed-turn prefix, so a child that produced no message of its
+ * own must NOT return the seeded parent's last assistant message). The output
+ * is the child's last `assistant/message` content (deep-cloned — the log is
+ * frozen); the stop reason is the child's last `turn/end` reason mapped to a
+ * {@link SubagentStopReason}. When `cancelled` is set but no `turn/end` was
+ * logged (a cancel landed in the pre-turn window, before any turn ran), the
+ * run settles `aborted` per the {@link SubagentRun.cancel} contract rather than
+ * the generic no-turn `error`.
+ */
+function readResult(child: Agent, seedLength: number, cancelled: boolean): SubagentResult {
+  const own = child.session.events.slice(seedLength)
+  const lastMessage = own.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message')
+  const lastEnd = own.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end')
+  const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : []
+  if (lastEnd === undefined && cancelled) return { output, stopReason: 'aborted' }
+  return { output, stopReason: toStopReason(lastEnd?.data.reason) }
+}

+ 85 - 0
packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts

@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import * as Invariants from '@deepseek-ai/dsh-invariants'
+import SubagentService from '@deepseek-ai/dsh-subagent'
+import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts'
+
+type Script = ConstructorParameters<typeof MockAdapter>[0]
+
+/**
+ * Drives the shared in-process run driver DIRECTLY (no provider package), so the
+ * driver's own contract — depth read/cap, the one-shot drive, the result read —
+ * is covered independently of which backend (spawn/fork) calls it. The only
+ * mocked boundary is the model; the real agent loop, SubagentService, and
+ * dsh-invariants are mounted, so a malformed child session log fails the test.
+ */
+async function setup(script: Script) {
+  const ctx = new Context()
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(Invariants)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(SubagentService)
+  ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
+  const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
+  return { ctx, parent }
+}
+
+function text(blocks: { type: string; text?: string }[]): string {
+  return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
+}
+
+describe('depthOf', () => {
+  it('reads 0 for an agent with no subagentDepth, the set value otherwise', async () => {
+    const { parent } = await setup([])
+    expect(depthOf(parent)).toBe(0)
+    const withDepth = { options: { subagentDepth: 3 } } as unknown as Agent
+    expect(depthOf(withDepth)).toBe(3)
+  })
+})
+
+describe('startInProcessRun', () => {
+  it('drives a fresh child (no seed) to completion and returns its output', async () => {
+    const { ctx, parent } = await setup([textResponse('driver child answer')])
+    const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' })
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(text(result.output)).toBe('driver child answer')
+    expect(depthOf(ctx.agents.get(run.id)!)).toBe(1)
+    await run.dispose()
+  })
+
+  it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
+    const { ctx, parent } = await setup([])
+    expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' }))
+      .toThrow(SubagentDepthError)
+  })
+
+  it('seeds the child session when a seed is supplied', async () => {
+    // Drive the parent through one real turn, then seed the child with that
+    // completed-turn prefix — the child must SEE the parent's history but its
+    // result is scoped to its OWN events (not the seeded parent message).
+    const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('seeded child reply')])
+    parent.send([{ type: 'text', text: 'parent q' }])
+    await parent.whenIdle()
+    const seed = parent.session.events.slice()
+    const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed })
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(text(result.output)).toBe('seeded child reply')
+    const child = ctx.agents.get(run.id)!
+    // The child inherited the parent's prefix.
+    expect(child.session.events.slice(0, seed.length).some(e => e.type === 'user/message')).toBe(true)
+    await run.dispose()
+  })
+})

+ 30 - 0
packages/subagent/subagent-inprocess/tsconfig.json

@@ -0,0 +1,30 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../subagent"
+    }
+  ]
+}

+ 19 - 0
packages/subagent/subagent-spawn/README.md

@@ -0,0 +1,19 @@
+# @deepseek-ai/dsh-subagent-spawn
+
+The in-process **spawn** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a **fresh** child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`) — its own session, its own (or the parent's) model, zero inherited conversation. The cheapest transport, reusing the agent factory's quiescent [`AgentHandle`](../../core/agent) teardown.
+
+The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../subagent-inprocess/README.md) driver (`startInProcessRun`); this backend just passes **no seed** (a fresh child). The [fork](../subagent-fork/README.md) backend is an independent peer over the same driver — neither knows about the other.
+
+## What it does
+
+`start(request)` delegates to `startInProcessRun(ctx, request, { providerName })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose).
+
+## Capabilities
+
+`{ outputSchema: false, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap; structured output and tool-scoping are deferred (the service rejects a request needing either before `start` runs).
+
+## Config
+
+| Key | Meaning |
+|---|---|
+| `providerName` | Registry name on `ctx.subagents` (default `spawn`). |

+ 49 - 0
packages/subagent/subagent-spawn/package.json

@@ -0,0 +1,49 @@
+{
+  "name": "@deepseek-ai/dsh-subagent-spawn",
+  "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents",
+  "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-subagent": "^0.0.1",
+    "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop": "workspace:^",
+    "@deepseek-ai/dsh-bash-local": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@deepseek-ai/dsh-subagent-inprocess": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tool-bash": "workspace:^",
+    "@deepseek-ai/dsh-tool-subagent": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@cordisjs/plugin-loader": "^1.0.0-rc.4",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 54 - 0
packages/subagent/subagent-spawn/src/index.ts

@@ -0,0 +1,54 @@
+/**
+ * The in-process SPAWN subagent backend: registers a {@link SubagentProvider}
+ * on `ctx.subagents` that runs each child as a FRESH child {@link Agent} on the
+ * same cordis context (its own session, own system prompt, zero parent
+ * context). The cheapest transport, reusing the agent factory's quiescent
+ * teardown.
+ *
+ * The run mechanics live in `@deepseek-ai/dsh-subagent-inprocess`
+ * ({@link startInProcessRun}); this backend just passes NO seed (a fresh
+ * child). The fork backend is an independent peer over the same driver.
+ *
+ * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
+ *
+ * @module @deepseek-ai/dsh-subagent-spawn
+ */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
+
+export const name = 'subagent-spawn'
+export const inject = ['subagents', 'agents']
+
+/** Config: the registry name to register the provider under. */
+export interface Config {
+  /** Provider name on `ctx.subagents` (default `spawn`). */
+  providerName: string
+}
+
+export const Config: z<Config> = z.object({
+  providerName: z.string().default('spawn'),
+})
+
+/**
+ * The spawn provider. Supports `depthLimit` (it constructs the child, so it can
+ * enforce a recursion cap) but NOT `outputSchema` or `toolFilter` in this cut —
+ * a request that needs either is rejected by the service before `start` runs.
+ */
+class SpawnProvider implements SubagentProvider {
+  readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
+
+  constructor(readonly name: string, private readonly ctx: Context) {}
+
+  start(request: SubagentStartRequest) {
+    // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
+    // depth, drives the one-shot, and maps the result.
+    return startInProcessRun(this.ctx, request, { providerName: this.name })
+  }
+}
+
+export function apply(ctx: Context, config: Config): void {
+  ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx))
+}

+ 49 - 0
packages/subagent/subagent-spawn/tests/harness.ts

@@ -0,0 +1,49 @@
+import { Context } from 'cordis'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
+import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
+import SubagentService from '@deepseek-ai/dsh-subagent'
+import * as Spawn from '../src/index.ts'
+import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
+
+/**
+ * Shared harness for the spawn-backend e2e: the full real stack (DeepSeek
+ * adapter + real bash tool + the subagent tool bound to the spawn backend), so
+ * a real parent agent can delegate to a real in-process child that does real
+ * work (writes a file). Lives outside the *.e2e.ts pattern so importing it never
+ * re-registers another file's tests.
+ */
+export async function spawnHarness(workdir: string): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
+  await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
+  await ctx.plugin(ToolBash)
+  await ctx.plugin(SubagentService)
+  await ctx.plugin(Spawn, { providerName: 'spawn' })
+  // The model-facing subagent tool, bound to the spawn backend.
+  await ctx.plugin(ToolSubagent, { provider: 'spawn' })
+  return ctx
+}
+
+export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
+  return new Promise((resolve) => {
+    const dispose = ctx.on('agent/status', (subject, status) => {
+      if (subject === agent && status === 'idle') {
+        dispose()
+        resolve()
+      }
+    })
+  })
+}

+ 54 - 0
packages/subagent/subagent-spawn/tests/spawn.e2e.ts

@@ -0,0 +1,54 @@
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { Context } from 'cordis'
+import { AgentId } from '@deepseek-ai/dsh-agent'
+import { spawnHarness, waitForIdle } from './harness.ts'
+
+/**
+ * With-key smoke for the in-process spawn backend: a REAL parent agent delegates
+ * to a REAL child (via the `subagent` tool → spawn backend) that uses the REAL
+ * bash tool to write a file, and we verify the WORLD (the file on disk) — not
+ * the agent's self-report. This is the "green units, broken product" guard:
+ * mocks prove the plumbing, only a real model proves a parent can actually drive
+ * a child to do real work. Key-gated (self-skips without DEEPSEEK_API_KEY).
+ */
+
+let ctx: Context | undefined
+let workdir: string | undefined
+
+afterEach(async () => {
+  await ctx?.fiber.dispose()
+  ctx = undefined
+  if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
+  workdir = undefined
+})
+
+describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', () => {
+  it('a parent delegates to a child that writes a file on disk', async () => {
+    workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-'))
+    ctx = await spawnHarness(workdir)
+    const parent = ctx.agentLoop.create(AgentId('e2e-parent'), {
+      model: 'deepseek-v4-flash',
+      systemPrompt: 'You are an orchestrator. To do file work, delegate to a subagent with the `subagent` tool — '
+        + 'give it a complete, standalone instruction. Report only when done.',
+    })
+
+    parent.send([{ type: 'text', text:
+      'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text '
+      + 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." '
+      + 'After the subagent finishes, tell me it is done.' }])
+    await waitForIdle(ctx, parent)
+
+    // Verify the WORLD: the child actually wrote the file.
+    const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
+    expect(proof).toContain('SUBAGENT_WAS_HERE')
+
+    // The parent's log records the subagent tool/call + its result (not the
+    // child's internal steps).
+    const events = [...parent.session.events]
+    const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent')
+    expect(subagentCalls.length).toBeGreaterThan(0)
+  }, 180_000)
+})

+ 271 - 0
packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts

@@ -0,0 +1,271 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import * as Invariants from '@deepseek-ai/dsh-invariants'
+import SubagentService from '@deepseek-ai/dsh-subagent'
+import { MockAdapter, maxTokensResponse, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import * as spawn from '../src/index.ts'
+import { depthOf, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess'
+
+type Script = ConstructorParameters<typeof MockAdapter>[0]
+
+/**
+ * Drives the REAL spawn backend end-to-end: a real agent loop + a scripted mock
+ * MODEL (the only mocked boundary) + the real SubagentService + the real
+ * dsh-invariants plugin (so a malformed child session log would fail the test).
+ * The parent is a real config agent; the spawn provider creates a real child
+ * agent on the same context and we assert its output.
+ */
+async function setup(script: Script) {
+  const ctx = new Context()
+  const adapter = new MockAdapter(script)
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(Invariants)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(SubagentService)
+  await ctx.plugin(spawn, { providerName: 'spawn' })
+  ctx.llm.registerAdapter(['mock'], adapter)
+  const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
+  return { ctx, parent, adapter }
+}
+
+function text(blocks: { type: string; text?: string }[]): string {
+  return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
+}
+
+describe('dsh-subagent-spawn', () => {
+  it('runs a fresh child to completion and returns its final assistant output', async () => {
+    // One model call for the child: a plain text answer.
+    const { ctx, parent } = await setup([textResponse('child answer')])
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(text(result.output)).toBe('child answer')
+    await run.dispose()
+  })
+
+  it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => {
+    const { ctx, parent } = await setup([textResponse('hi')])
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
+    await run.result
+    const child = ctx.agents.get(run.id)!
+    expect(child.session.header.id).not.toBe(parent.session.header.id)
+    expect(child.session.header.parentSession).toBe(parent.session.header.id)
+    await run.dispose()
+  })
+
+  it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => {
+    // Drive the parent through one real turn so it has history, THEN spawn.
+    const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')])
+    parent.send([{ type: 'text', text: 'parent prompt' }])
+    await parent.whenIdle()
+    const parentEventCount = parent.session.events.length
+    expect(parentEventCount).toBeGreaterThan(0)
+
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent })
+    await run.result
+    const child = ctx.agents.get(run.id)!
+    // The child's first user/message is its OWN prompt, not the parent's history.
+    const firstUser = child.session.events.find(e => e.type === 'user/message')
+    expect(firstUser).toBeDefined()
+    await run.dispose()
+  })
+
+  it('disposes the child to quiescence (agent removed from the registry)', async () => {
+    const { ctx, parent } = await setup([textResponse('x')])
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
+    await run.result
+    expect(ctx.agents.get(run.id)).toBeDefined()
+    await run.dispose()
+    // After dispose, the child is unregistered (the AgentHandle teardown ran).
+    expect(ctx.agents.get(run.id)).toBeUndefined()
+  })
+
+  it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => {
+    const { ctx, parent } = await setup([textResponse('x')])
+    expect(depthOf(parent)).toBe(0)
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
+    await run.result
+    const child = ctx.agents.get(run.id)!
+    expect(depthOf(child)).toBe(1)
+    await run.dispose()
+  })
+
+  it('refuses to spawn past maxDepth (depthLimit capability)', async () => {
+    const { ctx, parent } = await setup([])
+    // parent is depth 0, child would be depth 1 — cap at 0 forbids any child.
+    expect(() => ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }))
+      .toThrow(SubagentDepthError)
+  })
+
+  it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => {
+    const { ctx, parent } = await setup([maxTokensResponse('cut off')])
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
+    const result = await run.result
+    expect(result.stopReason).toBe('max-tokens')
+    await run.dispose()
+  })
+
+  it('maps a child whose turn errored (script exhausted) to stopReason "error" with empty output', async () => {
+    // Empty script: the child's first model call throws "script exhausted", the
+    // turn ends `error`, and there is no assistant/message → empty output.
+    const { ctx, parent } = await setup([])
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(result.output).toEqual([])
+    await run.dispose()
+  })
+
+  it('settles aborted (without running the child) when the request signal is ALREADY aborted', async () => {
+    // Regression: a signal aborted BEFORE the run starts never fires an `abort`
+    // event, so the listener can't catch it. The driver must check the
+    // already-aborted case up front and settle `aborted` without running the
+    // child — otherwise an already-cancelled request runs to `completed`. The
+    // empty script proves the child's model is never called.
+    const controller = new AbortController()
+    controller.abort()
+    const { ctx, parent } = await setup([])
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    expect(result.output).toEqual([])
+    await run.dispose()
+  })
+
+  it('cancelling BEFORE the child turn starts settles aborted, not error', async () => {
+    // Regression: a cancel landing in the pre-turn window clears the queued
+    // prompt before any `turn/end` is logged. Deriving the stop reason from
+    // `turn/end` alone then mis-maps the no-turn case to `error`; the run must
+    // honor the cancel contract and settle `aborted`. The cancel is synchronous
+    // (same tick as start, before the loop's queued-wait continuation runs), so
+    // the turn is dropped and the empty script is never consumed.
+    const { ctx, parent } = await setup([])
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
+    run.cancel('early')
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    expect(result.output).toEqual([])
+    await run.dispose()
+  })
+
+  it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => {
+    // 'hang' makes the child's model stream one chunk then wait until aborted.
+    const controller = new AbortController()
+    const { ctx, parent } = await setup(['hang'])
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
+    // Let the child's turn start, then abort via the request signal (the
+    // backend bridges it to child.cancel()).
+    await new Promise(r => setTimeout(r, 30))
+    controller.abort()
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    await run.dispose()
+  })
+
+  it('run.cancel() also cancels the child directly', async () => {
+    const { ctx, parent } = await setup(['hang'])
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
+    await new Promise(r => setTimeout(r, 30))
+    run.cancel('test cancel')
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    await run.dispose()
+  })
+
+  it('run.cancel() with no reason uses the default cancel reason', async () => {
+    const { ctx, parent } = await setup(['hang'])
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
+    await new Promise(r => setTimeout(r, 30))
+    run.cancel()
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    await run.dispose()
+  })
+
+  it('does not expose the optional runtime methods (sendMessage/resume) in this cut', async () => {
+    const { ctx, parent } = await setup([textResponse('x')])
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
+    expect('sendMessage' in run).toBe(false)
+    expect('resume' in run).toBe(false)
+    await run.result
+    await run.dispose()
+  })
+
+  it('inherits the parent cwd into the child session', async () => {
+    const { ctx } = await setup([textResponse('x')])
+    // A parent WITH a cwd (config agents have none, so create one explicitly).
+    const parentHandle = ctx.agents.create({
+      agentId: AgentId('cwd-parent'),
+      sessionId: SessionId('cwd-parent-session'),
+      meta: { cwd: '/tmp/parent-workspace' },
+      agentOptions: { model: 'mock' },
+    })
+    const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent })
+    await run.result
+    const child = ctx.agents.get(run.id)!
+    expect(child.session.header.cwd).toBe('/tmp/parent-workspace')
+    await run.dispose()
+    await parentHandle.dispose()
+  })
+
+  it('uses request.agentOptions.model when the parent has no model of its own', async () => {
+    const { ctx } = await setup([textResponse('explicit model child')])
+    // A parent with NO model (its own turns would need one supplied per-request).
+    const parentHandle = ctx.agents.create({
+      agentId: AgentId('modelless-parent'),
+      sessionId: SessionId('modelless-parent-session'),
+      agentOptions: {},
+    })
+    // The request supplies the child's model explicitly.
+    const run = ctx.subagents.start('spawn', {
+      prompt: [{ type: 'text', text: 'p' }],
+      parent: parentHandle.agent,
+      agentOptions: { model: 'mock' },
+    })
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(text(result.output)).toBe('explicit model child')
+    await run.dispose()
+    await parentHandle.dispose()
+  })
+
+  it('advertises depthLimit but not outputSchema/toolFilter', async () => {
+    const { ctx } = await setup([])
+    const provider = ctx.subagents.getProvider('spawn')!
+    expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false })
+  })
+
+  it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SubagentService)
+    await ctx.plugin(AgentRegistry)
+    const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
+    expect(ctx.subagents.list()).toEqual(['spawn'])
+    await fiber.dispose()
+    expect(ctx.subagents.list()).toEqual([])
+  })
+
+  it('has the namespace-plugin export shape (no stray default)', () => {
+    expect('default' in spawn).toBe(false)
+    expect(spawn.name).toBe('subagent-spawn')
+    expect(spawn.inject).toEqual(['subagents', 'agents'])
+    const loader = Object.create(Loader.prototype) as Loader
+    const unwrapped = loader.unwrapExports(spawn) as Record<string, unknown>
+    expect(unwrapped).toBe(spawn)
+    expect(unwrapped.name).toBe('subagent-spawn')
+    expect(unwrapped.inject).toEqual(['subagents', 'agents'])
+    expect(typeof unwrapped.apply).toBe('function')
+  })
+})

+ 27 - 0
packages/subagent/subagent-spawn/tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../subagent"
+    },
+    {
+      "path": "../subagent-inprocess"
+    }
+  ]
+}

+ 2 - 2
packages/subagent/subagent/src/index.ts

@@ -70,7 +70,7 @@ declare module 'cordis' {
 export interface SubagentRunInfo {
   /** The provider that started the run. */
   provider: string
-  /** The child agent/session id. */
+  /** The child agent's id. */
   id: AgentId
 }
 
@@ -78,7 +78,7 @@ export interface SubagentRunInfo {
 export interface SubagentRunEndInfo {
   /** The provider that ran it. */
   provider: string
-  /** The child agent/session id. */
+  /** The child agent's id. */
   id: AgentId
   /** The terminal stop reason. */
   stopReason: SubagentResult['stopReason']

+ 1 - 1
packages/subagent/subagent/src/types.ts

@@ -122,7 +122,7 @@ export interface SubagentResult {
  * presence of the method IS the capability — narrow before calling.
  */
 export interface SubagentRun {
-  /** The child agent's id (also its session id token, for correlation). */
+  /** The child agent's id (use `ctx.agents.get(id)` to reach the live child). */
   readonly id: AgentId
   /**
    * Resolves with the child's terminal {@link SubagentResult} when the run

+ 10 - 0
packages/support/llm-replay/src/index.ts

@@ -211,6 +211,16 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
  * the snapshot harness runs one ACP session per scenario to guarantee that. The
  * cursor is advanced synchronously at listener-invocation time (not lazily
  * inside the generator) so call ORDER, not iteration order, fixes the mapping.
+ *
+ * TODO(subagent-snapshots): this single global cursor cannot route calls to the
+ * right agent when a parent and an in-process subagent both stream on one ctx.
+ * Snapshot coverage of nested agents needs either per-session-keyed replay (a
+ * `Map<sessionId, cursor>` fed by the calling agent on the `agent/request`
+ * waterfall, which carries the agent) or a call-ordered merge of the parent and
+ * child session logs (sound because subagent execution is strictly nested —
+ * the parent blocks on the child). Tracked as a stacked follow-up to the
+ * in-process subagent backends; see the subagent RFC's "Snapshot coverage of
+ * nested agents" deferral.
  */
 export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void {
   const entries = loadReplayScript(config)

+ 125 - 0
pnpm-lock.yaml

@@ -333,6 +333,131 @@ 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/subagent/subagent-fork:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@cordisjs/plugin-loader':
+        specifier: ^1.0.0-rc.4
+        version: 1.0.0-rc.4(cordis@4.0.0-rc.6)
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-agent-loop':
+        specifier: workspace:^
+        version: link:../../core/agent-loop
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../subagent
+      '@deepseek-ai/dsh-subagent-inprocess':
+        specifier: workspace:^
+        version: link:../subagent-inprocess
+      '@deepseek-ai/dsh-subagent-spawn':
+        specifier: workspace:^
+        version: link:../subagent-spawn
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      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/subagent/subagent-inprocess:
+    devDependencies:
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-agent-loop':
+        specifier: workspace:^
+        version: link:../../core/agent-loop
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../subagent
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      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/subagent/subagent-spawn:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@cordisjs/plugin-loader':
+        specifier: ^1.0.0-rc.4
+        version: 1.0.0-rc.4(cordis@4.0.0-rc.6)
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-agent-loop':
+        specifier: workspace:^
+        version: link:../../core/agent-loop
+      '@deepseek-ai/dsh-bash-local':
+        specifier: workspace:^
+        version: link:../../bash/bash-local
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-llm-deepseek':
+        specifier: workspace:^
+        version: link:../../llm/llm-deepseek
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../subagent
+      '@deepseek-ai/dsh-subagent-inprocess':
+        specifier: workspace:^
+        version: link:../subagent-inprocess
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tool-bash':
+        specifier: workspace:^
+        version: link:../../bash/tool-bash
+      '@deepseek-ai/dsh-tool-subagent':
+        specifier: workspace:^
+        version: link:../tool-subagent
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      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/subagent/tool-subagent:
     dependencies:
       schemastery:

+ 4 - 1
tsconfig.build.json

@@ -34,6 +34,9 @@
     { "path": "./packages/support/llm-replay" },
     { "path": "./packages/subagent/subagent" },
     { "path": "./packages/support/subagent-mock" },
-    { "path": "./packages/subagent/tool-subagent" }
+    { "path": "./packages/subagent/tool-subagent" },
+    { "path": "./packages/subagent/subagent-inprocess" },
+    { "path": "./packages/subagent/subagent-spawn" },
+    { "path": "./packages/subagent/subagent-fork" }
   ]
 }

+ 4 - 1
tsconfig.json

@@ -45,6 +45,9 @@
     { "path": "./packages/support/llm-replay" },
     { "path": "./packages/subagent/subagent" },
     { "path": "./packages/support/subagent-mock" },
-    { "path": "./packages/subagent/tool-subagent" }
+    { "path": "./packages/subagent/tool-subagent" },
+    { "path": "./packages/subagent/subagent-inprocess" },
+    { "path": "./packages/subagent/subagent-spawn" },
+    { "path": "./packages/subagent/subagent-fork" }
   ]
 }