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.
Packages
Harness packages, all under the @deepseek-ai/dsh-* scope. Each package is a Cordis plugin (microkernel-style): it exports either a default Service subclass or a functional plugin that gets registered via ctx.plugin(), declares its ctx key/events where applicable through declaration merging, and exposes extension points through ctx.effect(), ctx.on(), and ctx.waterfall().
Hierarchy
Packages are grouped by modular role at packages/<group>/<pkg>/. The group directory is a pure container (no package.json of its own); the package name stays @deepseek-ai/dsh-<pkg> regardless of group. Each group has a README.md describing its role and whether it is product or support infrastructure.
| Group | Role | Release expectation |
|---|---|---|
core/ |
Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
llm/ |
LLM capability family: the abstract service + provider adapters | Product — stable surface |
bash/ |
Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
subagent/ |
Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
session-persistence/ |
Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
ui/ |
Editor/client integration surfaces (the ACP bridge) | Product — stable surface |
support/ |
Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
util/ |
Low-level zero-dependency utilities shared across groups (the Branded<B> primitive) |
Support — small, stable, harness-dep-free |
The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not have to treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and the hierarchy docs).
Dependency graph
dsh-brand (no harness deps — type-only Branded<B> primitive)
dsh-llm ← dsh-brand (vocabulary; brands CallId)
dsh-bash ← dsh-brand (abstract executor seam; brands BashTaskId/OwnerToken)
dsh-session ← dsh-llm, dsh-brand
dsh-system-prompt ← dsh-llm
dsh-agent ← dsh-llm, dsh-session, dsh-brand
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
dsh-bash-local ← dsh-bash (BashExecutor impl)
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
dsh-llm-deepseek ← dsh-llm (DeepSeek adapter)
dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter)
dsh-agent-loop ← dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent
dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks)
dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence (ACP JSON-RPC bridge)
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)
dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin)
The rule: extension plugins depend on interfaces, never on the concrete loop. dsh-agent-loop is swappable — UI/hook/tool plugins keep working against the dsh-agent vocabulary if the loop is replaced. The sanctioned exception is a composition/bundle package like dsh-agent-core, whose whole job is to assemble the concrete spine: it depends on dsh-agent-loop (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see capability seams).
What goes where
| Package | Group | Role | ctx key |
|---|---|---|---|
llm/ |
llm |
Abstract LLM service + content-block vocabulary + chunk assembler | ctx.llm |
session/ |
core |
Event-sourced session log + in-memory store | ctx.sessions |
system-prompt/ |
core |
Prompt-section + tool-schema assembly registry | ctx.systemPrompt |
tools/ |
core |
Tool registry + tools/execute waterfall |
ctx.tools |
agent/ |
core |
Agent interface, registry, agent/* event vocabulary |
ctx.agents |
agent-loop/ |
core |
THE concrete loop plugin: ReactLoopAgent + the loop driver |
ctx.agentLoop |
agent-core/ |
core |
Bundle plugin: the providerless/executor-less/UI-less spine as code (forwards agent-loop's agents) |
(loads the spine) |
bash/ |
bash |
Abstract bash executor seam (interface + vocabulary) | ctx.bash |
bash-local/ |
bash |
Local-subprocess BashExecutor implementation |
(registers ctx.bash) |
tool-bash/ |
bash |
Model-facing bash/bash_output/bash_kill tool schemas |
(registers on ctx.tools) |
llm-deepseek/ |
llm |
DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on ctx.llm) |
llm-pi-ai/ |
llm |
DeepSeek adapter via @earendil-works/pi-ai (design twin) |
(registers on ctx.llm) |
session-persistence/ |
session-persistence |
Persistence seam + write coordinator | ctx.sessionPersistence |
session-persistence-jsonl/ |
session-persistence |
JSONL-sidecar persistence backend | (registers ctx.sessionPersistence) |
session-persistence-sqlite/ |
session-persistence |
SQLite persistence backend | (registers ctx.sessionPersistence) |
invariants/ |
support |
Dev-mode event-contract invariants + session-log freeze | (listens on session/*, agent/*) |
acp/ |
ui |
Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives ctx.agents/ctx.sessions) |
stdio-agent/ |
ui |
Terminal stdio chat APP: agent-core spine + console logger + readline UI + a pre-created main agent, with a bin |
(composition + bin) |
acp-agent/ |
ui |
ACP server APP: agent-core spine + JSONL persistence + the acp bridge (no stdout logger), with a bin |
(composition + bin) |
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) |
Each package has its own README.md with purpose, service API, events, extension points, and deliberate non-goals (TODOs).
Conventions (applied across all harness packages)
- Registrations are effects: every contribution (adapter, tool, section, agent, event listener) goes through
ctx.effect()/ctx.on(), so disposal and HMR clean up automatically. Everyregister()returns the disposer. - Declaration merging for events and ctx: services declare their events in
declare module 'cordis' { interface Events { ... } }and their ctx key ininterface Context. - Waterfall semantics:
ctx.waterfalllisteners receive(...args, next)and MUST callnext()to delegate; returning without it short-circuits (the veto mechanism). - Extensible unions:
ContentBlockMap,MessageSourceMap,FinishReasonMap,TurnTriggerMap,TurnEndReasonMap, andSessionEventMapuse the merge-extensible-map pattern so plugins can add variants via declaration merging. - ESM everywhere; imports use package names across package boundaries,
.tsextensions within a package. - Tests: vitest, colocated under
packages/<group>/<pkg>/tests/*.spec.ts. Every registry needs an HMR-safety test. Err on the side of more tests.