Selaa lähdekoodia

Add in-process subagent backends: spawn (fresh) and fork (seeded)

The second PR of the subagent seam: the two in-process backends that run a
child agent on the same cordis context, reusing the agent factory's quiescent
AgentHandle teardown. Both register on ctx.subagents (PR1's named-provider
registry) and share one run driver.

- dsh-subagent-spawn: a FRESH child via ctx.agents.create — own session, the
  parent's model by default (overridable), zero inherited conversation. Also
  exports the shared in-process run driver (startInProcessRun): mint ids, stamp
  cwd/parentSession-lineage/depth, drive the one-shot (send → whenIdle), read
  the last assistant/message + turn/end reason, dispose to quiescence.
- dsh-subagent-fork: a child SEEDED with the parent's balanced completed-turn
  prefix (the log up to and including its last turn/end), so the child inherits
  context. The in-flight unbalanced turn is excluded — a raw seed would fail the
  invariants replay. Proven: a regression test goes red if the boundary seeds
  the open turn.
- Seam extension: CreateAgentOptions.seed, threaded through AgentLoop.createAgent
  → ctx.sessions.prepare({ seed }) (the primitive resume already used). This is
  the fork-lineage path the TODO(sub-agents) markers anticipated.
- Depth: a merge-extensible AgentOptions.subagentDepth (0 top-level, parent+1 for
  a child); the depthLimit capability refuses a spawn past request.maxDepth.

Tests: real-loop unit tests for both backends (mock MODEL only, real loop +
invariants), a multi-subagent test (one parent drives a fork AND a spawn child
then keeps working), and a with-key e2e (a real parent delegates via the
`subagent` tool to a real child that writes a file on disk — world-verified).
100% per-file coverage. The coding-agent demo wires the spawn backend + tool.

Snapshot coverage of nested agents is deferred to a stacked follow-up
(TODO(subagent-snapshots)): dsh-llm-replay is a single global positional cursor
that cannot route calls to a parent vs. a child on one context. Recorded in the
RFC's deferrals and a new AGENTS.md rule: designing a subsystem must design its
test infrastructure END TO END up front, verifying the snapshot/e2e harness can
express the new shape — a gap this plan hit.
Tianyi Cui 3 kuukautta sitten
vanhempi
sitoutus
7aabd2a3df

+ 1 - 0
AGENTS.md

@@ -207,6 +207,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js
 - **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/cordis-catalog/events-and-services.md

@@ -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)
 

+ 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).

+ 10 - 0
docs/module-graph.md

@@ -63,6 +63,10 @@ graph TD
   subagent-mock --> agent
   subagent-mock --> llm
   subagent-mock --> subagent
+  subagent-spawn --> agent
+  subagent-spawn --> llm
+  subagent-spawn --> session
+  subagent-spawn --> subagent
   tool-subagent --> agent
   tool-subagent --> llm
   tool-subagent --> subagent
@@ -75,6 +79,10 @@ 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-spawn
 ```
 
 | Package | Depends on |
@@ -101,6 +109,8 @@ graph TD
 | `tool-bash` | `agent`, `bash`, `llm`, `tools` |
 | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` |
 | `subagent-mock` | `agent`, `llm`, `subagent` |
+| `subagent-spawn` | `agent`, `llm`, `session`, `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-spawn` |

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

@@ -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)`.

+ 28 - 5
examples/coding-agent/cordis.yml

@@ -48,12 +48,35 @@
     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.
 
       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 + an in-process spawn backend + the model-facing `subagent`
+# tool, as leaf entries after the app (which provides ctx.agents/ctx.tools). The
+# tool is bound to the `spawn` backend: a delegated task runs as a fresh child
+# agent on this same process. (fork is available too — load dsh-subagent-fork
+# and a second dsh-tool-subagent bound to it with a distinct toolName.)
+- id: subagent
+  name: '@deepseek-ai/dsh-subagent'
+
+- id: subagent-spawn
+  name: '@deepseek-ai/dsh-subagent-spawn'
+  config:
+    providerName: spawn
+
+- id: tool-subagent
+  name: '@deepseek-ai/dsh-tool-subagent'
+  config:
+    provider: spawn

+ 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 - 4
packages/core/agent-loop/src/index.ts

@@ -140,16 +140,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
 }

+ 3 - 1
packages/subagent/README.md

@@ -5,8 +5,10 @@ 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-spawn/` | In-process backend: a fresh child agent (+ the shared run driver) | (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 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.

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

@@ -0,0 +1,45 @@
+{
+  "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/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib",
+    "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-spawn": "^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-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"
+  }
+}

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

@@ -0,0 +1,79 @@
+/**
+ * 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. Shares the run driver with
+ * `@deepseek-ai/dsh-subagent-spawn`; the only difference is the seed.
+ *
+ * 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-spawn'
+
+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)
+  })
+})

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

@@ -0,0 +1,161 @@
+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 * as fork from '../src/index.ts'
+import { completedTurnPrefix } from '../src/index.ts'
+
+type Script = ConstructorParameters<typeof MockAdapter>[0]
+
+/**
+ * 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('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"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../subagent"
+    },
+    {
+      "path": "../subagent-spawn"
+    }
+  ]
+}

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

@@ -0,0 +1,29 @@
+# @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.
+
+It also exports the **shared in-process run driver** (`startInProcessRun`) that the [fork](../subagent-fork/README.md) backend builds on — spawn and fork differ only in the session seed.
+
+## What it does
+
+`start(request)` →
+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, 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: 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.
+
+## 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`). |
+
+## Depth tracking
+
+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`. Read it with the exported `depthOf(agent)`.

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

@@ -0,0 +1,48 @@
+{
+  "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/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib",
+    "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"
+  },
+  "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-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"
+  }
+}

+ 164 - 0
packages/subagent/subagent-spawn/src/in-process.ts

@@ -0,0 +1,164 @@
+/**
+ * The shared in-process subagent run driver. A subagent backend that runs the
+ * 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. Both in-process backends use this:
+ * `@deepseek-ai/dsh-subagent-spawn` (a fresh child) and
+ * `@deepseek-ai/dsh-subagent-fork` (a child seeded with a prefix of the
+ * parent's log) differ ONLY in the `seed` they pass — everything downstream
+ * (drive the child, read its final output, map the stop reason, dispose) is
+ * identical and lives here.
+ *
+ * @module @deepseek-ai/dsh-subagent-spawn/in-process
+ */
+
+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())
+  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).
+  const onAbort = (): void => { child.cancel('subagent cancelled') }
+  request.signal?.addEventListener('abort', onAbort, { once: true })
+
+  const result: Promise<SubagentResult> = (async () => {
+    try {
+      child.send(request.prompt)
+      await child.whenIdle()
+      return readResult(child)
+    } finally {
+      request.signal?.removeEventListener('abort', onAbort)
+    }
+  })()
+
+  return {
+    id: childId,
+    result,
+    cancel(reason?: string): void {
+      child.cancel(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: the last
+ * `assistant/message` content (deep-cloned — the log is frozen) and the last
+ * `turn/end` reason mapped to a {@link SubagentStopReason}.
+ */
+function readResult(child: Agent): SubagentResult {
+  const events = child.session.events
+  const lastMessage = events.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message')
+  const lastEnd = events.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end')
+  const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : []
+  return { output, stopReason: toStopReason(lastEnd?.data.reason) }
+}

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

@@ -0,0 +1,57 @@
+/**
+ * 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 fork sibling (`@deepseek-ai/dsh-subagent-fork`) shares this package's run
+ * driver ({@link startInProcessRun}) and differs ONLY in seeding the child with
+ * a prefix of the parent's log.
+ *
+ * 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 './in-process.ts'
+
+export { startInProcessRun, depthOf, SubagentDepthError } from './in-process.ts'
+export type { InProcessRunOptions } from './in-process.ts'
+
+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)
+})

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

@@ -0,0 +1,239 @@
+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 '../src/in-process.ts'
+
+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('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')
+  })
+})

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

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

+ 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)

+ 89 - 0
pnpm-lock.yaml

@@ -333,6 +333,95 @@ 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-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-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-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:

+ 3 - 1
tsconfig.build.json

@@ -34,6 +34,8 @@
     { "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-spawn" },
+    { "path": "./packages/subagent/subagent-fork" }
   ]
 }