diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index d6d9ab1ba7..fa2c4bd0fb 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -39,6 +39,7 @@ sequenceDiagram
Tools-->>Session: tool-owned events when applicable
Driver->>Session: tool/result and step/end
Driver->>Hooks: agent/turn-continuation waterfall
+ Driver->>Hooks: agent/turn-stop serial terminal checkpoint
Driver->>Session: turn/end
Driver->>Persistence: session/flush parallel checkpoint
Driver-->>SDK: agent/status idle
diff --git a/docs/architecture.md b/docs/architecture.md
index 7f2cc51f3e..cb2a61fe4c 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -60,7 +60,9 @@ A **session** is one agent's append-only event log. A **turn** drains one queued
### Turn Flow
```text
-create agent -> mint agent scope (agent.ctx) -> run creation setup -> emit agent/session-start(source)
+reserve ids -> mint agent.ctx -> await unpublished setup
+ -> enter session + agent -> session/created -> agent/created
+ -> enable driving -> agent/session-start(source) -> start driver
forever:
wait for queued messages
emit agent/status(running)
@@ -82,11 +84,12 @@ forever:
'assistant/message'
each tool call:
'tool/call'
- tools/pre-execute -> tools/execute -> tools/post-execute
+ tools/pre-execute -> monotonic guards -> tools/execute -> tools/post-execute -> tools/result
'tool/result'
append post-tool context and steering
'step/end'
agent/turn-continuation
+ agent/turn-stop (terminal policy)
stop unless tools or continuation policy ask for another step
'turn/end'
checkpoint persistence and notify idle/running status
@@ -94,7 +97,7 @@ forever:
Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the system prompt sent to the model. Plugins contribute ordered sections (static or computed from the per-call `AssembleContext`), tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` owns the openers — the static `harness:identity` section (order −100) and the deployment's persona (order 0, its `persona` config, shared context-wide) — while the shipped loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md).
-Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; leftover steering after a turn is re-queued as ordinary input.
+Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved.
### Failure Boundaries
@@ -148,7 +151,7 @@ New behavior should attach to a documented extension point; changing the shipped
| Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly |
| Add command execution | implement and register a `ctx.bash` backend |
| Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events |
-| Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall |
+| Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall; use serial `agent/turn-stop` for a monotonic terminal stop |
| Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header |
| Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
| Add durable session state | add a `SessionEventMap` member and render/replay from the log |
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 1c24a2916e..5f56be9da8 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -29,7 +29,7 @@ flowchart LR
pkg_tools["tools"]
pkg_tool_fs["tool-fs"]
pkg_tool_web["tool-web"]
- svc_tools["ctx.tools
Tool registry and execution waterfall"]
+ svc_tools["ctx.tools
Tool registry and guarded execution pipeline"]
pkg_tool_ask_user["tool-ask-user"]
pkg_tool_bash["tool-bash"]
pkg_tool_cordis["tool-cordis"]
@@ -155,7 +155,7 @@ flowchart LR
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
-| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. |
+| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index a85fa29e93..5f01144504 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -128,7 +128,7 @@ export interface Config {
Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
-Source: [`packages/core/agent-loop/src/index.ts:38`](../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:37`](../packages/core/agent-loop/src/index.ts)
## `@deepseek-ai/dsh-bash-local`
@@ -578,7 +578,7 @@ Source: [`packages/subagent/subagent-acp/src/index.ts:30`](../packages/subagent/
## `@deepseek-ai/dsh-subagent-fork`
-Requires: `subagents` · `agents`
+Requires: `subagents`
```ts config-catalog
/** Config: the registry name to register the provider under. */
@@ -625,7 +625,7 @@ Source: [`packages/support/subagent-mock/src/index.ts:84`](../packages/support/s
## `@deepseek-ai/dsh-subagent-spawn`
-Requires: `subagents` · `agents`
+Requires: `subagents`
```ts config-catalog
/** Config: the registry name to register the provider under. */
@@ -684,7 +684,7 @@ export interface Config {
}
```
-Source: [`packages/core/system-prompt/src/index.ts:220`](../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts)
## `@deepseek-ai/dsh-tool-cordis`
@@ -831,9 +831,9 @@ Requires: `systemPrompt`
export interface Config {
/**
* The presentation mode. `'native'` (the default) contributes every
- * registered tool as a wire function definition — byte-for-byte today's
- * behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus
- * the generated `tools:sdk` prompt section declaring every other tool as a
+ * visible end capability as a native wire function definition. Under
+ * `'code'` this registry contributes exactly ONE wire tool,
+ * `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
* TypeScript API the program calls. `'both'` contributes every native
* definition AND `run_code` + the SDK section. Non-native modes require a
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
@@ -850,7 +850,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
-Source: [`packages/core/tools/src/index.ts:338`](../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:402`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-web`
diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md
index 50c7cbccf8..5eede7ff5d 100644
--- a/docs/cookbook/adding-a-tool.md
+++ b/docs/cookbook/adding-a-tool.md
@@ -22,7 +22,7 @@ export function apply(ctx: Context) {
},
async execute(args, exec) {
// args is TYPED from the schema: { path: string; limit?: number }
- // exec carries { callId, name, arguments, agent?, signal? }
+ // exec carries immutable identity + token; signal is the operational field
return [{ type: 'text', text: await readFile(args.path, 'utf8') }]
},
}))
@@ -34,7 +34,9 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w
## Rules of the execute() contract
- **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input.
-- **Throwing means isError.** The registry catches anything `execute()` throws and returns `{isError: true}` to the model. Use that for infrastructure failures (bad input, spawn errors, aborts) — but REPORT domain failures in the result text instead (e.g. tool-bash returns `[exit code: 9]` with `isError: false`: the model decides what a failing command means).
+- **Registration snapshots your definition.** Parameters must be losslessly JSON-serializable; the registry validates and clones them, copies the scalar fields, binds each callback once to your definition as its method receiver, and freezes the stored record. Reassigning `definition.execute` after registration does not hot-swap the tool—dispose and register a new definition through the owning effect instead. Deliberate mutable state inside the callback's closure or receiver remains ordinary plugin state.
+- **Execution identity is protected.** The registry requires `arguments` to survive lossless-JSON validation before and after cloning, freezes the detached value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline.
+- **Throwing or returning non-JSON data means isError.** The registry catches anything `execute()` throws and validates the complete post-policy result as losslessly JSON-serializable before final observers run. A throw, malformed result, or non-JSON content/context/meta becomes `{isError: true}` so the live outcome cannot succeed and then fail at the durable log. Use errors for infrastructure failures (bad input, spawn errors, aborts), but report domain failures in the result text instead (for example, tool-bash returns `[exit code: 9]` with `isError: false` because the model decides what a failing command means).
- **Honor `exec.signal`.** Cancel in-flight work when it fires.
- **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`.
- **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch).
@@ -45,13 +47,13 @@ Follow tool-bash's background pattern: a `run_in_background` flag returns a task
> TODO: each tool reimplements this background pattern by hand today. At some point we need a generic long-running-tool layer that handles task ids, incremental polling, kill, and completion notices uniformly.
-## Permissions / sandboxing
+## Execution policy and observation
-Prefer not to build policy into the tool. The seam is the `tools/pre-execute` gate (deny/ask — see the permission-gate example in [extension-cookbook.md](./extension-cookbook.md)) and the `tools/post-execute` inspect/transform seam, or a sandboxing implementation behind the tool's executor seam.
+Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](./extension-cookbook.md#a-hook-plugin-permission-gate)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap core dispatch with a deadline/retry/metrics scope, `tools/post-execute` to transform or attach model-facing context, and `tools/result` to observe the immutable normalized outcome without changing it. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points).
## Code Mode reaches your tool for free
-Under the registry's non-native `mode` ([Code Mode](../../packages/core/tools/README.md)), a registered tool is ALSO callable from a `run_code` program as `await tools.(args)` — nothing to add. The generated SDK declares your parameters from the same JSON Schema `defineTool` emits (constructs outside that subset degrade to `unknown`), each program call re-enters `execute()` through both waterfalls, and a failed call rejects the program-side promise with your error text. Two consequences worth designing for: your `description` and parameter `description`s become JSDoc a model reads while WRITING CODE, and non-text result blocks reach programs as placeholders (text is the lingua franca of the bridge).
+Under the registry's non-native `mode` ([Code Mode](../../packages/core/tools/README.md)), each visible registered capability is callable from a `run_code` program as `await tools.(args)` — nothing to add. The registry keeps `run_code` itself as reserved, unfilterable presentation infrastructure while restrictions still control which end capabilities appear in the scoped SDK and bindings. The generated SDK declares parameters from the same JSON Schema `defineTool` emits (constructs outside that subset degrade to `unknown`); each program call receives its own immutable execution whose `parent` is the enclosing `run_code` token, then re-enters the complete pre/guard/around/post/result pipeline. A failed call rejects the program-side promise with your error text. Design `description` and parameter `description`s as JSDoc a model reads while writing code, and remember that non-text result blocks reach programs as placeholders (text is the bridge's lingua franca).
## How your tool renders in an editor (ACP presentation)
diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md
index de21ece7b1..b9c5d2cdf0 100644
--- a/docs/cookbook/extension-cookbook.md
+++ b/docs/cookbook/extension-cookbook.md
@@ -28,6 +28,8 @@ export function apply(ctx: Context) {
}
```
+This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an invariant needs a monotonic final denial, `tools/execute` when a plugin must wrap the actual dispatch lifetime (timeouts/retries/metrics; only `exec.signal` is replaceable), `tools/post-execute` for explicit result transformation, and `tools/result` for contained observation of the immutable final outcome. The [adding-a-tool guide](./adding-a-tool.md#execution-policy-and-observation) gives the selection rule.
+
## A UI plugin
A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.send()` / `agent.steer()`.
@@ -92,14 +94,17 @@ Every product feature maps to a listener on a documented extension seam — the
| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams |
| `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders |
| `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue |
-| Dynamic workflow | orchestrator plugin on `turn/end` (or `step/end`) driving `send`/`steer` + subagents |
+| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt protection, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` |
| Queued + steering messages | core `Agent.send()` / `Agent.steer()` |
| Context compaction (auto + manual) | the `ctx.compact` seam + a backend (`dsh-compact-basic`) on the serial `agent/pre-step` seam; auto = token-pressure check before each step; a manual trigger invokes the same `ctx.compact` routine ([compaction RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) |
-| System prompt configurability | `ctx.systemPrompt.section()` with ordering |
+| System prompt configurability | `ctx.systemPrompt.section()` with ordering; an owner uses `systemPrompt.protect()` only when its canonical section/tool presence is a correctness invariant |
| AGENTS.md (root) | a section provider reading the file |
| AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener |
| Built-in tools | `ctx.tools.register()`; schemas flow into the assembly automatically — the `dsh-tool-*` families (bash, fs, web, subagent, todo) are the shipped examples |
-| ToolSearch / progressive disclosure | filter tools at `system-prompt/assemble` (the assembly carries the schemas; the loop logs the result as the request header, so disclosure stays reconstructable) |
+| ToolSearch / progressive disclosure | filter ordinary capabilities at `system-prompt/assemble` (the loop logs the result as the request header); owner-protected transport and correctness entries retain their canonical presence or absence |
+| Tool deadline / retry / metrics | wrap core dispatch with `tools/execute`; a wrapper may replace `exec.signal`, delegate, and inspect the normalized result in one lexical lifetime |
+| Final tool-result metrics / audit / capture | observe immutable authoritative outcomes with `tools/result`; use `tools/post-execute` instead only when the plugin must transform the result or attach context |
+| Monotonic terminal turn policy | return `{ action: 'stop' }` from serial `agent/turn-stop`, after continuation and steering have already been folded |
| Tool sandbox (landlock / sandbox-exec) | `tools/pre-execute` (deny), or a sandboxing `BashExecutor` on the `dsh-bash` seam |
| Permission system / AskUserQuestion | `tools/pre-execute` (deny/ask); register an ask tool |
| Plan mode | `tools/pre-execute` (deny writes) + a mode prompt section via `ctx.systemPrompt.section()` or `agent.inject()` (model-visible ⟺ logged: `agent/request` shapes call config only) |
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index 659dc9143b..48f0d32eb0 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n
### `agent/created` — emit
-An agent was registered in the AgentRegistry and is ready to receive messages.
+An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store, but concrete factories may keep driving verbs locked until the subsequent `agent/session-start` boundary; that event is the first supported place to inject or queue work during startup.
```ts cordis-catalog
'agent/created'(this: Scoped, agent: Agent): void
@@ -23,11 +23,11 @@ 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:287`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts)
### `agent/disposed` — emit
-An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down.
+An agent was removed from the registry after its driver and any in-flight turn reached quiescence. Ordered teardown may still be detaching the session and unwinding the agent's scoped registrations when this notification runs.
```ts cordis-catalog
'agent/disposed'(this: Scoped, agent: Agent): void
@@ -35,7 +35,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:299`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:312`](../../packages/core/agent/src/types.ts)
### `agent/error` — emit
@@ -47,7 +47,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:553`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:585`](../../packages/core/agent/src/types.ts)
### `agent/pre-step` — serial
@@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/types.ts:404`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:417`](../../packages/core/agent/src/types.ts)
### `agent/prompt-submit` — waterfall
@@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a
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:422`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:435`](../../packages/core/agent/src/types.ts)
### `agent/queued` — emit
@@ -85,7 +85,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:327`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts)
### `agent/request` — waterfall
@@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/types.ts:451`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts)
### `agent/session-prefix` — waterfall
@@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array —
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/types.ts:503`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:516`](../../packages/core/agent/src/types.ts)
### `agent/session-start` — emit
@@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source`
Types: [Agent](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit
@@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
Types: [Agent](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts)
### `agent/step-result` — waterfall
@@ -149,7 +149,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:518`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:531`](../../packages/core/agent/src/types.ts)
### `agent/turn-continuation` — waterfall
@@ -161,7 +161,19 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
Types: [Agent](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/types.ts:536`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:549`](../../packages/core/agent/src/types.ts)
+
+### `agent/turn-stop` — serial
+
+Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded. A listener returns `{ action: 'stop' }` to make this turn terminal, or `undefined` to abstain. Terminal stop is monotonic: listener order and steering cannot resume the turn, and pending steering is discarded rather than becoming another step or turn. A malformed non-undefined result fails the turn closed.
+
+```ts cordis-catalog
+'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): Promise | ContinuationStop | undefined
+```
+
+Types: [Agent](../core-data-structures/core.md)
+
+Source: [`packages/core/agent/src/types.ts:568`](../../packages/core/agent/src/types.ts)
## `fs/*`
@@ -256,10 +268,10 @@ Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/sr
A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run.
```ts cordis-catalog
-'subagent/end'(info: SubagentRunEndInfo): void
+'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void
```
-Source: [`packages/subagent/subagent/src/index.ts:107`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:109`](../../packages/subagent/subagent/src/index.ts)
### `subagent/provider-added` — emit
@@ -269,7 +281,7 @@ A provider became resolvable in the SubagentService registry. Consumers that der
'subagent/provider-added'(provider: SubagentProvider): void
```
-Source: [`packages/subagent/subagent/src/index.ts:73`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:75`](../../packages/subagent/subagent/src/index.ts)
### `subagent/provider-removed` — emit
@@ -279,17 +291,17 @@ A provider left the registry (its plugin's fiber was disposed — an unload or a
'subagent/provider-removed'(name: string): void
```
-Source: [`packages/subagent/subagent/src/index.ts:84`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts)
### `subagent/start` — emit
A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run.
```ts cordis-catalog
-'subagent/start'(info: SubagentRunInfo): void
+'subagent/start'(this: Scoped, info: SubagentRunInfo): void
```
-Source: [`packages/subagent/subagent/src/index.ts:96`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:98`](../../packages/subagent/subagent/src/index.ts)
## `system-prompt/*`
@@ -301,17 +313,17 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio
'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise
```
-Source: [`packages/core/system-prompt/src/index.ts:44`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:45`](../../packages/core/system-prompt/src/index.ts)
### `system-prompt/change` — emit
-A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's.
+A section, tool provider, variable provider, or protection was registered or unregistered (the assembly inputs changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's.
```ts cordis-catalog
'system-prompt/change'(): void
```
-Source: [`packages/core/system-prompt/src/index.ts:54`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:55`](../../packages/core/system-prompt/src/index.ts)
## `tools/*`
@@ -323,11 +335,11 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
'tools/change'(): void
```
-Source: [`packages/core/tools/src/index.ts:151`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:170`](../../packages/core/tools/src/index.ts)
### `tools/execute` — waterfall
-Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed arguments and re-invokes downstream with the shared payload, so a wrapper mutates `exec` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less).
+Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can set or replace the one mutable field, `exec.signal` (e.g. with a per-call deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the pipeline so a wrapper cannot change which capability or scope was authorized. (Cordis `next()` ignores passed arguments and re-invokes downstream with the shared payload, so a wrapper changes `exec.signal` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less).
```ts cordis-catalog
'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise
@@ -335,7 +347,7 @@ Around-dispatch waterfall wrapping the registry's core tool dispatch, between th
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
-Source: [`packages/core/tools/src/index.ts:121`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:125`](../../packages/core/tools/src/index.ts)
### `tools/post-execute` — waterfall
@@ -347,7 +359,7 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
-Source: [`packages/core/tools/src/index.ts:141`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:145`](../../packages/core/tools/src/index.ts)
### `tools/pre-execute` — waterfall
@@ -359,7 +371,19 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl
Types: [ToolExecution](../core-data-structures/tools.md)
-Source: [`packages/core/tools/src/index.ts:97`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:98`](../../packages/core/tools/src/index.ts)
+
+### `tools/result` — parallel
+
+Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization. Unlike the three waterfalls, this seam cannot transform the result: each listener receives the now-frozen execution object and a deep-frozen result snapshot; listener failures are contained and logged, and ToolRegistry.execute still returns the outcome. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`, using the same carrier as the pipeline.
+
+```ts cordis-catalog
+'tools/result'(this: Scoped, exec: Readonly, result: Readonly): Promise | void
+```
+
+Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
+
+Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts)
## `workflow/*`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 8f5f32a22b..6e9fcd8a42 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -17,28 +17,30 @@ The loop itself is deliberately thin — every behavior beyond "call the model,
```ts cordis-catalog
create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent
-createAgent(options: CreateAgentOptions): AgentHandle
+async createAgent(options: CreateAgentOptions): Promise
async resume(options: ResumeAgentOptions): Promise
```
-Source: [`packages/core/agent-loop/src/index.ts:70`](../../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:69`](../../packages/core/agent-loop/src/index.ts)
## `ctx.agents` — `AgentRegistry`
-Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via setFactory.
+Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory.
```ts cordis-catalog
setFactory(factory: AgentFactory): () => Promise | void
-create(options: CreateAgentOptions): AgentHandle
+async create(options: CreateAgentOptions): Promise
async resume(options: ResumeAgentOptions): Promise
register(agent: Agent): () => Promise | void
+enter(agent: Agent): () => void
+announce(agent: Agent): void
get(id: AgentId): Agent | undefined
list(): Agent[]
```
Types: [Agent](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/index.ts:145`](../../packages/core/agent/src/index.ts)
+Source: [`packages/core/agent/src/index.ts:168`](../../packages/core/agent/src/index.ts)
## `ctx.bash` — `BashExecutor` (abstract seam)
@@ -199,40 +201,42 @@ list(): string[]
start(name: string, request: SubagentStartRequest): SubagentRun
```
-Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts)
## `ctx.systemPrompt` — `SystemPrompt`
-Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona).
+Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative contribution protections; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona).
```ts cordis-catalog
section(section: PromptSection): () => Promise | void
tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void
+protect(protection: PromptProtection): () => Promise | void
async assemble(context: AssembleContext = {}): Promise
```
-Source: [`packages/core/system-prompt/src/index.ts:335`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:379`](../../packages/core/system-prompt/src/index.ts)
## `ctx.tools` — `ToolRegistry`
-Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline. The registry contributes its schemas into the system-prompt assembly — WHICH schemas is governed by its `mode` config (see Config.mode); under a non-native mode it also registers the `run_code` tool and the `tools:sdk` prompt section itself.
+Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The registry contributes its schemas into the system-prompt assembly — WHICH schemas is governed by its `mode` config (see Config.mode); under a non-native mode it also owns the reserved `run_code` presentation transport and the `tools:sdk` prompt section.
Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One visibility function (visible) feeds prompt assembly, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so what the model is shown, what a presenter renders, what a program can call, and what dispatches can never disagree.
```ts cordis-catalog
register(definition: ToolDefinition): () => Promise | void
restrict(filter: ToolRestriction): () => Promise | void
+guard(guard: ToolGuard): () => Promise | void
visible(scope?: ScopeKey): ToolDefinition[]
get(name: string, scope?: ScopeKey): ToolDefinition | undefined
schemas(scope?: ScopeKey): ToolSchema[]
knownNames(scope?: ScopeKey): string[]
-async execute(exec: ToolExecution): Promise
+async execute(exec: ToolExecutionInput): Promise
```
-Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
+Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
-Source: [`packages/core/tools/src/index.ts:392`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:474`](../../packages/core/tools/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`
diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md
index cdc928b2fc..ad1041a5ed 100644
--- a/docs/core-data-structures/core.md
+++ b/docs/core-data-structures/core.md
@@ -18,7 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam |
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant |
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
-| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline |
+| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline |
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
@@ -339,7 +339,7 @@ interface Agent {
}
```
-`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, the serial `agent/pre-step` surface-mutation seam, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits.
+`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits.
## Interception decisions
@@ -370,6 +370,12 @@ type ContinuationDecision =
| { action: 'continue'; reason?: HookContext }
```
+`agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering.
+
+```ts type-equiv
+type ContinuationStop = Extract
+```
+
`agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it):
```ts type-equiv
diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md
index 6f2e23e37e..66525592cf 100644
--- a/docs/core-data-structures/subagent.md
+++ b/docs/core-data-structures/subagent.md
@@ -38,7 +38,7 @@ interface SubagentStartRequest {
## The terminal result: `SubagentResult`
-The outcome of a run, resolved by `SubagentRun.result`. `structured` is present iff the request carried an `outputSchema` AND the provider honored it. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success.
+The outcome of a run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success.
```ts type-equiv
interface SubagentResult {
@@ -92,7 +92,7 @@ The service (`ctx.subagents`) emits `subagent/start` when a run begins and `suba
## 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:
+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 application. They synchronously snapshot caller-owned data, install provider ownership before attaching the abort listener, create one run-owner fiber under `parent.ctx`, and invoke the factory through that fiber: parent teardown, provider teardown, and manual run disposal share the same pre-publication ownership and quiescence boundary, while the child still receives a flat new scope rather than inheriting the parent's capabilities. 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).
diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md
index 96d3e79bdc..e62e11922f 100644
--- a/docs/core-data-structures/tools.md
+++ b/docs/core-data-structures/tools.md
@@ -1,6 +1,6 @@
# Tools
-The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the waterfall execution shapes, and the UI-presentation vocabulary.
+The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the guarded execution shapes, and the UI-presentation vocabulary.
Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts)
@@ -81,22 +81,51 @@ type InferArgs = Simplify<
`defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs`, and the helper converts the spec to JSON Schema (`schemaSpecToJsonSchema`) for the wire and validates model-generated args (`validateArgs`) before the typed body runs. A mismatch throws `ToolArgsError` (`code: 'INVALID_ARGS'`), which the registry turns into an `isError` result so the model can self-correct. Why a custom DSL and not schemastery: tool parameters need JSON Schema (the LLM wire format), not validation/transformation — the lightweight DSL gives the best authoring DX with the smallest surface.
-## Execution: the `tools/pre-execute` / `tools/post-execute` pipeline shapes
+Registration is a value boundary. `ToolRegistry.register()` validates `ToolDefinition.parameters` as lossless JSON before and after cloning, copies the scalar fields, binds the execute/presentation callbacks once to the original definition as their method receiver, and deep-freezes the stored record. Replacing a callback property on the caller-owned definition later does not change dispatch. `get()`/`visible()` expose only that frozen snapshot, while `schemas()` produces detached projections, so the model-visible and executable views cannot drift through a leaked mutable registry object.
-`ctx.tools.execute()` runs each call through a two-waterfall pipeline — `tools/pre-execute` (the allow/deny/ask gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context) — the seams where sandbox, permission, hook, and plan-mode plugins gate or transform a call. The pending call is a `ToolExecution`; the outcome is a `ToolExecutionResult`.
+## Execution: extensible waterfalls plus monotonic policy
+
+`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput`, snapshots it into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). The outcome is a `ToolExecutionResult`.
```ts type-equiv
-interface ToolExecution {
- callId: CallId
- name: string
+interface ToolExecutionToken {
+ readonly [toolExecutionTokenBrand]: true
+}
+```
+
+```ts type-equiv
+interface ToolExecutionInput {
+ readonly callId: CallId
+ readonly name: string
/** Parsed JSON arguments (unknown — tools validate their own input). */
- arguments: unknown
+ readonly arguments: unknown
/** The agent on whose behalf the call runs (set by the agent loop). */
- agent?: Agent
+ readonly agent?: Agent
+ /**
+ * Opaque token of the enclosing transport execution, when one exists. Code
+ * Mode sets this on SDK sub-dispatches so commit-style observers can wait for
+ * the outer `run_code` outcome without receiving its live mutable execution.
+ */
+ readonly parent?: ToolExecutionToken
signal?: AbortSignal
}
```
+```ts type-equiv
+interface ToolExecution extends ToolExecutionInput {
+ /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
+ readonly token: ToolExecutionToken
+}
+```
+
+`ToolExecutionToken` is a compile-time opaque type and a frozen, property-free object at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` requires the caller's `arguments` to be losslessly JSON-serializable, checks again after cloning to contain unstable accessors, assigns a fresh token, and deep-freezes the detached arguments. A cloneable mutable exotic such as `Map` is rejected and normalized to an error before policy. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are non-writable throughout all waterfalls, so a listener cannot change which capability or scope was authorized or reach a live enclosing execution; an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers, where the execution remains usable as a `WeakMap` key without mutation races.
+
+A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it.
+
+```ts type-equiv
+type ToolGuard = (execution: Readonly) => string | undefined
+```
+
```ts type-equiv
interface ToolExecutionResult {
callId: CallId
@@ -129,7 +158,9 @@ interface ToolExecutionResult {
}
```
-Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`:
+The registry rebuilds and validates the complete authoritative result after post-policy. Its content, structured error, additional context, and presentation metadata must round-trip losslessly through JSON; a malformed or non-JSON value becomes a JSON-safe `isError` result before `tools/result` observers run, so the live outcome is always safe for the later durable `tool/result` append.
+
+Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`:
```ts type-equiv
type PreToolDecision =
@@ -144,7 +175,7 @@ type PostToolDecision =
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
```
-Call `next()` to delegate to the default (allow / accept-unchanged), or return a decision to short-circuit. A `pre-execute` `deny` (or `ask`, which degrades to deny until the permission system lands) skips dispatch and yields an `isError` result; input rewrite is deliberately NOT offered on `PreToolDecision` (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC). A `post-execute` `accept` may replace the model-facing `content` (clean, because `tool/result` is logged after `execute()` returns); a `block` turns the call into an `isError` whose content is the corrective `feedback`. Core dispatch sits between the waterfalls as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn.
+Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` (or `ask`, which degrades to deny until the permission system lands) skips dispatch and yields an `isError` result; a registered `ToolGuard` runs after that waterfall and can impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The awaited `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn.
## The structured-output schema subset
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index e004aae912..12caa6aa6f 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -7,18 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
-| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:287`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:553`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
-| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:404`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
-| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:422`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
-| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:503`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:347`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) |
-| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
-| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:518`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:536`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:585`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
+| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:417`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
+| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:435`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
+| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
+| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:516`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) |
+| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:531`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
+| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:549`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:568`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
@@ -26,16 +27,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:107`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:73`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:84`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:96`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
-| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
-| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:54`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
-| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:151`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
-| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:121`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
-| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:141`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:109`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:75`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
+| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:45`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
+| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:55`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
+| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:170`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
+| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
+| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:145`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `tools/result` | `parallel` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:96`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:106`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 917386af9b..aba91c76aa 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -219,7 +219,7 @@ One bridged sub-dispatch from a `run_code` program: the parent `run_code` call i
Types: [CallId](core-data-structures/core.md)
-Source: [`packages/core/tools/src/code-mode.ts:36`](../packages/core/tools/src/code-mode.ts)
+Source: [`packages/core/tools/src/code-mode.ts:38`](../packages/core/tools/src/code-mode.ts)
#### `tool/result` — surface
diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md
index 501f8c7331..80b9b86e2a 100644
--- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md
+++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md
@@ -10,9 +10,10 @@ The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic
Pure Cordis event taxonomy. The loop's extension seams are typed events with deliberate dispatch modes:
-- **waterfall** (around-middleware) where plugins mutate or veto: `agent/prompt-submit`, `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`.
-- **emit** (sync fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors.
-- **parallel** (awaited) for the one durability checkpoint: `session/flush`.
+- **waterfall** (around-middleware) where plugins transform, veto, or wrap: `agent/prompt-submit`, `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`.
+- **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final.
+- **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint and the immutable observe-only `tools/result` notification.
+- **emit** (synchronous fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, and errors.
The event vocabulary lives in interface packages (dsh-agent declares the agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete loop plugin and is itself swappable — nothing outside it may depend on it.
diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md
index e786adbfe8..2b1c198812 100644
--- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md
+++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md
@@ -33,11 +33,9 @@ These invariants hold and are pinned by tests:
- A `tool-bash` HMR reload does NOT make an existing background task readable or killable by a different session (ownership survives on the executor).
- Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the `AgentLoop` plugin fiber.
-## Seam precondition (recorded)
+## Session owner tokens are unique among live agents
-The bash owner-token comparison relies on `session.header.id` being unique among live agents. The agent registry does NOT enforce this — it rejects a duplicate *agentId*, not a duplicate session id, and `createAgent` accepts an arbitrary `sessionId`. This is NOT reachable via ACP (UUID sessionId, `agentId === sessionId`, duplicate-load rejected), so it is not a live product hole, but a programmatic caller that registers two agents with the same session id would break bash isolation and mis-route the completion notice. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/impl/consumer split.
-
-The planned resolution is to remove the precondition by construction — see [unify the agent id and the session id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md): once an agent IS its session (one id), the registry's existing unique-`agentId` check is a unique-session-id guarantee and no two live agents can share a session token.
+The bash owner-token comparison relies on `session.header.id` being unique among live agents. `SessionStore.enter()` rejects a duplicate live session id, and the async agent factory reserves both agent and session ids across persistence loading and unpublished setup before rechecking the store at publication. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split.
## Alternatives considered
diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md
index 8d7a5ab251..f5a4cb4b24 100644
--- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md
+++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md
@@ -4,308 +4,702 @@ Status: implemented
## Problem
-The harness runs multiple agents inside one application, but those agents need different capabilities and policies. A child created to summarize a file may need a different persona, a smaller tool set, and listeners that govern only its work; applying those contributions to every agent would leak authority and couple otherwise independent runs.
+One application can run many agents that share infrastructure but must not share every capability or policy. A child agent may need a different persona, fewer tools, its own structured-result schema, and listeners that govern only its work, while still using the deployment's model adapters, persistence backend, tool implementations, and user interface.
-This is not the same problem as running several isolated applications. The agents intentionally share the deployment's model adapters, persistence backend, tool implementations, and other services. What varies is the view assembled for one agent and the policy attached to its activity.
+This is a composition problem, not an application-isolation problem. Starting a separate service graph for every child would isolate too much; putting every registration in one global graph isolates too little.
-The affected extension surfaces include both data and behavior:
-
-| Surface | Per-agent need | Failure when global |
+| Surface | What varies by agent | Failure when it is only global |
|---|---|---|
-| Tools | Hide dangerous or irrelevant tools; add a child-only result tool; replace one implementation for one agent | The model sees excess authority, or a child-specific tool leaks into every prompt |
-| Prompt sections and variables | Give a child its own persona or runtime facts | Every agent receives the same instructions or values |
-| Event listeners | Apply a hook, guard, or continuation policy to one agent | A listener written for one agent can veto or mutate another agent's work |
-| Lifetime | Remove all of the above when the agent ends | Manual cleanup misses failure, cancellation, hot-reload, or owner-teardown paths |
+| Tools | Available capabilities, a child-only tool, or a scoped replacement for one implementation | The model receives excess authority, or a child-specific tool leaks into every prompt |
+| Prompt state | Persona, instructions, variables, and Code Mode SDK declarations | Every agent receives the same instructions or runtime facts |
+| Live policy | Hooks, execution guards, result observers, and continuation rules | A listener intended for one agent can alter another agent's work |
+| Lifetime | Cleanup when the agent fails, is cancelled, is disposed, or loses its owner | Registrations outlive the agent or disappear before its final work settles |
-The model-visible and executable views must also agree. Hiding a tool only from the prompt is not a security boundary if a generated call can still execute it; hiding it only from execution produces a prompt that advertises unusable capabilities. The same consistency requirement extends to Code Mode bindings and user-interface presentation.
+Two consistency requirements make the problem deeper than filtering a list. First, the model-visible and executable views must agree: a hidden tool must not remain callable, and an advertised tool must not fail merely because execution used a different registry view. This agreement must also cover Code Mode bindings and UI presentation.
-The subagent API exposes the practical gap. A provider can accept a child persona, a tool filter, and a structured-output schema, but those options are honest only if two concurrent children can receive different registrations without mutating shared global state.
+Second, some rules are invariants rather than cooperative extensions. An ordinary middleware listener may replace a prompt assembly, turn an allow into a deny, rewrite a result, force another model step, or short-circuit listeners registered after it. Structured output therefore cannot rely on being “first” or “last” in an extensible listener chain; the owning service needs a final boundary for rules that later listeners must not undo.
+
+The subagent API makes both needs concrete. Two concurrent children can request different personas, tool filters, and output schemas. Those requests are honest only when each child receives an independently owned view and when its terminal-output protocol survives unrelated plugins.
## Decision
-Each live agent owns a registration context named `agent.ctx`. Registering through the application's ordinary plugin context contributes globally; registering through `agent.ctx` contributes to that agent alone and ties the contribution's lifetime to the agent.
+Each live agent owns a registration context named `agent.ctx`, and services expose narrow owner-final policy boundaries where ordinary middleware ordering is not strong enough. Together these choices make one agent's world composable with normal plugin APIs while keeping authority, observation, and cleanup aligned.
-The rule is intentionally small enough to be the normal plugin-author mental model:
+The design has three parts:
-| Registration context | Visibility | Lifetime owner |
+| Part | Rule | Purpose |
|---|---|---|
-| Ordinary plugin context | Every agent | The registering plugin |
-| `agent.ctx` | Exactly that agent | That agent |
+| Registration scope | A registration through a plain plugin context is global; the same registration through `agent.ctx` belongs to that agent | Reuse existing APIs for per-agent tools, prompt state, and listeners |
+| Lifecycle transaction | Create and resume await scoped setup while the agent and session are unpublished, then publish them in an ordered rollback-covered sequence | No observer sees a partially composed agent, and every failure path owns cleanup |
+| Owner-final policy | Prompt protection, tool guards, final tool-result observation, and terminal turn stopping run at service-owned boundaries | Invariants do not depend on listener registration order |
-The scope is flat. A child does not inherit registrations from its parent's scope; parent/child lineage remains explicit session data. A child sees the deployment-global layer plus its own layer, which prevents accidental authority inheritance through an agent tree.
+The scope is flat. An agent resolves the deployment-global layer plus its own layer; a child does not inherit registrations from its parent's scope. Parent/child lineage remains explicit session data, and parent-owned disposal links lifetimes without silently inheriting authority.
-This decision is implemented by the [`dsh-scope` primitive](../../../../packages/core/scope/README.md), scope-aware tool and prompt registries, scope-filtered event dispatch, and an agent lifecycle that creates and destroys the entire scoped world as one ordered operation.
+The implementation lives primarily in [`dsh-scope`](../../../../packages/core/scope/README.md), [`dsh-agent`](../../../../packages/core/agent/README.md), [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md), and [`dsh-tools`](../../../../packages/core/tools/README.md). The [generated Cordis event catalog](../../../cordis-catalog/events.md) is the exhaustive event-signature reference; this RFC explains why the contracts have their current shape.
-## Background: the Cordis concepts used by the design
+## Background: the small Cordis vocabulary used here
-The implementation reuses four Cordis mechanisms. Readers do not need Cordis internals beyond this section; the [Cordis primer](../../../cordis-primer.md) is the broader reference.
+The design relies on four framework ideas: contexts, effects, waterfall events, and dispatch receivers. This section gives the complete mental model needed for the rest of the RFC; the [Cordis primer](../../../cordis-primer.md) covers the framework more broadly.
-### Contexts provide services
+### A context is both a service view and a registration origin
-A Cordis context is the object through which a plugin reaches shared services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service call retains the context used to access it, so a registry can tell whether a registration came through an ordinary plugin context or through an agent's context without adding a scope argument to every method.
+A Cordis `Context` is the object through which a plugin reaches services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service method can recover the context through which it was accessed, so the service can tell whether a call came from an ordinary plugin context or from an agent's scoped context without adding a `scope` parameter to every registration API.
-Contexts also represent a capability view. A derived context can reach only the services made available by the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface, a deliberate part of the `Agent.ctx` contract rather than an ambient root context.
+A context also carries a capability view. A derived context reaches the services injected into the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface; it is not an ambient root context.
-### Effects own registrations
+### Effects give registrations an owner
-Registrations are Cordis effects: adding a tool, prompt section, or listener returns cleanup behavior owned by the context's runtime unit, called a fiber. Disposing a fiber unwinds all effects registered through it, which is the basis for hot reload and reliable cleanup.
+A Cordis effect is work whose cleanup belongs to a runtime unit called a fiber. Tool registration, prompt contribution, and event subscription are effects, so disposing their fiber unwinds them on normal teardown, failure, or hot reload.
-`dsh-scope` creates a no-op plugin fiber for each scope. The fiber contributes no behavior of its own; it exists to provide one lifetime bucket for everything registered through the scoped context.
+`dsh-scope` mounts a no-op plugin fiber for each scope. The plugin contributes no behavior; its fiber is the ownership bucket for everything registered through the scoped context.
-### Event dispatch can filter listeners
+### A waterfall is ordered around-middleware
-Cordis decides which listeners receive an event by inspecting the object used as the dispatch receiver, known as the event's `this` value. `dsh-scope` supplies a receiver with a filter: unscoped listeners are admitted for compatibility, while a scoped listener is admitted only when its key matches the event's subject.
+A Cordis waterfall is an extensible middleware chain. A listener calls `next()` to delegate, can inspect or replace the downstream result, and can return without calling `next()` to short-circuit everything inside it.
-### Disposal order requires explicit nesting
+This flexibility is useful for cooperative transformations, but registration order is not an invariant boundary. A later plugin can prepend another listener, a wrapper can replace the downstream result after `next()` returns, and a short-circuit can prevent inner listeners from running at all.
-Cordis may dispose sibling effects concurrently. When order matters, a generator effect yields the exact disposer functions to create a nested last-in-first-out chain; wrapping a disposer in another function breaks the identity Cordis uses to remove it from the concurrent sibling set.
+### The dispatch receiver selects scoped listeners
-This detail drives both `Scope.rawDispose` and the convention that registry `register` methods return their exact Cordis disposer. It is what lets agent teardown wait for the loop, then unregister the agent, then detach the session, rather than racing those operations.
+Cordis filters event listeners using the dispatch receiver, the object exposed as `this` inside a function-style listener. `dsh-scope` supplies a receiver carrying the operation's scope key, so the event system can admit global listeners plus listeners registered for that key and reject listeners belonging to other agents.
-## Scope model
+This receiver is live coordination state, not a durable session fact. The distinction matters later: `tools/result` is a live final-outcome notification, while the similarly named `tool/result` is an append-only session event stored for replay and model history.
-The scope primitive joins visibility and ownership while remaining independent of the agent packages. This lets lower-level packages such as sessions and prompt assembly participate without depending upward on the agent loop.
+## Agent-scoped registrations
-### One registration fact controls two properties
+An agent scope couples two facts that must not drift apart: who can see a registration and who disposes it. The calling context determines both facts, leaving the domain-specific merge rules to each registry.
-The decisive fact is which context performed a registration. The scope tag selects its visibility layer, and the same context's fiber owns its disposal.
+### The resolution model is global plus exactly one scope
-Keeping those properties coupled prevents a dangerous state such as “visible to agent A but disposed with unrelated plugin B.” An API shaped as `register(value, { agent })` would make that state expressible and would retain global registration as the easy-to-forget default.
+Every scope-aware registry keeps a global layer and per-scope layers. Resolving for agent A combines the global layer with A's layer only; it does not walk A's parent lineage or combine sibling scopes.
-### Scope keys are opaque identities
+| Registration origin | Visible to | Disposed with |
+|---|---|---|
+| Plain plugin context | Every agent | The registering plugin |
+| `agent.ctx` | That agent only | That agent's scope |
-A scope key is an object compared by identity, not a string or database identifier. The harness uses the live `Agent` object as its own key, so event payloads and execution records that already carry an agent can select the right scope without translating through another registry.
+Named scoped contributions shadow a same-named global contribution. A child persona is therefore a scoped `deployment:persona` section, and a per-agent tool implementation can keep the same model-facing name. Duplicate names within one layer still fail loudly. The deliberate exception is a globally protected prompt-section name, whose owner reserves it against scoped shadowing.
-Object identity decouples a live scope from externally meaningful or sequentially reused string IDs. The key is meaningful only during the live agent's lifetime.
+The plugin-facing mechanism is the same API called through a different context. In language-neutral pseudocode:
-### The primitive has four responsibilities
+```text
+# Deployment-wide contribution
+appContext.tools.register(readTool)
-The public API is small; the package README carries the exact signatures.
+# Contribution visible only to agent A and disposed with A
+agentA.ctx.tools.register(childOnlyTool)
-| Responsibility | Mechanism |
+resolveTools(agent A):
+ visible = copy(globalTools allowed by A's restrictions)
+ visible.overlay(tools registered through A.ctx)
+ visible.append(reserved presentation transport, when configured)
+ return visible
+```
+
+There is no `for each ancestor` step. Resolving for A never reads the parent or sibling layers.
+
+The scope key is an opaque object compared by identity. The harness uses the live `Agent` object as its own key, so event payloads, tool executions, and prompt assemblies that already carry the agent can select the correct layer without translating through a string ID that may later be reused.
+
+### `agent.ctx.agent` is an association, not the scope resolver
+
+`agent.ctx` carries an own `agent` property for setup code and plugin ergonomics. Contexts derived from it inherit that association, while a plain context reads `undefined`.
+
+The property is deliberately not treated as the authoritative scope tag. A nested scope can install a nearer scope key while still inheriting the original `ctx.agent` association, so lower-level services resolve layers with `scopeOf(context)`. In normal agent composition the two point at the same live agent; the separation keeps the generic scope primitive independent of the agent package.
+
+### The scope primitive has separate public and composite disposal forms
+
+`dsh-scope` exposes the minimum operations needed to create a layer, read it, target events, and dispose it. “Quiescent” here means that every asynchronous cleanup registered in the scope has settled and no teardown work remains in flight.
+
+| Operation | Responsibility |
|---|---|
-| Create an ownership bucket | `createScope(context, key)` mounts the no-op fiber and returns its derived context |
-| Read the registration layer | `scopeOf(context)` reads the nearest inherited scope tag |
-| Filter event delivery | `scopeTarget(subject, key)` creates the dispatch carrier |
-| Tear down in a larger ordered lifecycle | `Scope.rawDispose` exposes the exact fiber disposer |
+| `createScope(context, key)` | Mount the ownership fiber and return its tagged derived context |
+| `scopeOf(context)` | Read the nearest inherited scope key |
+| `scopeTarget(subject, key)` | Build the receiver used for scope-filtered dispatch |
+| `Scope.dispose()` | Give ordinary callers an idempotent promise shared by repeat and racing calls until quiescence |
+| `Scope.rawDispose` | Expose the exact Cordis disposer so a larger generator lifecycle can nest it at a precise teardown position |
-Derived contexts inherit the tag, and a nested scope replaces it with the nearer key. This allows `agent.ctx.plugin(...)` to build a reusable profile whose registrations remain scoped to the same agent.
+The two disposal forms solve different framework constraints. Cordis identifies nested effects by disposer-function identity, so an ordered composite lifecycle must yield `rawDispose` exactly. Cordis disposers are also single-shot, so a second raw call may not await the first asynchronous teardown; `Scope.dispose()` follows the backing fiber's in-flight lifecycle and gives all ordinary callers the same quiescence boundary, including a race in which `rawDispose` started first. The test/tooling `ScopeHost.dispose()` extends that shared boundary across its host fiber and every minted child scope.
-### Scoping is explicit at each seam
+The primitive itself is small. Its essential implementation shape is:
-The scoped context automatically supplies effect ownership, but it does not magically change every service operation. A scope-aware registry must read `scopeOf()` when registering, and a scope-filtered event dispatcher must provide a carrier naming the operation's subject.
+```text
+createScope(parentContext, key):
+ fiber = mount no-op plugin under parentContext
+ scopedContext = derive fiber.context with nearest-scope-tag = key
-Read and execution operations select their subject explicitly: callers use `tools.schemas(agent)`, `tools.get(name, agent)`, `tools.execute({ agent, ... })`, or `systemPrompt.assemble({ scope: agent, agent })`. Calling `agent.ctx.systemPrompt.assemble()` with an empty assembly context still asks for the global layer. This separation lets a shared service operate on behalf of any agent while making the subject visible at the call site.
+ rawDispose = fiber's exact disposer
+ dispose = memoized operation that:
+ invoke rawDispose if it has not started
+ follow fiber's in-flight teardown until quiescent
-## Registration resolution
+ return { ctx: scopedContext, rawDispose, dispose }
+```
-The tool and system-prompt services each keep a global layer plus a map of per-scope layers. Resolution combines only the global layer and the requesting agent's own layer.
+Derived contexts inherit the nearest scope tag. Mounting an ordinary plugin under `agent.ctx` therefore preserves the agent's scope, while deliberately creating another scope replaces the tag for registrations below it.
-### Scoped names shadow global names
+### Registry resolution stays domain-specific
-For named contributions, the scoped definition wins over a global definition with the same name. Duplicates inside one layer still fail loudly.
+The shared primitive answers “which layer?” and “who owns cleanup?” but does not force every service to merge data the same way. Tools, prompt sections, variables, and tool-schema providers retain rules appropriate to their domains.
-Shadowing is what makes a child persona ordinary configuration: the system-prompt service owns a global `deployment:persona`, and a child registers another section with that name through its context. The same rule supports a per-agent implementation of a model-facing tool without renaming the tool.
+Prompt sections, prompt variables, and tools use scoped-over-global shadowing by name. Tool-schema providers are additive, but a provider registered through `agent.ctx` participates only in that agent's assemblies. Read operations name the subject explicitly: tool lookup and execution receive an agent or scope, and prompt assembly receives an `AssembleContext` whose `scope` selects the layer.
-Tool-schema providers are additive rather than named, but a provider registered through `agent.ctx` is consulted only for that agent's assemblies. Prompt variables use the same named-shadowing rule as sections and tools.
+Calling a service through `agent.ctx` does not implicitly make every later read agent-scoped. For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope still requests the global layer. This keeps shared services able to operate on behalf of any subject and makes the subject visible at the read or execution call site.
-### Restrictions mask global tools; scoped tools are explicit grants
+### Tool registrations are frozen snapshots
-`agent.ctx.tools.restrict({ allow, deny })` filters the global tool layer for that agent. `allow` keeps only listed names, `deny` removes listed names, and multiple restrictions intersect so independently installed policies can only reduce the global surface.
+The tool view must not change because a caller kept the object it passed to `register()` or received a definition from `get()` or `visible()`. Registration therefore creates the stored identity once; future changes happen through explicit unregister/register effects.
-Restrictions do not remove tools registered in the agent's own layer. A scoped tool is an explicit grant, which is necessary for facilities such as a child's `structured_output` capture tool to remain available under a restrictive allow-list.
+Tool parameters cross the model and log boundary, so the registry requires them to be lossless JSON before cloning and validates the clone again to contain unstable getters. It snapshots the scalar fields, binds each callback once to the original definition as its method receiver, and deep-freezes the stored record. Replacing `definition.execute` after registration therefore has no effect, while a callback can still deliberately read mutable state from its closure or original receiver. `get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached schema projections.
-The restriction snapshots its arrays when registered, validates every named tool against the current pre-restriction universe, and rejects an empty filter. These choices make configuration mistakes loud and prevent later caller mutation from changing a live policy.
+```text
+registerTool(context, definition):
+ require definition.parameters is lossless JSON
+ parameters = clone(definition.parameters)
+ require parameters is still lossless JSON
-An out-of-view tool resolves exactly like an unregistered tool and returns `UNKNOWN_TOOL` if called. This avoids exposing whether a hidden global implementation exists.
+ stored = deepFreeze({
+ copied name, description, timeout,
+ parameters,
+ execute: bind definition.execute to definition,
+ presentation callbacks: bind once when present
+ })
-### One visibility function feeds every consumer
+ layerFor(scopeOf(context)).add(stored.name, stored)
+```
-The tool registry defines one canonical `visible()` rule and every consumer uses it for prompt schemas, lookup, execution, Code Mode's generated SDK and bindings, timeout-policy lookup, Cordis inspection, and ACP presentation. A model cannot be shown one definition while execution or the UI resolves another.
+The reserved Code Mode transport uses the same frozen-definition contract even though it lives outside the ordinary layers.
-Code Mode introduces one intentional distinction. A restriction is per-agent runtime state, so a globally configured `toolOrder` may name a restricted-away tool and simply leave an empty position for that agent. By contrast, `mode: 'code'` is deployment configuration that deliberately collapses the wire-visible universe to `run_code`; a `toolOrder` that still names native tools is invalid configuration and fails every assembly.
+### Tool restrictions reduce end capabilities without removing transport
-To preserve this distinction, a prompt tool provider returns both the post-restriction schemas and the pre-restriction `knownNames` universe. Ordering validates names against the latter but orders only the former.
+A tool restriction masks the global end-capability layer for one agent, while tools registered in that agent's own layer are explicit grants. Multiple restrictions intersect, so separately installed policies can only reduce the global surface.
+
+The restriction snapshots its input, rejects an empty filter, and validates named tools against the pre-restriction capability universe. A restricted-away tool behaves like an unknown tool at execution, avoiding disclosure of a hidden global implementation.
+
+[Code Mode](../feature/2026-06-15-code-mode.md)'s `run_code` is not an end capability. It is a reserved presentation transport that carries calls to the visible end capabilities, so the registry keeps it outside both global and scoped registration layers: restrictions cannot remove it, a scoped tool cannot shadow it, and configuration cannot explicitly allow or deny it. Without this exception, a restriction could leave the generated SDK in the prompt but remove the only way to invoke it.
+
+The registry still uses one executable visibility view. It first resolves restricted global capabilities plus scoped grants, then appends the reserved transport in non-native modes; registry-owned prompt schemas, lookup, execution, Code Mode SDK bindings, timeout lookup, inspection, and UI presentation all consume that view.
+
+The guarantee covers the tool registry's contribution. A plugin can deliberately use the lower-level `systemPrompt.tools()` API or assembly waterfall to add an unrelated wire schema; that plugin owns the matching executable behavior and any ordering it introduces. Owner protection preserves reserved named infrastructure without turning the system-prompt service into a validator for unrelated contributions.
+
+`knownNames` serves a narrower configuration purpose: it is the pre-restriction end-capability universe used to distinguish a typo from a deliberately hidden tool. The system-prompt provider adds presentation names when validating `toolOrder`: `code` mode accepts only `run_code`, `both` accepts end capabilities plus `run_code`, and a per-agent restriction may remove a known capability from one assembly without turning the deployment's order configuration into an error.
## Scoped event delivery
-Registrations alone are insufficient: a listener installed for one agent must hear only events about that agent. Scoped dispatch applies that rule while preserving the existing behavior of global plugins.
+Scoped registration is incomplete unless behavior follows the same boundary. An event about agent A reaches global listeners and A-scoped listeners, never listeners installed for B.
-### Delivery is global-plus-matching-scope
+### Delivery is global plus the matching scope
-For an event about agent A, the dispatch carrier admits ordinary unscoped listeners and listeners registered through A's context. It rejects listeners registered through every other agent context.
+The dispatch receiver carries the operation's scope key. Its filter admits an unscoped listener or a listener registered through the matching scoped context, while a subject-less dispatch admits unscoped listeners only. Cordis's explicit `{ global: true }` listener option remains the intentional bypass for infrastructure that must observe every dispatch.
-A subject-less dispatch, such as an agent-less tool execution or a bare session created outside an agent scope, admits only unscoped listeners. Cordis's explicit `{ global: true }` listener option still bypasses filtering for infrastructure that intentionally observes everything.
+Registry-membership notifications remain unfiltered. Events such as `tools/change`, `system-prompt/change`, and `subagent/provider-*` describe shared registry state rather than one agent's activity, so a scoped subscriber still observes those global changes.
-Events about registry membership stay unfiltered. A notification that a tool or provider was added concerns shared registry state rather than one agent's activity, so scoped subscribers to `tools/change`, `system-prompt/change`, or `subagent/provider-*` still hear the global notification.
+### Each event family derives its key from its real subject
-### Each event family has one scope source
+The operation being described determines the key; callers cannot attach an unrelated scope. Fused helpers and store-owned carriers keep the payload subject and delivery subject together.
-The event subject determines the key; callers do not choose an unrelated scope.
-
-| Event family | Scope key |
+| Event family | Scope source |
|---|---|
-| `agent/*` | The event's agent |
-| `tools/pre-execute`, `tools/execute`, `tools/post-execute` | `execution.agent`, or no key for an agent-less call |
-| `system-prompt/assemble` | The assembly context's scope |
+| `agent/*`, including `agent/turn-stop` | The event's agent |
+| `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `tools/result` | `ToolExecution.agent`, or no key for an agent-less call |
+| `system-prompt/assemble` | `AssembleContext.scope` |
| `session/created`, `session/event`, `session/flush` | The owner scope captured when the session enters the store |
| `subagent/start`, `subagent/end` | The delegating parent agent |
-Agent events use `agentEvents(context, agent)`, which creates the carrier and injects the same agent as the first event argument in one operation. Prompt assembly similarly uses `assembleContextFor(agent)` to set both the human-friendly `agent` field and the scope selector. These fused helpers make a mismatched carrier and subject difficult to express.
+The dispatch rule can be read independently of Cordis internals:
-The session store captures its carrier when a session is entered because later appends and flushes may originate from code that no longer has the agent's context in hand. `ctx.sessions.flush(session)` is the only durability-checkpoint entry point, so callers cannot forget the captured carrier.
+```text
+dispatchScoped(subject, scopeKey, event, arguments):
+ carrier = proxy(subject, tag = scopeKey)
-### The carrier is method-transparent
+ for listener in listeners(event):
+ if listener has no scope tag or requests the explicit global bypass:
+ call listener with this = carrier
+ else if listener.scopeTag == scopeKey:
+ call listener with this = carrier
+ else:
+ skip listener
+```
-Cordis passes the dispatch carrier to a function-style listener as `this`. Agent event declarations allow a listener to call methods such as `this.send(...)`, so the carrier must behave like the real subject rather than merely look like it.
+The real helpers fuse values that must agree: `agentEvents(context, agent)` supplies the same agent as subject, scope key, and first event argument; session storage captures its carrier once at `enter()`.
-The carrier is a JavaScript proxy whose property reads use the real subject as receiver and whose methods are bound to that subject. This matters for classes with native private fields: calling a method with the proxy itself as receiver would throw because the proxy does not possess the class's private-field identity.
+`agentEvents(context, agent)` creates the carrier and injects that same agent as the first event argument. `assembleContextFor(agent)` similarly sets both the agent-facing field and the scope selector. The session store captures its carrier when a session enters because later appends and flushes may occur where the original agent context is no longer available.
-The proxy preserves the subject's own event filter, writes through to the subject, returns the real constructor, and obeys JavaScript's invariants for frozen own properties. Its object identity is intentionally not transparent; the actual subject is also present in event arguments whenever identity matters.
+### The carrier behaves like the subject but has distinct identity
-`Scoped` is a TypeScript-only marker requiring a carrier at scoped dispatch sites. It adds no runtime behavior; runtime carrier marks and development invariants provide the corresponding check for JavaScript and casted code.
+Function-style listeners receive the carrier as `this`, and agent event APIs allow them to call subject methods. The carrier is therefore a JavaScript proxy that reads and writes through to the real subject and binds methods to it.
+
+Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The proxy preserves the subject's existing event filter and JavaScript object invariants, but it is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters.
+
+`Scoped` is a TypeScript-only marker that requires this carrier at declared scoped dispatch sites. It improves authoring but adds no runtime security, so runtime marks and development invariants check the same contract for JavaScript, casts, and hand-written dispatches.
## Agent creation and teardown
-An agent's scope, session, registry entry, and driver loop form one lifecycle. Creating them inside one composite effect gives both rollback on partial construction and deterministic teardown on every ownership path.
+An agent's scope, session, registry entry, and driver form one owned transaction. Setup finishes before publication, publication is synchronous and rollback-covered rather than magically atomic, and teardown reaches one ordered quiescent boundary.
-### Creation has a deliberate composition window
+### Create and resume reserve identities before asynchronous work
-Agent creation proceeds in this order:
+Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup.
-1. Construct the live agent object.
-2. Mint its scope and assign `agent.ctx`.
-3. Enter the session through `agent.ctx`, capturing the session's carrier.
-4. Announce `session/created`.
-5. Register the agent, which announces `agent/created`, so setup code can resolve it.
-6. Run `CreateAgentOptions.setup(agentCtx)` to compose scoped tools, prompt contributions, restrictions, listeners, or child plugins.
-7. Emit `agent/session-start`.
-8. Start the driver loop.
+The factory captures IDs and the setup callback and clones caller-owned agent options, session metadata, and seed events before the first asynchronous boundary. Resume does the same before persistence loading. A caller mutating its options object later therefore cannot move the transaction away from the identities it reserved or change the configuration eventually published.
-The setup callback runs inside the storage rollback boundary and before the first prompt assembly. A synchronous throw removes the agent and session and unwinds every scoped registration, so no half-created entry keeps either ID occupied.
+Reservations prevent two concurrent transactions from composing different unpublished agents under the same public identity. They remain held across persistence loading and setup and are released on every success or failure path.
-The two creation notifications occur before setup. Observers can therefore see the pre-setup world, and listeners installed by setup do not receive this agent's `session/created` or `agent/created`; rollback cannot retract external side effects those earlier listeners performed. This is a current atomicity limitation, not a guarantee provided by the setup window.
+Resume installs an owner-liveness sentinel before reserving IDs or starting persistence I/O, then races loading against owner disposal. If disposal wins, resume rejects and releases both reservations immediately; a backend promise that settles later cannot publish. After a successful load, the factory synchronously installs the full agent lifecycle before removing the sentinel, so ownership passes from load to setup without an unobserved disposal gap.
-Setup performs direct synchronous registrations but does not drive the agent. Calling `send`, `steer`, or `inject` there could open a turn before `agent/session-start`, reversing a lifecycle contract used by bridges and hooks; development invariants report that misuse at the first `turn/start`. Mounting an asynchronously activating child plugin also does not extend the synchronous setup window unless its activation ordering is separately awaited.
+The sentinel exists only for the interval in which no agent lifecycle can exist yet:
-### Teardown waits for one quiescent boundary
+```text
+resume(request):
+ snapshot request ids, options, and setup callback
+ sentinel = owner.effect(onDispose => signal ownerDisposed)
+ reserve(agentId, sessionId)
-The yielded disposers produce this teardown order:
+ try:
+ persisted = await firstOf(persistence.load(sessionId), ownerDisposed)
+ session = reconstruct(persisted)
-1. Request the loop to stop and await its actual exit, including its closing session events and durability flush.
-2. Unregister the agent.
-3. Detach the session from the store.
-4. Unwind the scope's listeners and registrations.
+ # This call installs the full lifecycle before its first await.
+ starting = startOwned(agentId, session, options, setup)
+ disarm and dispose sentinel
+ return await starting
+ finally:
+ release both ids
+ settle the sentinel transaction
+```
-Detaching the session before the asynchronous scope unwind keeps registry and store rollback synchronous on construction failures. Scoped listeners remain installed through the stop-and-drain phase, so they hear the final flush before the session detaches.
+If `ownerDisposed` wins, the load promise may continue inside the backend, but it has no path back to publication.
-Cordis disposers are single-shot but a second call does not necessarily await a first call already in progress. The agent lifecycle therefore owns a shared completion promise in addition to the raw disposer. Tool cleanup, parent teardown, explicit `AgentHandle.dispose()`, and owner-fiber unload all await the same fully quiescent result.
+### Setup composes an unpublished world
-Every registry returns its exact effect disposer so the composite lifecycle can preserve this order even when the whole owner fiber unloads. Returning a wrapper would leave the inner registration as a concurrently disposed sibling and could emit `agent/disposed` while the final turn was still draining.
+The optional `setup(agentCtx)` callback receives the new agent context and may synchronously register contributions or await child-plugin activation. During setup, neither the session nor agent is visible through its global registry, but `agentCtx.agent` exposes the unpublished agent to the code composing it.
+
+Setup may register scoped tools, prompt sections, variables, restrictions, listeners, protections, or child plugins. If it throws or rejects, the scope unwinds without publishing either object, and the reserved IDs become reusable. If the owner unloads during an await, the preinstalled teardown skeleton marks the transaction inactive; late setup completion cannot publish.
+
+After setup settles, the factory yields one microtask checkpoint and rechecks the lifecycle flag, owner-fiber state, and owning agent's disposed state. Cordis begins owner unload synchronously but may run nested effect disposers in the next microtask; the explicit owner checks and checkpoint let a same-turn unload win instead of allowing an immediately fulfilled setup to publish an already-doomed agent.
+
+Setup composes but does not drive. The concrete agent rejects `send`, `steer`, `inject`, and `cancel` until publication reaches the session-start boundary, keeps its inbox in a JavaScript native-private field, and allows only one concrete driver to claim a session. Driver startup is absent from the package surface: the package exports neither its loop/inbox internals nor source subpaths, and only instance-bound controls held by the factory can enable and start the driver. JavaScript or a type cast therefore cannot bypass the lock by calling a public `start()` or writing directly into the queue. These boundaries prevent a turn from opening before lifecycle listeners know the session exists.
+
+The common create/resume tail makes the unpublished boundary explicit:
+
+```text
+startOwned(snapshot, preparedSession):
+ world = prepareLifecycle(snapshot, preparedSession)
+ # world now owns agent.ctx and the complete rollback/teardown skeleton
+
+ try:
+ await firstOf(snapshot.setup(world.agent.ctx), world.deactivated)
+ await oneMicrotask()
+ require world.lifecycleActive
+ require world.ownerFiberActive
+ require world.ownerAgentNotDisposed
+
+ world.publish(snapshot.source)
+ return handle(world.agent, world.dispose)
+ catch error:
+ await world.dispose()
+ throw error
+```
+
+`setup` can await arbitrary plugin activation, but every exit still passes through the already-installed disposer.
+
+### Publication is ordered and rollback-covered
+
+After setup succeeds, the factory publishes in one synchronous sequence with no `await` between steps:
+
+1. Enter the session store and capture its scope carrier.
+2. Enter the agent registry without announcing it.
+3. Emit `session/created`.
+4. Emit `agent/created`.
+5. Enable driving.
+6. Emit `agent/session-start`.
+7. Start the driver loop.
+
+The implementation keeps publication synchronous and leaves rollback to the surrounding owned transaction:
+
+```text
+publish(world):
+ world.detachSession = world.agent.ctx.sessions.enter(world.session)
+ world.detachAgent = app.agents.enter(world.agent)
+ app.sessions.announce(world.session)
+ app.agents.announce(world.agent)
+ world.driver.enableDrivingVerbs()
+ emitNonVetoing(agent/session-start)
+ world.stopDriver = world.driver.start()
+```
+
+Both registry entries exist before the first creation listener runs, and setup-installed listeners receive both announcements. Driving opens immediately before `agent/session-start`, so that event remains the first supported place for a listener to inject or queue startup work.
+
+The sequence is not described as atomic because observers run between its steps. If a `session/created` or `agent/created` listener throws, the transaction rolls the registry entries and scope back, but effects already performed by an earlier listener cannot be retracted. An announced agent is paired with its disposal notification during rollback. `agent/session-start` is a non-vetoing notification: listener failures are logged and contained so the loop still starts.
+
+### Teardown stops work before revoking its world
+
+Every owner path uses the same reverse order: stop the loop and await its actual exit, remove the agent from the registry, detach the session, then unwind the scope. The final session events and durability flush therefore happen while the session and scoped listeners are still live.
+
+```text
+disposeOwnedAgent(world):
+ world.stopDriver()
+ await world.agent.done # waits for any in-flight turn close and flush
+ world.detachAgent() # emits agent/disposed when announced
+ world.detachSession()
+ await world.scope.dispose()
+```
+
+The actual Cordis generator yields these disposers in reverse so its last-in-first-out teardown executes in the order shown.
+
+`agent/disposed` means the driver is quiescent and the agent has left the registry; session detachment and scope unwind may still be completing after that notification. `AgentHandle.dispose()` is memoized so concurrent owners await the same full transaction, and `Scope.dispose()` provides the corresponding shared boundary for direct scope disposal and raw-disposer races.
+
+Parent-owned subagents use explicit ownership rather than capability inheritance. The driver creates one run-owner fiber under `parent.ctx` and invokes the child factory through that fiber, so lifecycle ownership exists before setup or publication begins; disposing a parent reaches its descendants even if a delegating tool never reaches its own `finally`. The child still receives a newly minted scope and resolves only global plus child-scoped capabilities.
+
+## Owner-final policy boundaries
+
+Cooperative waterfalls remain the general extension mechanism, but an invariant belongs after the last transformable point. The design adds four narrow boundaries, each owned by the service that can define what “final” means.
+
+### Prompt protection restores named canonical contributions
+
+`systemPrompt.protect({ sections, tools })` declares that selected names must match the canonical registry/provider assembly after the complete `system-prompt/assemble` waterfall. Protections registered globally and for the current scope compose by set union, so callback order cannot weaken them. Protection finalizes a returned assembly rather than recovering from listener failure; if the waterfall throws, assembly still fails.
+
+For each protected name, the service restores the canonical presence and definition. If the canonical assembly omitted the name, protection removes a listener-fabricated entry; this makes mode-dependent absence enforceable as well as presence.
+
+A global section protection also reserves the registry name against scoped shadowing. Registering a scoped section under an already protected global name throws, and adding global protection throws if any scoped shadow already exists. Section registration copies `name`, `order`, and the text value or callback before the check and stores that record, so later mutation of the caller's object cannot rename a safe section into a reserved one. This check must happen before assembly: otherwise the ordinary scoped-over-global merge would make the shadow itself look canonical, leaving post-waterfall restoration with the wrong owner's value. Tool-schema protection does not impose a blanket schema-name reservation because providers are additive and may deliberately contribute unrelated executable schemas.
+
+Restoration is intentionally not a whole-assembly reset. The service first removes protected names from the waterfall result, then reinserts protected canonical entries in their canonical order immediately before the first surviving later unprotected canonical neighbor, or at the end when no such neighbor survives. Unprotected entries keep the ordering and definitions chosen by the waterfall. This anchor rule preserves the protected contribution's meaningful local placement without claiming that protection restores every global relative position after arbitrary listener reordering.
+
+```text
+registerSection(input, scope):
+ stored = copy(input.name, input.order, input.text)
+ if scope exists and stored.name is globally protected:
+ fail before registration
+ sectionLayer(scope).add(stored)
+
+assemble(context):
+ canonical = assemble registries for context.scope
+ transformed = await systemPromptAssembleWaterfall(clone(canonical))
+
+ for each protected name:
+ remove every transformed entry with that name
+ if canonical contains the name:
+ if a later unprotected canonical neighbor survived:
+ insert the canonical entry before that neighbor
+ else:
+ append the canonical entry
+
+ return transformed
+```
+
+This algorithm restores a protected entry's definition, presence or absence, and useful local anchor without erasing unrelated listener output.
+
+Code Mode uses global protection for the `tools:sdk` section and reserved `run_code` schema. Structured output adds scoped protection for its instruction and capture schema. These are named guarantees: unrelated listeners may still contribute unrelated sections or tools.
+
+### Tool executions have stable identity
+
+`ctx.tools.execute(input)` accepts a caller-owned `ToolExecutionInput` and snapshots it into a distinct pipeline-owned `ToolExecution`. The registry requires `arguments` to be losslessly JSON-serializable, validates before cloning and again after cloning to contain unstable accessors, then deep-freezes the detached value. A cloneable but mutable exotic such as `Map` is rejected before policy rather than smuggled through an apparently frozen wrapper. Invalid input still produces one normalized final error notification.
+
+The registry assigns each pipeline trip a frozen, property-free `ToolExecutionToken`; callers cannot choose that token. The execution's `token`, `callId`, `name`, `agent`, optional opaque `parent` token, and detached `arguments` are non-writable and non-configurable from the first policy listener onward. `signal` is the only operational field: an around-dispatch wrapper may add, replace, or remove it, and the registry freezes the complete execution before outcome observation.
+
+Stable identity prevents a listener from changing which capability or scope was authorized after policy ran. It also gives commit-style observers a safe `WeakMap` key even when an adapter reuses a model call ID.
+
+For a nested transport dispatch, `parent` carries only the enclosing execution's opaque token rather than its live object. Code Mode sets an SDK sub-call's `parent` to the outer `run_code` execution's `token`, so an observer can correlate the two outcomes without receiving a reference that could mutate the still-running outer wrapper.
+
+The input-to-execution conversion is intentionally one-way:
+
+```text
+prepareExecution(input):
+ require input.parent is absent or a registry-minted token
+ require input.arguments is lossless JSON
+ detachedArguments = clone(input.arguments)
+ require detachedArguments is still lossless JSON
+
+ execution = {
+ token: new frozen property-free object,
+ callId: input.callId,
+ name: input.name,
+ arguments: deepFreeze(detachedArguments),
+ agent: input.agent,
+ parent: input.parent,
+ signal: input.signal
+ }
+
+ make every field except signal non-writable and non-configurable
+ return execution
+```
+
+### Tool guards can deny but never re-allow
+
+`ctx.tools.guard()` installs a synchronous global or scope-specific guard after the extensible `tools/pre-execute` waterfall and before dispatch. A guard returns a denial reason or `undefined`; it has no allow result.
+
+This one-way result makes the boundary monotonic. Pre-execution hooks can still compose ordinary allow, deny, and ask decisions, but no listener ordering can convert a guard denial back into dispatched work. A denied call still continues through result transformation and final observation as an error outcome.
+
+### `tools/result` observes the authoritative live outcome
+
+The complete live pipeline is `tools/pre-execute` → monotonic guards → `tools/execute` → `tools/post-execute` → `tools/result`. The first three named events are transformable waterfalls; `tools/result` is an awaited, observe-only notification after all transforms and the registry's outer error normalization. Immediately before that boundary, the registry validates that the entire authoritative result can round-trip losslessly through JSON; an invalid tool or listener result becomes a normal JSON-safe `isError` outcome instead of reaching observers as apparent success and failing later at the session log.
+
+Every `tools/result` listener receives the same frozen execution and deep-frozen result snapshot. Listener failures are contained independently, so they cannot change the caller's result or starve peer observers. Scope filtering derives from `execution.agent`.
+
+`tools/result` is not the durable session event `tool/result`. The live notification belongs to the registry and also fires for direct programmatic executions; the agent loop subsequently appends `tool/result` to the session log for replay, UI reconstruction, and model history. A policy that needs the final in-process verdict uses the former, while a consumer that needs persisted transcript state uses the latter.
+
+The entire registry method reads like one authority ladder:
+
+```text
+execute(input):
+ try:
+ execution = prepareExecution(input)
+ catch invalidInput:
+ execution = frozen identity shell with arguments = undefined
+ result = errorResult(invalidInput)
+ await tools/result observers with independent failure containment
+ return result
+
+ try:
+ ordinaryDecision = await tools/pre-execute(execution)
+ if ordinaryDecision allows:
+ denial = firstRegisteredGuardDenial(execution)
+ else:
+ denial = ordinaryDecision.denial
+
+ if denial exists:
+ result = errorResult(denial)
+ else:
+ result = await tools/execute(execution, next = dispatchRegisteredTool)
+ result = requireValidExecutionResult(result)
+
+ result = await tools/post-execute(execution, result)
+ result = requireLosslessJson(result)
+ catch pipelineFailure:
+ result = errorResult(pipelineFailure)
+
+ freeze(execution)
+ frozenResult = deepFreeze(clone(result))
+ await every tools/result observer independently, containing each failure
+ return result
+```
+
+Waterfalls can transform only at their named stages. Guards can only deny, and the final observers can only observe.
+
+### `agent/turn-stop` makes a composed continuation terminal
+
+Ordinary continuation remains extensible. The loop computes a default, runs the `agent/turn-continuation` waterfall, records any force-continue reason as steering, and folds pending steering into the decision because steering normally demands another model step.
+
+The scoped serial `agent/turn-stop` checkpoint runs after that folding. Its strict serial helper consults listeners in order until one returns a non-`undefined` value; a listener returns `{ action: 'stop' }` or abstains with `undefined`. The dedicated helper exists because ordinary Cordis serial dispatch treats `null` and `false` as framework abstentions, while this public contract has exactly one abstention value. A stop is terminal, so later listeners and pending steering cannot restore continuation. A malformed result, including `null` or `false`, or a throwing policy closes the current turn with an error while leaving the driver available for later work.
+
+Terminal stop deliberately discards steering while preserving ordinary queued prompts. Its terminal state remains in force through `turn/end` and the durability flush, so steering added by continuation, turn-close, or flush listeners cannot escape through the loop's late-steering fallback into another step or turn. This is the explicit exception to the normal rule that leftover steering becomes input for another turn. The authority is reserved for protocols, such as a completed structured child, where further model work would violate the result contract.
+
+```text
+afterSuccessfulStep(turn):
+ decision = await agent/turn-continuation(defaultDecision)
+ record decision.reason as steering when present
+ if steering is pending: decision = continue
+
+ terminal = await strictSerial(agent/turn-stop)
+ # undefined means abstain; null, false, malformed values, and throws are errors
+ if terminal == stop:
+ discard steering
+ terminalStopped = true
+ decision = stop
+
+ append turn/end
+ await session/flush
+
+ if terminalStopped:
+ discard steering added by turn/end or flush listeners
+ else:
+ move leftover steering to the next-turn queue
+```
+
+The queued-prompt FIFO is separate and is never drained by terminal stop.
## Subagent composition
-The subagent seam demonstrates why agent scoping exists: a provider can compose a child-specific world with ordinary registrations and let the agent lifecycle own it.
+In-process subagents demonstrate how the scope, lifecycle, and final-policy pieces compose. A provider builds the child's world during unpublished setup, then lets the ordinary agent lifecycle own it.
-### Persona and tool filters become real capabilities
+### Inputs and ownership are fixed before asynchronous creation
-The in-process spawn and fork providers advertise persona and tool-filter support because their child setup can register a shadowing `deployment:persona` section and a tool restriction through the child's context. ACP remains honest about not supporting those capabilities because it delegates to a separate process whose registration context is not locally available.
+Starting a run snapshots every accepted field before asynchronous owner setup. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached. The schema is validated before cloning, while the prompt must pass the same lossless-JSON check before and after cloning that the session log requires. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent.
-Omitted configuration stays absent. This matters for schema-driven configuration: a materialized empty `allow` list means “allow nothing,” which is not equivalent to an omitted list, and an empty filter is not equivalent to no filter. The configuration schema preserves those distinctions before setup calls `restrict`.
+The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. The child factory runs through the owner fiber. Parent teardown, provider teardown, and manual run disposal all dispose this same node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view.
-### Parent disposal owns the subtree
+```text
+startInProcessRun(providerContext, acceptedRequest):
+ snapshot all request data, including parent identity
-After creating a child, the in-process driver registers the child's memoized disposer as an effect on the parent scope. Disposing a parent therefore reaches the whole descendant tree even if the delegating tool's `finally` block never runs.
+ providerLink = providerContext.effect(onDispose => disposeRunOwner())
+ attach snapshot.abortSignal listener
+ runOwner = mount no-op plugin under snapshot.parent.ctx
-This is structured concurrency expressed through ownership rather than through scope inheritance. The child still has a flat capability view—global plus child-only registrations—while its lifetime is linked explicitly to the parent.
+ returnedRun.dispose = () =>:
+ dispose providerLink
+ await disposeRunOwner()
-### Structured output becomes per-child state
+ childHandle = await runOwner.ctx.agents.create({
+ fresh ids and lineage,
+ cloned options and optional seed,
+ setup(childCtx) => install persona, tool restriction, structured runtime
+ })
+```
-A structured child registers a real-schema `structured_output` tool and its instruction section through its own context. Concurrent children can carry different schemas because each resolves only its own tool definition; no placeholder global schema, reference count, or strip-for-other-agents pass is needed.
+Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers.
-Four scoped listeners enforce the terminal protocol:
+### Persona, filtering, and lifetime use ordinary registrations
-1. An outer prompt-assembly listener reasserts the exact tool schema and instruction after downstream listeners have run. It replaces an existing tool in place, appends it when absent, removes duplicates, and restores the instruction to its order-190 section band.
-2. A tool pre-execution listener denies calls after a value has been captured, preventing later side effects in the same model response.
-3. A tool post-execution listener commits a staged value only if the final pipeline decision accepts that same execution.
-4. A turn-continuation listener stops the child after capture instead of spending another model step merely because a tool ran.
+A child persona is a scoped `deployment:persona` section that shadows the deployment-wide section. A child tool filter is a scoped restriction over global end capabilities. Omitted filters remain omitted; a materialized empty `allow` list means “allow nothing” and is not confused with absence.
-Staging is keyed by the `ToolExecution` object's identity in a `WeakMap`, not by the model or adapter's call ID. Only the pipeline trip whose tool body staged a value can commit it; a blocked trip cannot leave state that a later call with a reused ID accidentally promotes. The weak key also allows an abandoned stage to be reclaimed without global cleanup bookkeeping.
+The child's persona, filter, and structured runtime are installed inside factory setup. The common run-owner fiber gives structured-concurrency-style teardown without importing the parent's capability layer into the child.
+
+### Structured output is a child-owned terminal protocol
+
+A structured child registers a real-schema `structured_output` tool and its instruction through its own context. Concurrent children can use different schemas because each scope resolves its own definition, with no global placeholder, reference count, or remove-for-everyone-else pass.
+
+Presentation mode changes where the model invokes the capture capability, but not which child owns it:
+
+| Tool mode | Registry's canonical wire contribution | Generated SDK | Structured-output guarantee |
+|---|---|---|---|
+| `native` | Visible end-capability schemas, including scoped `structured_output` | None | Protection restores the capture schema and instruction |
+| `code` | Reserved `run_code` transport | Visible end-capability bindings, including `structured_output` | Protection keeps `run_code` and the SDK present, keeps native `structured_output` absent from the wire, and restores the instruction |
+| `both` | Visible native schemas plus reserved `run_code` | Visible end-capability bindings, including `structured_output` | The model may call the protected capture capability natively or through the protected transport |
+
+The table describes the registry's named canonical contribution. An unrelated assembly listener may deliberately add another schema; protection does not erase unrelated names.
+
+### Capture uses stage, final commit, monotonic denial, and terminal stop
+
+The capture tool validates its arguments and stages the cloned value in a JavaScript `WeakMap` keyed by the immutable `ToolExecution`. This is an object-identity table whose key does not keep an abandoned execution alive. Validation failure becomes the ordinary `INVALID_ARGS` error that the model can correct within the turn.
+
+The scoped `tools/result` observer commits a direct native capture only when that exact execution's authoritative final result succeeds. A later call with a reused string call ID cannot reach the weak-keyed stage, and a post-execution block cannot promote it.
+
+```text
+# Native structured-output call
+structured_output.body(value, execution):
+ validate value against this child's schema
+ staged[execution] = clone(value)
+ return ordinary success
+
+on tools/result(execution, finalResult):
+ if execution is staged:
+ value = staged.remove(execution)
+ if finalResult succeeded:
+ captured = value
+```
+
+For a Code Mode SDK call, successful inner observation records a pending value against the child execution's opaque `parent` token instead of committing immediately. When the enclosing `run_code` reaches its own `tools/result`, the observer compares that pending token with the outer execution's `token` and commits only on success. A program error or outer post-policy block discards the pending value. This extra boundary is necessary because an inner side effect can succeed while the transport that is supposed to deliver the structured answer still fails.
+
+```text
+# Code Mode adds an outer transport commit
+on tools/result(innerStructuredCall, innerResult):
+ if innerStructuredCall is staged:
+ value = staged.remove(innerStructuredCall)
+ if innerResult succeeded:
+ pending = { outerToken: innerStructuredCall.parent, value }
+
+on tools/result(outerRunCodeCall, outerResult):
+ if pending.outerToken == outerRunCodeCall.token:
+ value = pending.value
+ pending = none
+ if outerResult succeeded:
+ captured = value
+```
+
+The native path has one final-result commit; Code Mode has two because the inner capability and outer transport can fail independently.
+
+Once a value is captured or pending on its outer transport, the scoped `ToolGuard` denies later calls in the same response. After a committed capture, the scoped `agent/turn-stop` ends the turn after ordinary continuation and steering have been folded. Together these boundaries prevent post-capture side effects and prevent a successful tool call from purchasing an otherwise automatic extra model step.
+
+The provider does not re-prompt a child that finishes without a committed capture. Such a run returns an error result with no `structured` value; requesting an output schema creates a requirement, not a guarantee that a failed child produces a value.
## Correctness enforcement
-Scoping errors are dangerous because a missed carrier silently restores global delivery. The implementation therefore makes the safe path short and checks it at type, runtime, test, and documentation boundaries.
+Scope mistakes are fail-open if they merely omit a carrier, so the implementation checks the contract at API, type, runtime, and repository-gate boundaries. None of these checks substitutes for using the correct runtime carrier.
-### Compile-time and API shaping
+### API shape couples subjects that must agree
-Scoped event declarations require the `Scoped` carrier marker. `agentEvents` couples carrier creation to the agent argument, `assembleContextFor` couples agent prompt facts to the scope selector, and session flush is a service method that owns carrier lookup.
+`agentEvents(context, agent)` couples the dispatch carrier to the agent argument, `assembleContextFor(agent)` couples prompt facts to the scope selector, and `SessionStore.flush(session)` owns lookup of the carrier captured when the session entered the store. These helpers make a mismatched subject harder to express than the correct spelling.
-These TypeScript checks improve authoring but are not treated as a security boundary: JavaScript callers, casts, and hand-written dispatches can bypass them.
+### Type markers cover every scoped event declaration
-### Development-time invariants
+Scoped agent, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript therefore rejects a bare subject at typed dispatch sites, including the `subagent/start` and `subagent/end` paths whose scope is the delegating parent.
-The invariants plugin observes Cordis's internal dispatch seam. For every scope-filtered event it verifies that a carrier exists and, where the subject is present in the arguments, that the carrier key is the same object. Session and subagent lifecycle payloads do not expose the owner key, so their runtime check proves carrier presence only; the session store and subagent service centralize the dispatch spelling that selects the key. The plugin also rejects an assembly context whose `agent` and `scope` fields disagree and a setup callback that opens a turn before `agent/session-start`.
+The marker is compile-time only. JavaScript callers, casts, and direct use of Cordis's dispatch APIs can bypass it, which is why the runtime checks remain necessary.
-The invariant checks run before listener delivery, so a violation points at the dispatching call site instead of appearing later as cross-agent behavior.
+### Development invariants check actual dispatch
-### Drift gates and focused tests
+The invariants plugin observes Cordis's internal dispatch path before listener delivery. For each scope-filtered event it requires a marked carrier and, where the event arguments expose the subject, verifies that the carrier key is the same object.
-`verify-scoped-dispatch` compares the runtime invariant table with the event declarations marked as scope-filtered. The generated event matrix also rejects a declared event with no recognized dispatcher, preventing helper-shaped calls from disappearing silently from the architecture documentation.
+Session and subagent payloads do not expose the owner key directly, so their invariant proves carrier presence while their service centralizes how the correct key is chosen. Additional invariants reject an assembly whose `agent` and `scope` disagree and a turn opened before `agent/session-start`.
-Focused tests cover scoped visibility, shadowing, restrictions, carrier transparency, setup rollback, teardown order, shared quiescence, session delivery, structured-output tamper recovery, stale-stage isolation, Code Mode bindings, and parent-child disposal. The [events catalog](../../../cordis-catalog/events.md) remains the exhaustive event contract rather than being duplicated here.
+### Repository gates keep declarations and dispatchers aligned
+
+`verify-scoped-dispatch` compares the declared scoped events with the runtime invariant table, and the generated event matrix requires every declaration to have a recognized dispatcher. Source JSDoc is regenerated into the [event catalog](../../../cordis-catalog/events.md), keeping the exhaustive signature and mode reference in one place.
## Alternatives considered
-The alternatives below solve only part of the problem or separate visibility from ownership, which would make safe composition harder to reason about.
+The rejected designs either split visibility from ownership, isolate the wrong boundary, or depend on extension ordering for correctness.
-### Pass an agent or scope option to every registration
+### Pass an agent option to every registration
-An API such as `tools.register(definition, { agent })` makes global registration the leak-by-omission default and requires every registry to invent parallel scope plumbing. It also lets visibility point at one agent while cleanup belongs to an unrelated context.
+An API such as `tools.register(definition, { agent })` leaves global registration as the leak-by-omission default and requires parallel scope plumbing in every registry. It also allows “visible to agent A, disposed with unrelated plugin B,” which the scoped context makes unrepresentable.
-The chosen design leaves existing registry signatures unchanged and uses the calling context as the single source of both facts.
+### Create one isolated service graph per agent
-### Create an isolated service instance per agent
+Service isolation chooses one registry instance for a context, while agent composition needs a merged view of deployment-global contributions plus one agent's additions. Per-agent graphs would duplicate shared adapters and force infrastructure such as persistence and UI bridges to discover every new instance.
-Cordis isolation selects one service instance for a context. Agent composition needs a merged view—deployment-global tools plus one agent's additions—not a choice between two independent registries.
+Isolation remains appropriate for independent applications. It is too coarse for collaborating agents inside one deployment.
-Per-agent service instances would require delegating merge registries for every scoped service and would force single-subscription infrastructure such as persistence and ACP to discover and subscribe to each new instance. Isolation remains the right bulkhead for co-hosting independent applications, not for agents collaborating inside one application.
+### Inherit the parent's scope into a child
-### Filter events but keep registries global
+Hierarchical capability inheritance makes lifetime convenient but silently grants every child the parent's scoped tools and policies. A flat view plus an explicit parent-owned disposer separates the two questions: the parent owns the child without conferring its authority.
-Listener filtering prevents a hook from intercepting the wrong agent, but it does not scope the model-visible tool schemas, prompt sections, variables, or executable tool lookup. Persona, tool filtering, and concurrent structured-output schemas would remain impossible or require ad hoc mutation.
+### Publish the agent before running setup
-### Add first-class scope support to vendored Cordis
+Early publication lets setup resolve the agent from global registries, but observers can see and act on a partially configured world. Rollback can remove entries but cannot retract external effects from already-run listeners.
-Cordis already exposes the primitives needed for this design: derived contexts, effect-owning plugin fibers, and listener filtering through the dispatch receiver. Modifying the vendored framework would add synchronization and maintenance cost without providing an additional harness capability.
+The unpublished callback already receives both the agent context and its `ctx.agent` association, so early global lookup is unnecessary.
-### Give each subsystem a separate per-agent API
+### Allow only synchronous setup
-Tool filters, prompt profiles, listener predicates, and session routing can each be implemented independently. That approach multiplies concepts, cleanup paths, and opportunities for the views to disagree.
+Synchronous setup is simpler but cannot honestly compose a child plugin whose activation is asynchronous. In TypeScript, a callback returning a promise can also be assigned to a void-returning callback type, so declaring setup as synchronous would not reliably prevent accidental escape from the rollback boundary.
-The shared scope primitive gives every subsystem the same answer to “which agent sees this?” and “when does it go away?” while letting each registry retain its own domain-specific resolution rules.
+Awaited setup makes the transaction explicit and keeps the first assembly behind it.
+
+### Enforce invariants with prepended waterfall listeners
+
+A prepended listener is not necessarily outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace the result after delegation. The same issue appears in prompt assembly, tool authorization, result commit, and turn continuation.
+
+The owner-final APIs express the actual strength required by each rule: restore named canonical data, deny monotonically, observe the immutable final outcome, or stop after all ordinary continuation inputs are folded.
+
+### Filter events while keeping registries global
+
+Listener filtering prevents a hook from intercepting the wrong agent but does not scope tool schemas, executable lookup, prompt sections, variables, or Code Mode bindings. Persona, tool filtering, and concurrent structured schemas would still require global mutation.
+
+### Add scope semantics to vendored Cordis
+
+Cordis already provides derived contexts, effect-owning fibers, and receiver-based listener filtering. The harness-level primitive combines those mechanisms without adding a framework fork whose synchronization cost would outlive this feature.
## Consequences
-The design adds one central concept and some low-level implementation machinery. In return, it makes per-agent composition ordinary, leak-resistant, and lifecycle-safe.
+The design makes per-agent composition ordinary and lifecycle-safe at the cost of a small scope runtime and several deliberately narrow final-policy APIs. The complexity is concentrated in services and dispatch helpers rather than repeated in every plugin.
### Benefits
-- Plugin authors use the same registration APIs globally or per agent; only the context changes.
-- An agent's prompt, executable tools, Code Mode bindings, policy listeners, and UI definitions resolve from the same scoped view.
-- Agent disposal revokes its registrations automatically, including failure and hot-reload paths.
-- Subagent persona, tool filtering, structured output, and parent-owned teardown compose without global mutation.
-- Existing unscoped plugins remain global observers and contributors, preserving the deployment-wide extension model.
+The main benefit is one composition model across data, behavior, and lifetime: registrations follow their context, while service-owned finalizers protect only the invariants that require stronger ordering.
+
+- Plugin authors use the same registration APIs globally and per agent; only the context changes.
+- Registry-owned prompt schemas, executable lookup, Code Mode bindings, policy listeners, and UI presentation resolve from the same agent view.
+- Create and resume expose no partially configured registry entry during awaited setup.
+- Agent disposal revokes scoped contributions after the driver and final session flush have settled.
+- Structured output composes per child without global mutation or listener-order assumptions.
+- Existing unscoped plugins remain deployment-wide contributors and observers.
### Costs and constraints
-- Every agent-subject event dispatcher must carry the correct scope; types, fused helpers, invariants, and gates exist because omission would otherwise fail open to global delivery.
-- `agent.ctx` is a capability-bearing context. The loop plugin's injected service set determines what holders can reach.
-- Scoped registry layers consume memory for the agent lifetime and add a two-layer resolution step, then disappear on scope disposal.
-- The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though property access and method calls are transparent.
-- Restrictions are flat and apply only to the global tool layer; scoped registrations are deliberate grants, and parent scopes do not confer capabilities on children.
-- The generic `Scope.dispose()` and `ScopeHost.dispose()` normalize Cordis's single-shot disposer but do not give racing callers a shared quiescence promise; the agent lifecycle adds that stronger boundary itself.
-- Setup is a synchronous contract, but its current `(Context) => void` TypeScript shape accepts an `async` function and the runtime does not inspect the returned promise; asynchronous setup can escape rollback and race the first assembly.
+The costs are concentrated in dispatch discipline, per-scope registry state, and explicit authority boundaries that are intentionally stronger than ordinary middleware.
+
+- Every scoped event dispatcher must carry the correct receiver; fused helpers, type markers, invariants, and gates exist because omission would otherwise deliver only to global listeners.
+- `agent.ctx` is capability-bearing. Its available services come from the agent loop's injected context, so holders receive that deliberate service surface.
+- Registries maintain per-scope maps and perform a global-plus-one-layer merge for the agent lifetime.
+- The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though method calls and property access behave like the subject.
+- Flat scopes do not inherit parent capabilities; a desired child capability must be global or explicitly registered for the child.
+- `run_code` is protected transport infrastructure rather than a filterable end capability, so a policy that must forbid programs denies execution at the tool-policy layer instead of removing the transport from a Code Mode prompt.
+- Prompt protection restores named canonical contributions and their anchor placement, not the entire assembly; unprotected output remains extensible, while a globally protected section name is deliberately unavailable for scoped shadowing.
+- Terminal turn stopping has authority to discard pending steering. That power is appropriate for owner-enforced terminal protocols and too strong for ordinary cooperative continuation policy.
+- Programmatic `ctx.agents.create()` and `ctx.agents.resume()` are asynchronous because they await setup. The config-only `ctx.agentLoop.create()` path has no setup callback and remains synchronous.
+- Ordered composition requires both an exact raw scope disposer and a shared public quiescence promise; the dual surface reflects two distinct Cordis lifecycle requirements.
### Deliberate boundaries
-The primitive is general, but this decision scopes only the surfaces needed for coherent agent composition. Per-agent filesystem policy, LLM adapter selection, named profile registries, and background subagents can build on the same context without changing the core scope model.
+The scope primitive is generic, but this decision applies it only where one agent needs a coherent registration view: tools, prompt state, scoped events, sessions, and in-process subagent composition. `agent.ctx` does not automatically scope every service call; filesystem policy, LLM interception, background subagent state, and future registries retain their existing seams until their own designs explicitly adopt the context rule.
diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md
index 4cc373c65c..f97e7abf50 100644
--- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md
+++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md
@@ -10,39 +10,39 @@ For multi-step tool work this is token-heavy and serial. The model cannot compos
Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated API over the tools, the program executes in a sandboxed runtime, and the model curates what comes back — only what it prints or returns — instead of every intermediate result.
-An earlier draft of this RFC designed Code Mode as an add-on consumer plugin with zero core changes, deferring the execution substrate to a follow-up. Both constraints are dropped here, deliberately. First, the harness is pre-release and optimizes for the correct foundation over blast radius: tool presentation is the registry's own concern, and bolting a second presentation onto it from outside means transforming the registry's contribution after the fact — a waterfall listener whose correctness depends on listener ordering, which fights the [reconstructable-requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) design instead of riding it (that refactor removed request mutation from `agent/request`, the seam the old draft relied on). Second, the substrate question is answerable now: a Node `worker_threads` runtime gives real containment — separate isolate, empty environment, heap caps, and a `terminate()` that reliably stops a hot synchronous loop — where the old draft's `node:vm` stub had none of those, and it fits the harness's existing trust model (§Trust posture) without a hardening follow-up.
+Tool presentation belongs to the registry that owns tool visibility: implementing a second presentation as an after-the-fact waterfall transform would make correctness depend on listener order and fight [reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md). The execution substrate is also part of the foundation rather than a placeholder: Node `worker_threads` provides a separate isolate, an empty environment, heap caps, and termination of a hot synchronous loop, while fitting the harness's existing trust model (§Trust posture).
## Decision
Three decisions, each elaborated in its own section below:
-1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (today's behavior, the default), `'code'` (the wire carries exactly one tool, `run_code`, plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas *and* `run_code` + SDK). The registry's existing tool-schema provider contributes whatever the mode dictates, so the wire tool list is shaped at its source — no interception, no listener-ordering caveats — and the logged request header records it for free.
-2. **Code execution is a capability seam** — a new group `packages/code-runtime/` with the interface package `@deepseek-ai/dsh-code-runtime` owning `ctx.codeRuntime` ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md); consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is a new implementation package, not a redesign.
+1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry shapes its wire contribution at the source and protects the transport pair through final assembly, so the logged request header records the same presentation the model receives.
+2. **Code execution is a capability seam** — `packages/code-runtime/` contains the interface package `@deepseek-ai/dsh-code-runtime`, which owns `ctx.codeRuntime` ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md); consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is another implementation package, not a redesign.
3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority.
### The registry owns the mode
`ToolRegistry` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention.
-**Wire tool list = the registry's contribution.** The registry already feeds the assembly through `ctx.systemPrompt.tools(() => this.schemas())`; the provider becomes mode-aware: `'native'` contributes all schemas (unchanged), `'code'` contributes only `run_code`'s schema, `'both'` contributes all schemas plus `run_code`. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the collapse is automatically logged and reconstructable — model-visible ⟺ logged holds with zero new mechanism. Scope of the guarantee, stated honestly: the mode governs the **registry's** contribution, and the registry is the only shipped schema source — but `systemPrompt.tools()` is a public multi-provider API and the `system-prompt/assemble` waterfall may transform the assembly, so a deployment that wires a second direct provider (or a mutating listener) owns what it adds, exactly as in native mode. Those are deliberate acts; what the design eliminates is the *accidental* leak the old draft worried about — a listener-ordering race around an after-the-fact collapse — and the shipped-configuration invariant (`'code'` ⇒ assembled tools exactly `[run_code]`) is pinned by tests and, like every request, by the logged header.
+**Wire tool list = the registry's contribution.** The registry feeds assembly through a mode-aware provider: `'native'` contributes every capability visible to that assembly scope, `'code'` contributes only `run_code`, and `'both'` contributes both. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the presentation is logged and reconstructable. The reserved transport is not a capability: it lives outside global/scoped registration and restriction layers, cannot be registered or shadowed, and cannot be named by `ctx.tools.restrict()`. `systemPrompt.protect()` restores its canonical schema after the complete assembly waterfall, so listeners cannot strip, replace, duplicate, or fabricate it. The mode governs only the registry's contribution; a deployment that deliberately installs another direct `systemPrompt.tools()` provider still owns that provider's schemas.
-**Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native tools rejects every assembly under `mode: 'code'` (those names are no longer contributed), by the existing fail-loud rule for unlisted names. This is correct behavior, not a bug: a deployment switching modes updates its order config or drops it.
+**Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'code'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it.
-**The SDK prompt section.** Under `'code'` and `'both'` the registry registers one lazy prompt section (`tools:sdk`, in the 100–199 tool-guidance order band) whose thunk regenerates, at each assembly, a TypeScript declaration of every registered tool except `run_code` itself, plus fixed usage instructions. The thunk reads the live store and emits tools in lexicographic name order, so its output is deterministic and stable across steps — an unchanged tool set produces byte-identical text (prefix-cache-friendly; a mid-session registration surfaces as one logged header delta, exactly like a native-mode tool change).
+**The SDK prompt section.** Under `'code'` and `'both'` the registry registers one lazy prompt section (`tools:sdk`, in the 100–199 tool-guidance order band) whose thunk regenerates, for each assembly scope, a TypeScript declaration of every visible end-capability tool plus fixed usage instructions. It uses the same visibility resolver as lookup and execution, so scoped grants and shadows appear while restricted globals disappear; the reserved `run_code` transport itself is excluded. The thunk emits tools in lexicographic name order, so an unchanged visible set produces byte-identical text, and `systemPrompt.protect()` restores the canonical section after every assembly listener. Because that protection is global, it also reserves the `tools:sdk` registry name against scoped section shadows; otherwise scoped-over-global resolution could make a later shadow look canonical before restoration.
**Codegen.** A pure `jsonSchemaToTs(schema)` module inside `dsh-tools` (sibling of `json-schema.ts` — `schemas()` and the SDK are two projections of the same store) maps the JSON-Schema subset the `defineTool` DSL emits (object/string/number/boolean/array, `properties`, `required`, string `enum` → literal union, nested objects, array `items`, `description` → JSDoc) to a TS type literal. It is **total**: any construct outside that subset (`$ref`, `oneOf`/`anyOf`, `integer`, future MCP shapes, …) degrades to `unknown` without throwing. Because `ToolSchema.name` is an arbitrary string, the SDK is declared as one object constant — `declare const tools: { "some-mcp-tool"(args: …): Promise; bash(args: …): Promise; … }` — quoted keys make every name reachable with no sanitization or alias-collision logic. Typing is advisory (the runtime executes type-stripped JS); the instructions say so.
### The run_code tool and the dispatch bridge
-Under `'code'` and `'both'` the registry registers `run_code` in itself as an ordinary tool — one required parameter `{ code: string }` — so the unchanged loop dispatches it through the normal pipeline and `tools/pre-execute` / `tools/post-execute` gate it like any other call (a permission plugin can inspect the program text before it runs). Its `execute(args, exec)`:
+Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`:
-1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every registered tool except `run_code`, the binding is an async function that (a) checks the run signal before and after (throwing stops the program — necessary because `ctx.tools.execute()` converts errors to `isError` data), (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON: normalizing BEFORE dispatch makes the dispatched form and the logged form the same JSON value by construction, so an executed sub-call can never fail at logging time, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) `` — the run signal, not the bare outer one, so a budget expiry aborts an in-flight sub-tool (`bash-local` kills on its spec signal) instead of orphaning it, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders, an MVP limitation), `isError` → **the binding rejects** with an `Error` carrying the result text. Rejection is the deliberate model-facing contract — real code signals failure by throwing, `try/catch` and `Promise.all` short-circuiting behave as every model has seen them behave — where the old draft's `{ output, isError }` envelope made error handling a bespoke convention.
+1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every visible capability tool, the binding is an async function that (a) checks the run signal before and after, (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, parent: exec.token, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) ``, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders), `isError` → **the binding rejects** with an `Error` carrying the result text. The child's readonly `parent` is only the outer execution's frozen, property-free token, so commit-style observers can correlate outcomes without receiving a mutation path into the live `run_code` wrapper. Every sub-call still traverses the full pipeline under its own immutable identity and registry-assigned token. The run signal, rather than the bare outer one, lets budget expiry abort an in-flight sub-tool instead of orphaning it. Rejection gives programs ordinary `try/catch` and `Promise.all` failure semantics rather than a bespoke result envelope.
2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: exec.signal })`.
3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` resolves, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` returns. A successful run then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); the registry's existing catch turns it into a structured `isError` result.
**Sub-call `additionalContext` is suppressed, deliberately.** A `tools/post-execute` hook may attach `additionalContext` to a call; for loop-dispatched calls the loop buffers those and appends each as a `context/message` only after the step's `tool/result`s, preserving call/result adjacency. A sub-dispatch result's `additionalContext` has no such safe outlet from inside a running `run_code`: injecting immediately would land a `context/message` between the parent's `tool/call` and its `tool/result` (breaking the adjacency the buffering exists to protect), and `PostToolDecision.additionalContext` is singular where a program may produce many. The MVP therefore drops sub-call `additionalContext`, pinned by a test and stated in the hooks bridge's docs; the follow-up (a plural context channel or loop-level sub-dispatch buffering) is deferred until a real hook needs it through Code Mode.
-**Concurrency: serialized, enforced by the binding.** The bindings are async, so a model writing `Promise.all([tools.a(…), tools.b(…)])` starts both immediately — concurrent dispatch would be the *default*, while the tool contract still carries no concurrency-safety metadata (the open parallel-execution TODO). Each `run_code` invocation therefore owns a dispatch queue and every binding call chains onto it, so even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order; when the run settles, queued-but-unstarted dispatches are abandoned. Lifting this per-tool once tools can declare themselves concurrency-safe is deferred work, same as before.
+**Concurrency: serialized, enforced by the binding.** The bindings are async, so a model writing `Promise.all([tools.a(…), tools.b(…)])` starts both immediately — concurrent dispatch would be the default, while the tool contract carries no concurrency-safety metadata (the open parallel-execution TODO). Each `run_code` invocation therefore owns a dispatch queue and every binding call chains onto it, so even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order; when the run settles, queued-but-unstarted dispatches are abandoned. Lifting this per tool remains tied to tools declaring themselves concurrency-safe.
**Presentation.** `run_code`'s render intent is decided here per the [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title `Run code`, `rawInput` = the program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). Not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not.
@@ -61,7 +61,7 @@ Each sub-dispatch appends one session event, declared by `dsh-tools` via `Sessio
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout.
- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all).
-Per explicit-over-implicit at seams, the request spells out everything the runtime acts on; defaulting (timeouts, caps) is the implementation's validated config, never a hidden `??` inside `run()`. Consumption uses the loop's established optional-backend idiom: cordis has no optional injection — every `inject` entry gates activation — so a static `inject` on the registry would hold `ctx.tools` (and every tool plugin behind it) hostage to a code runtime existing even under `mode: 'native'`; instead the registry reads `ctx.get('codeRuntime')` at use time, exactly as `agent-loop` consumes `sessionPersistence`, with absence failing loud in the provider thunk as above. The seam split is justified by real planned divergence on both axes — substrate (worker now; container/microVM later) and language (the Python/AssemblyScript direction sketched in the earlier draft survives as future work) — not by speculation: `dsh-tools` consumes the interface today and tests against a trivial in-repo fake, exactly the interface/implementation/consumer shape of the bash template.
+Per explicit-over-implicit at seams, the request spells out everything the runtime acts on; defaulting (timeouts, caps) is the implementation's validated config, never a hidden `??` inside `run()`. Consumption uses the loop's optional-backend idiom: Cordis has no optional injection — every `inject` entry gates activation — so a static `inject` on the registry would hold `ctx.tools` (and every tool plugin behind it) hostage to a code runtime existing even under `mode: 'native'`; instead the registry reads `ctx.get('codeRuntime')` at use time, exactly as `agent-loop` consumes `sessionPersistence`, with absence failing loud in the provider thunk. The seam has concrete divergence on both axes: the worker-thread substrate can be replaced by a container or microVM implementation, and the TypeScript language contract can be paired with a language-specific SDK and runtime. `dsh-tools` consumes only the interface and tests against a trivial in-repo fake, exactly the interface/implementation/consumer shape of the bash template.
### The worker-thread runtime
@@ -76,7 +76,7 @@ Per explicit-over-implicit at seams, the request spells out everything the runti
### Trust posture
-The worker runtime is **containment, not a security boundary**, and the RFC says so without ceremony. Model code in the worker can reach Node globals — `fetch`, `process` (with an empty env), dynamic `import()` of built-ins — so a deliberately adversarial program has ambient authority comparable to what the harness's own `bash` tool already grants every model turn: `dsh-bash-local` runs arbitrary model-written commands with the host filesystem, network, and a scrubbed-but-populated environment. One asymmetry runs the other way and is stated plainly: `worker.terminate()` ends the thread, not OS processes a program may have spawned via `node:child_process` — weaker than `bash-local`'s process-group kill for direct children (equivalent for double-forked daemons, which survive both); the wall-clock ceiling bounds the worker itself, and orphan cleanup is the same deployment-level concern it already is for bash. Code Mode is gated where bash is gated — `tools/pre-execute`, where permission/sandbox plugins veto or approve the program before it runs — and adds containment bash does not have: empty env, heap caps, hard termination of the program itself, a separate isolate. The earlier draft's two-flag unsafe ceremony (`{ unsafe: true }` constructor + `allowUnsafeRuntime`) existed for a `node:vm` stub with *no* containment and is dropped with it; demanding scarier flags for the better-contained executor than for bash would be posture theater. A deployment that needs a hard boundary (untrusted multi-tenant input) needs it for bash too; that is a future `isolation: 'container'` backend, and the `isolation` descriptor exists so such a deployment can tell backends apart.
+The worker runtime is **containment, not a security boundary**. Model code in the worker can reach Node globals — `fetch`, `process` (with an empty env), dynamic `import()` of built-ins — so a deliberately adversarial program has ambient authority comparable to what the harness's own `bash` tool grants every model turn: `dsh-bash-local` runs arbitrary model-written commands with the host filesystem, network, and a scrubbed-but-populated environment. One asymmetry runs the other way: `worker.terminate()` ends the thread, not OS processes a program may have spawned via `node:child_process` — weaker than `bash-local`'s process-group kill for direct children (equivalent for double-forked daemons, which survive both); the wall-clock ceiling bounds the worker itself, and orphan cleanup is the same deployment-level concern it is for bash. Code Mode is gated where bash is gated — `tools/pre-execute`, where permission/sandbox plugins veto or approve the program — and adds containment bash does not have: empty env, heap caps, hard termination of the program itself, and a separate isolate. A `node:vm` executor with no containment would need explicit unsafe acknowledgement; imposing that ceremony on the better-contained worker while bash needs none would be posture theater. A deployment that needs a hard boundary (untrusted multi-tenant input) needs it for bash too; that is a future `isolation: 'container'` backend, and the `isolation` descriptor lets deployments distinguish backends.
### What the model sees
@@ -84,37 +84,37 @@ The `tools:sdk` section carries the `.d.ts` plus fixed instructions: the program
## Consequences
-The design shipped as four stacked changes — this RFC, the `dsh-code-runtime` interface package, the `dsh-code-runtime-worker` backend, and the `dsh-tools` integration — each gates-green with docs in the same change; review fixes landed on the change that introduced them and merged down.
+The design consists of the `dsh-code-runtime` interface package, the `dsh-code-runtime-worker` backend, and the `dsh-tools` presentation and dispatch integration.
-What exists now:
+Shipped surface:
- **The seam**: `packages/code-runtime/` — `@deepseek-ai/dsh-code-runtime` (abstract `CodeRuntime`, the vocabulary above, `ctx.codeRuntime`) and `@deepseek-ai/dsh-code-runtime-worker` (the worker-thread backend, every cap a validated config field). Rows in the service map, capability-seams graph, config catalog, and cordis catalog.
-- **The registry surface**: `ToolRegistry`'s first config (`mode`), the mode-aware wire contribution, the `tools:sdk` section, `jsonSchemaToTs`/`renderToolsSdk` (exported), `run_code` + the dispatch bridge + `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog).
-- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `demo:code-mode` boots each UI example's `code-mode.cordis.yml` overlay (the worker runtime + `mode: 'code'` over the base tree); the adding-a-tool cookbook states that a registered tool is reachable from programs for free, and the tool-pipeline doc shows sub-dispatches re-entering both waterfalls.
-- **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on.
+- **The registry surface**: `ToolRegistry`'s `mode` config, mode-aware wire contribution, protected `tools:sdk` section and reserved `run_code` transport, `jsonSchemaToTs`/`renderToolsSdk` (exported), the dispatch bridge and `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog).
+- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `demo:code-mode` boots each UI example's `code-mode.cordis.yml` overlay (the worker runtime + `mode: 'code'` over the base tree); every program sub-dispatch resolves the same scoped capability view and re-enters the complete tool pipeline with an immutable link to its enclosing transport execution.
+- **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); restrictions can hide end capabilities but cannot remove the presentation transport; sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on.
## Testing
What the suites pin, per tier:
- **Unit — worker runtime** (real workers, no mocks): output/value capture and log-source attribution; error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM); the two budgets from both sides (a hot loop behind an un-awaited pending dispatch dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`); binding-bridge hostility (junk/forged port traffic incl. non-object messages and forged `log`/`done` cap bypass attempts, unknown names, duplicate ids, post-settlement replies, `__proto__`/`constructor`/`toString` binding names); structured-clone fallback and cap truncation; `env` emptiness verified from inside a program; dispose-awaits-exit. A real-load-path e2e runs the BUILT package under plain `node` so the worker entry resolves both unbuilt (tsx) and built — the published-artifact guard from [docs/testing.md](../../../testing.md).
-- **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` unchanged, `'code'` exactly `[run_code]`, `'both'` all + `run_code`); `toolOrder × mode` rejection; missing-runtime / wrong-language loud failures; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety (disposing the registry removes the tool and the section).
+- **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` capabilities, `'code'` exactly `[run_code]`, `'both'` capabilities + `run_code`); reserved-name, restriction, shadow, assembly-protection, and `toolOrder × mode` invariants; missing-runtime / wrong-language loud failures; full-pipeline and opaque parent-token behavior for sub-dispatches; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety.
- **e2e (with-key, self-skips)**: a real model under `mode: 'code'` composes two bash calls in one program (`examples/coding-agent/tests/code-mode.e2e.ts`) — every logged `request/header` carries exactly `[run_code]`, the dispatch events land under the parent call, the file the program wrote exists, and the final answer is the curated output.
- **Snapshot (keyless replay)**: goldens for a `run_code` turn under `'code'` and `'both'` (`code-mode-turn`, `both-mode-turn`), each its own header-pinning class — the SDK section text, the collapsed header tool list, the dispatch events, and the result card are committed and replayed.
## Alternatives considered
-**An add-on consumer plugin, zero core changes (the previous draft of this RFC).** Rejected on both halves. The wire-collapse half aged out from under it: it targeted the `agent/request` waterfall, which [reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) has since re-typed to call-config-only, and the surviving alternative — transforming the assembly a waterfall listener receives — is strictly worse than contributing the right list in the first place (transformation must undo `toolOrder` canonicalization it cannot see the config for, and its correctness depends on where it sits in a listener chain). The deeper reason is ownership: which tools the model is offered, in which representation, is the registry's single concern — `schemas()` for function calling and the SDK for Code Mode are two projections of one store, and splitting the second projection into a satellite package would preserve a boundary the domain does not have.
+**An add-on consumer plugin with zero core changes.** Rejected because `agent/request` is call-config-only under [reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md), while transforming an assembled tool list would have to undo `toolOrder` canonicalization without owning its config and would depend on listener order. Which tools the model is offered, and in which representation, is the registry's single concern: native schemas and the SDK are two projections of one visible store.
-**`node:vm` as the reference runtime, hardening deferred (also the previous draft).** Rejected: `node:vm` is not isolation (prototype-chain escapes reach the host realm), cannot interrupt a hot loop, and forced the draft into a two-flag unsafe ceremony plus a mandatory follow-up RFC. The worker thread delivers the missing properties now — separate isolate, empty env, `resourceLimits`, reliable `terminate()` (all verified by probe before this revision) — at bash-equivalent trust, so the reference implementation and the production one are the same package and the ceremony dissolves.
+**`node:vm` as the reference runtime, with hardening deferred.** Rejected: `node:vm` is not isolation (prototype-chain escapes reach the host realm) and cannot interrupt a hot loop. A worker thread provides a separate isolate, empty environment, `resourceLimits`, and reliable `terminate()` at bash-equivalent trust, so the reference and production implementation are one package without an unsafe-acknowledgement ceremony.
-**Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s (now cheap to add as a logged surface replace, per the reconstructable-requests consequences) still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under Code Mode for residual native calls.
+**Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s is cheap to add as a logged surface replacement under reconstructable requests, but still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under Code Mode for residual native calls.
**Parallel native dispatch in the loop.** The other answer to round-trip cost; still valid future work (the open TODO), still blocked on concurrency-safety metadata, and still no composition — it parallelizes calls the model already decided on in one step. Code Mode's serialized-queue decision keeps the two compatible: when the metadata lands, both native parallel dispatch and per-tool binding parallelism unlock together.
**Always-exclusive (Cloudflare-faithful, no mode).** Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (`bash`, `read`, `edit`) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form (`'code'`) one line away without imposing it.
-**Per-tool visibility tiers (this tool native, that tool code-only).** Deferred again, knowingly: it needs per-tool metadata and a presentation split that `'native' | 'code' | 'both'` does not, and every learning it depends on (how models actually split usage under `'both'`) arrives only after this ships.
+**Per-tool visibility tiers (this tool native, that tool code-only).** Deferred: it needs per-tool metadata and a presentation split that `'native' | 'code' | 'both'` does not, and its design depends on evidence about how models split usage under `'both'`.
**Sanitized identifier aliases in the SDK** (`my-tool` → `my_tool`, Cloudflare's approach). Rejected: quoted keys on a `declare const` make every name reachable with zero alias-collision logic; models handle `tools["my-tool"](…)` fine.
diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md
index 7795b044f6..454f21df93 100644
--- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md
+++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md
@@ -6,21 +6,31 @@ Status: implemented
The harness needs a hooks subsystem: users extend or gate the agent at lifecycle points the way Claude Code (CC) and Codex do. The key reframe driving this design is that **"native hooks" are not a package** — a native hook is just an ordinary Cordis plugin subscribing to the canonical lifecycle events. So the real product is a *powerful, well-typed canonical event surface*; the CC/Codex bridges (the `dsh-hooks-claude` / `dsh-hooks-codex` packages) are merely translators that map an external shell-hook protocol onto that same surface. Anything a bridge can do, a plain plugin can do directly — more powerfully (no serialization boundary, full `ctx`, typed returns).
-Before this change the interception surface was incomplete and inconsistent for that goal: there was no per-prompt seam (CC's `UserPromptSubmit`), no session-start signal (CC's `SessionStart`), the single `tools/execute` waterfall conflated the pre-gate and post-inspect phases (CC splits `PreToolUse`/`PostToolUse`), and `agent/turn-continuation` returned a bare `boolean` with no room for a force-continue *reason*. The [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) pinned down the three-domain rule and the typed-Decision idiom as the interception convention; this RFC builds the actual seams on top of it.
+The surface needs distinct contracts for per-prompt policy (CC's `UserPromptSubmit`), session-start observation (CC's `SessionStart`), pre-tool policy, around-dispatch control, post-tool transformation, final-result observation, and continuation with a model-facing reason. Conflating those phases gives plugins mutation channels they do not need and makes finality depend on listener ordering. The [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) supplies the three-domain rule and the typed-Decision idiom; this RFC applies them to the lifecycle seams.
## Decision
-Add/reshape the interception seams so every one returns a small, seam-specific **typed Decision union**, and the set covers the hook points in scope (`session-start`, `prompt-submit`, `pre-tool`, `post-tool`, `stop`-via-continuation).
+The canonical surface separates transformable policy, around-dispatch control, and observe-only notification. Policy waterfalls return small seam-specific **typed Decision unions**; wrappers return normalized results; notifications receive immutable snapshots and cannot affect the outcome. The set covers the hook points in scope (`session-start`, `prompt-submit`, `pre-tool`, `post-tool`, `stop`-via-continuation) while leaving non-hook execution policy independently composable.
-**New `agent/*` events** (`dsh-agent`):
+**Agent events** (`dsh-agent`):
- `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`.
- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below).
-**Reshaped** `agent/turn-continuation` from `(…, defaultDecision: boolean) → boolean` to `(…, defaultDecision: ContinuationDecision) → ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the existing `/goal` step-end-steer pattern.
+**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern.
-**Split** the single `tools/execute` waterfall into `tools/pre-execute` (→ `PreToolDecision` allow/deny/ask gate) and `tools/post-execute` (→ `PostToolDecision` accept/block, optionally replacing content or attaching `additionalContext`). Core dispatch sits between them as plain code inside `ToolRegistry.execute`'s outer try/catch, and the tool body keeps its own inner try/catch so a thrown tool still becomes an `isError` result that `post-execute` listeners can inspect.
+### The tool pipeline gives each phase one kind of authority
-**New `TurnEndReason` variant** `rejected` (`dsh-session`): a turn whose entire prompt batch was blocked by `prompt-submit`.
+Every call follows one ordered pipeline: `tools/pre-execute` → monotonic guards → `tools/execute` → core dispatch → `tools/post-execute` → `tools/result`. The registry requires caller-owned `arguments` to survive lossless-JSON validation before and after cloning, then snapshots `ToolExecutionInput` into a pipeline execution with its own opaque token: identity fields and deeply frozen detached arguments are immutable for the whole pipeline, and a nested call's `parent` contains only the enclosing execution's token rather than its live object. Optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove, and the complete object freezes before final observers run. This identity contract prevents a policy listener from silently changing what the log, UI, and tool body believe ran.
+
+- **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks; deny/ask skips `tools/execute` and core dispatch but still produces a normalized result for post-policy and final observers.
+- **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids.
+- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch.
+- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContext`; in-place mutation of the result is not a transform channel, because the registry rebuilds the outcome from a protected snapshot plus the returned decision.
+- **`tools/result`** is the awaited parallel notification after every transform, lossless-JSON validation, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome.
+
+Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist.
+
+**`TurnEndReason.rejected`** (`dsh-session`): a turn whose entire prompt batch was blocked by `prompt-submit`.
### Three load-bearing loop decisions
@@ -30,13 +40,13 @@ Add/reshape the interception seams so every one returns a small, seam-specifi
3. **A forced `continue` `reason` is enqueued through the steering channel**, so the next step's top-of-loop drain records it as steering for the continued turn — next-*step* steering within the SAME turn, not a next-*turn* prompt (matching the existing `hasSteering` force-continue override).
-### Pre-tool INPUT rewrite is DEFERRED (the over-reach signal)
+### Pre-tool input rewrite is a separate consistency decision
-`PreToolDecision` is allow/deny/ask only — **no `arguments` rewrite**. Output replacement (`PostToolDecision.accept.content`) is safe because `tool/result` is logged AFTER execution (one source of truth). Input rewrite is NOT safe today: `assistant/message` (the model-history source) and `tool/call` (the audit record) are both logged BEFORE execution, and live consumers READ `tool/call.arguments` for presentation (the ACP bridge remembers them for `presentResult`; `dsh-tool-bash` derives the title/cwd/terminal-vs-background from them). A rewrite that changed only execution would make the UI show one command while another RAN. Designing that consistently (rewriting the audit + history + presentation as one unit) is a real consistency-design problem CC itself warns is racy — so it gets its own [proposed RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md), and `TODO(pre-tool-input-rewrite)` anchors it at the loop's pre-execute call site. This does not regress any production consumer (no production `tools/execute` listener mutated `exec.arguments`). The low-level capability to mutate `exec` in a `pre-execute` listener still exists (unadvertised — a test shim uses it to thread a generated id), but it is not a first-class advertised contract.
+`PreToolDecision` is allow/deny/ask only — **no `arguments` rewrite**. Output replacement is safe because `tool/result` is logged after execution from the final result. Input rewrite is different: `assistant/message` (model history) and `tool/call` (the audit record) are logged before `ToolRegistry.execute()`, while ACP and tool presentation read those arguments. The registry therefore seals the cloned arguments before `tools/pre-execute`; no listener or test shim can mutate them in place. An honest rewrite must update history, audit, presentation, and execution as one unit before that identity is created, which belongs to the separate [pre-tool input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) and its loop-side `TODO(pre-tool-input-rewrite)`.
-### What this PR does NOT do
+### Boundaries
-It does **not** declare `hook/*` SessionEvents (the durable hook-invocation log) — those belong to the `dsh-hook-protocol` library, because a native plugin can already use the typed Decisions without a durable hook log. A worked native-plugin example/test in this PR (`packages/core/agent-loop/tests/interception.spec.ts`) proves all the seams compose end-to-end through the REAL loop with NO `hook/*` involved — the concrete proof that "native hooks are just a plugin". Compaction (`PreCompact`/`PostCompact`), the Notification hook, Codex `PermissionRequest`, the permission/`ask` system, and the Stop loop-guard remain deferred (`FIXME(permissions)` marks the `ask`→deny degrade).
+The seam package does **not** declare `hook/*` session events (the durable hook-invocation log); those belong to `dsh-hook-protocol`, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (`packages/core/agent-loop/tests/interception.spec.ts`) composes the seams through the real loop with no `hook/*` protocol. Compaction (`PreCompact`/`PostCompact`), Notification, Codex `PermissionRequest`, and the permission/`ask` interaction remain outside this decision (`FIXME(permissions)` marks the current `ask`→deny degradation).
## Alternatives considered
@@ -45,4 +55,4 @@ It does **not** declare `hook/*` SessionEvents (the durable hook-invocation log)
## Consequences
-The canonical interception surface is now complete and uniformly typed: a native plugin returns typed decisions directly, and a CC/Codex bridge maps its protocol fields onto the same unions. The loop gained four firing points (session-start emit, prompt-submit waterfall, the post-tool context buffer, the continuation reshape) and the `dsh-tools` registry runs a two-waterfall pipeline; both are documented in [architecture.md](../../../architecture.md) and the package READMEs, and the decision types in [core-data-structures](../../../core-data-structures/core.md#interception-decisions) + [tools.md](../../../core-data-structures/tools.md). All existing `tools/execute` and `turn-continuation` listeners (tests, docs) migrated to the new seams. The ACP bridge maps the new `rejected` reason to `cancelled` (its codec). A pure internal change with no editor-visible transcript shift for the existing scenarios — the new behavior only fires when a hook is registered — so the snapshot goldens are unchanged; a hook-driven snapshot scenario lands with the `dsh-hooks-claude` bridge, which is what makes a hook observable end-to-end through ACP.
+The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, prompt-submit, post-tool context buffering, and continuation; `dsh-tools` owns identity sealing and the five-phase execution pipeline. Their contracts are documented in [architecture.md](../../../architecture.md), package READMEs, [core interception decisions](../../../core-data-structures/core.md#interception-decisions), and [tool structures](../../../core-data-structures/tools.md). The ACP bridge maps `rejected` turns to its `cancelled` codec value, while hook-driven snapshots verify the observable bridge behavior end to end.
diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md
index 2f711651f6..a5f294f76d 100644
--- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md
+++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md
@@ -36,7 +36,12 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai
### The foundation: structured output on the subagent seam
-`agent({schema})` needs `SubagentStartRequest.outputSchema` to actually work; it was vocabulary without an implementation (`outputSchema: false` everywhere). Implemented in `dsh-subagent-inprocess` for both in-process backends: a globally registered `structured_output` capture tool whose per-child schema is enforced by a `prepend: true` `system-prompt/assemble` listener doing FINAL-ASSEMBLY enforcement (post-processing `await next()` — cooperative mutation would not survive a downstream listener returning a replacement assembly; the calling instruction rides as a trailing prompt section, since `AgentOptions` carries no per-agent prompt field, and the loop logs the result as the step's `request/header`, keeping the injection reconstructable), a `prepend: true` `agent/turn-continuation` veto after capture (no wasted extra model step) plus a `tools/pre-execute` deny for calls arriving after the capture (terminal within the step, not only at its end), and validation-retry in-turn via `ToolArgsError`. The schema is `structuredClone`d at `start()` (caller mutation cannot drift enforcement). Deliberately NO re-prompt: a child that finishes cleanly without calling the tool settles `error` to the parent. Lifetime is refcounted by backends (plugin lifetime) AND live runs (start → settle). The seam's `outputSchema` type became the raw JSON-Schema SUBSET (`StructuredOutputSchema` in dsh-tools: single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`; anything unenforced is rejected loud) — the schema travels verbatim to the model as the forced tool's parameters, so the wire format, not the author DSL, is the right vocabulary.
+`SubagentStartRequest.outputSchema` is implemented by `dsh-subagent-inprocess` for both in-process backends. Each structured child receives its own scoped capture tool, instruction, and enforcement registrations on `child.ctx`; concurrent children can use different schemas without sharing mutable policy, and disposing the child removes the entire attachment.
+
+- **Assembly is owner-protected.** The child registers `structured_output` with the run's real schema plus an order-190 instruction, then `systemPrompt.protect()` restores their canonical presence and definition after the complete assembly waterfall. Restored entries anchor before the first surviving later unprotected canonical neighbor, or at the end, without undoing listener ordering of unprotected entries. In native and both modes the capture tool remains a native wire tool. In pure Code Mode its canonical native presence is absent, so protection removes injected copies while the scoped tool remains in the generated SDK; the Code Mode owner independently protects `tools:sdk` and the reserved `run_code` transport. The loop logs the final assembly as `request/header`, keeping the demand reconstructable.
+- **Capture uses a two-level commit.** The capture body validates and stages a cloned value in a `WeakMap` keyed by that immutable execution object; only the observe-only `tools/result` notification commits it when the authoritative JSON-safe result after pre-policy, guards, around dispatch, post-policy, and outer error normalization succeeds. A capture called from a `run_code` program carries only the enclosing execution's opaque token as `parent`: inner success becomes pending, and commits when that token matches the enclosing transport's own successful `tools/result`. An outer runtime failure or post-policy block therefore cannot report structured success, and the observer never receives a live outer execution reference.
+- **Finality is monotonic within and after the step.** A scoped `ctx.tools.guard()` denies calls after capture has become pending or committed, and it runs after the entire extensible `tools/pre-execute` waterfall so listener order cannot force-allow a later side effect. After the step, scoped serial `agent/turn-stop` runs after ordinary continuation and steering folding; a captured child stops with no extra model step, and neither a continuation wrapper nor late steering can resurrect it.
+- **Schema and failure behavior stay explicit.** `start()` clones the schema so caller mutation cannot drift enforcement. `ToolArgsError` keeps validation retry inside the same turn. A child that finishes cleanly without a committed capture settles `error` to the parent; there is no re-prompt loop. `StructuredOutputSchema` is the raw enforceable JSON-Schema subset in `dsh-tools` (single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`), and unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim.
## Deferred (documented non-goals of this cut)
@@ -51,11 +56,11 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai
## Alternatives considered
-- **Hostile-value containment in the host** (trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition): an earlier revision built all of it, and review showed the cost was real while the threat model was not — every one of those defenses guards against an author the premise already trusts. Removed in favor of the plain boundary above; the thread boundary makes such machinery redundant anyway (serialization by construction).
-- **In-process `node:vm` execution** (the first cut of this RFC shipped it): mechanically simplest — no RPC, no thread — but `start()` blocks the caller for the script's initial synchronous slice, a synchronous spin past the first await cannot be killed in-process (the vm `timeout` covers only that first slice), and `dispose()` could only ABANDON an unsettling script, leaving the spin on the host loop. Superseded by the worker-thread engine, which keeps the same vm-context script surface while unblocking the host and making termination real.
+- **Hostile-value containment in the host** (trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition): rejected because every defense targets an author the trust premise accepts, while the thread's serialization boundary already makes cross-realm values total by construction.
+- **In-process `node:vm` execution**: mechanically simplest — no RPC, no thread — but `start()` blocks the caller for the script's initial synchronous slice, a synchronous spin past the first await cannot be killed in-process (the vm `timeout` covers only that first slice), and `dispose()` could only abandon an unsettling script on the host loop. The worker-thread engine keeps the same vm-context script surface while unblocking the host and making termination real.
- **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool.
- **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`.
-- **Meta embedded in the script as `export const meta = {...}`** (CC's exact format; the first cut shipped it): keeps scripts self-contained and CC scripts drop-in, but obtaining meta means evaluating model-written text on the HOST — the shipped extractor ran the literal in an empty timed vm context, yet reading the RESULT still executed script-controlled getters on the host stack outside any timeout, re-opening the host-spin hole the worker thread exists to close. A JSON parameter deletes the scanner, the evaluation, and the hole outright; the cost is that a CC script's meta header must move into the parameter (the body stays drop-in).
+- **Meta embedded in the script as `export const meta = {...}`** (CC's exact format): keeps scripts self-contained and CC scripts drop-in, but obtaining meta requires evaluating model-written text on the host. Even an empty timed vm context cannot bound script-controlled getters when the host reads the resulting object. A JSON parameter removes the scanner, evaluation, and host-spin hole; the cost is that a CC script's meta header must move into the parameter (the body stays drop-in).
- **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss.
- **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role.
- **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way.
@@ -63,4 +68,4 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai
## Consequences
-The harness gains CC-compatible script orchestration: fan-out plans live in a rerunnable artifact instead of the parent context, and the structured-output half of the subagent seam is now real (the vocabulary stopped lying about `outputSchema`). What it cost, all bounded by the trust premise: a worker thread per run (~tens-of-ms spin-up), every hook crossing a message port as RPC, and a termination-path `agentsStarted` that degrades to the host-observed count; in exchange `start()` never blocks the host, a post-cancel grace ends in a real `worker.terminate()`, and the value boundary is serialization by construction. A worker thread is still NOT a security boundary — scripts share the model's trust level, and actual sandboxing names its exit (the isolated-vm/separate-process engine swap behind the seam). The fatal-vs-null strictness divergence from CC means a CC-authored script that RELIES on option typos dissolving to `null` behaves differently here — judged worth it to keep the repo's no-accepted-then-ignored rule. Consumers must hold the run handle for control (`cancel`/`dispose`); observers get data snapshots only, so no listener can extend a run's lifetime or corrupt another's view.
+The harness gains CC-compatible script orchestration: fan-out plans live in a rerunnable artifact instead of the parent context, and `outputSchema` yields an authoritative structured child result across native and Code Mode presentation. The cost, bounded by the trust premise, is a worker thread per run (~tens-of-ms spin-up), every hook crossing a message port as RPC, and a termination-path `agentsStarted` that degrades to the host-observed count; in exchange `start()` never blocks the host, a post-cancel grace ends in a real `worker.terminate()`, and the value boundary is serialization by construction. A worker thread is still not a security boundary — scripts share the model's trust level, and actual sandboxing requires an isolated-vm/separate-process engine behind the seam. The fatal-vs-null strictness divergence from CC means a CC-authored script that relies on option typos dissolving to `null` behaves differently, preserving the repo's no-accepted-then-ignored rule. Consumers must hold the run handle for control (`cancel`/`dispose`); observers get data snapshots only, so no listener can extend a run's lifetime or corrupt another's view.
diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md
index c321c56d53..6c7cbaa5ed 100644
--- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md
+++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md
@@ -14,7 +14,7 @@ This RFC has a hard prerequisite on [session persistence](../../implemented/arch
## Proposal
-A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT an [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the `tools/pre-execute`/`tools/post-execute` waterfalls.
+A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT a [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the tool registry's guarded pre/around/post/final-result pipeline.
It depends on the official `@agentclientprotocol/sdk` (the `AgentSideConnection` class) — Apache-2.0, actively versioned. The SDK declares a `zod` peer dependency and imports `zod/v4` at runtime, so `packages/ui/acp` must declare `zod` itself (per the workspace dependency constraints). This is the renamed successor to `@zed-industries/agent-client-protocol`, which is now deprecated on npm.
diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md
index b3e6ab3cd7..9657512082 100644
--- a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md
+++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md
@@ -4,7 +4,7 @@ Status: proposed
## Problem
-The [interception-seams RFC](../../implemented/feature/2026-06-30-interception-seams.md) added `tools/pre-execute` returning a `PreToolDecision` (allow/deny/ask) — but deliberately NOT input rewrite (a hook changing a tool call's `arguments` before it runs). Claude Code's `PreToolUse` hook offers an `updatedInput`, so a faithful CC bridge wants the same. This RFC designs that, separately, because doing it consistently is a real problem — not a field to bolt onto the allow decision.
+The [interception-seams RFC](../../implemented/feature/2026-06-30-interception-seams.md) defines `tools/pre-execute` as an allow/deny/ask gate over an execution whose identity is already protected and whose arguments are deeply frozen. Claude Code's `PreToolUse` hook also offers `updatedInput`, so a faithful bridge needs an explicit rewrite mechanism. A rewrite cannot be a mutation escape hatch on the existing execution object: it must keep the durable history, audit record, presentation, and executed value consistent.
## The problem: three readers of pre-execution arguments
@@ -14,37 +14,38 @@ In the loop, a tool call's arguments are committed to the log and read by live c
2. **`tool/call`** is the durable AUDIT record, appended before `ctx.tools.execute()`.
3. **Live presentation reads `tool/call.arguments`**: the ACP bridge remembers them and passes them to `presentResult`; `dsh-tool-bash` derives the card title, the rawInput, the cwd, and the terminal-vs-background treatment from them.
-So an "input rewrite" that changes ONLY what executes would make the UI show one command while another RAN, and render result state against the wrong arguments — a real inconsistency, not a documentable gap. (The existing low-level capability to mutate `exec.arguments` in a listener has exactly this latent inconsistency; it is unadvertised precisely because of this — yet not unused: a tool-bash integration test rewrites a scripted call's arguments through it (`packages/bash/tool-bash/tests/integration.spec.ts`), so this design must either sanction that path with the consistency unit below or seal it — `readonly` arguments at the seam, with the test shim moved onto a behavior-level helper.)
+An execution-only rewrite would make the UI show one command while another ran and render the result against the wrong arguments. The registry prevents that failure mode today: it structured-clones and deep-freezes `arguments`, makes the execution identity properties non-writable, and exposes no test shim or listener path that can replace them. The rewrite design must preserve that protected-identity boundary rather than weaken it.
## Proposal
-A sketch, to validate against the code when built. Treat input rewrite as a consistency unit: when a `pre-execute` hook supplies `updatedInput`, the rewrite must be reflected in ALL three readers, atomically, before execution:
+A rewrite is a pre-identity consistency transaction. When a hook supplies `updatedInput`, the effective value must be chosen before the registry constructs its immutable `ToolExecution`, and it must be reflected in all three readers atomically:
- The `tool/call` audit event records the REWRITTEN arguments (with the original retained in a sidecar field for the audit trail — a hook changed the call, and both the original and the effective arguments are facts worth keeping).
- The `assistant/message` in derived history must agree with what executed — options to evaluate: rewrite the assistant message's tool-call block in place (changes what the model "sees it said"), or record a separate correction the next request carries. The CC model is that the model sees the rewrite took effect.
- Presentation (`presentCall`/`presentResult`) reads the rewritten arguments, so the UI shows what actually ran.
-The shape would extend `PreToolDecision` with an allow-variant `arguments` (or a dedicated `{kind:'rewrite', arguments}`), and the loop would thread the rewrite through the three readers above rather than only into `ctx.tools.execute()`.
+Extending `PreToolDecision` at its current firing point is insufficient: both durable records already exist by then, and the execution identity is protected. The implementation must either move the relevant decision before the log commit or add a dedicated earlier rewrite decision over the pending model call. After the loop commits the effective arguments to history and audit, it constructs the ordinary immutable execution and runs the existing allow/deny/ask and tool pipeline unchanged.
## Alternatives considered
-### Why not now
+### Why not mutate the execution object?
-The interception-seams RFC notes input rewrite "fought the code across two review rounds" — the signal AGENTS.md names for an over-reaching change. Shipping allow/deny/ask first keeps the seam honest (no advertised contract that silently desyncs the UI), and a CC/Codex bridge that receives an `updatedInput` logs it and surfaces a faithful-but-degraded warning (like `ask`→deny) until this lands. This RFC is the home for the consistent design; `TODO(pre-tool-input-rewrite)` in the loop's pre-execute call site anchors it.
+Allowing a pre-execute listener to assign `exec.arguments` would provide only an execution rewrite, leaving model history, audit, and presentation unchanged. Keeping the identity protected makes such partial behavior unrepresentable. Until the consistency transaction exists, a CC/Codex bridge logs and warns about `updatedInput` rather than claiming it was honored; `TODO(pre-tool-input-rewrite)` at the loop dispatch site anchors the missing earlier phase.
## Acceptance criteria
-- A `pre-execute` rewrite is reflected in all three readers atomically before execution: the `tool/call` audit records the rewritten arguments (the original retained in a sidecar field), derived history agrees with what executed, and presentation renders the rewritten arguments.
-- The unadvertised `exec.arguments` mutation path is either sanctioned by this consistency unit or sealed (`readonly` arguments at the seam, the test shim moved onto a behavior-level helper).
+- A requested rewrite is resolved before `ToolExecution` identity is created and reflected in all three readers atomically: the `tool/call` audit records the rewritten arguments (the original retained in a sidecar field), derived history agrees with what executed, and presentation renders the rewritten arguments.
+- The effective `ToolExecution.arguments` remains deeply frozen and non-writable throughout pre-policy, guards, dispatch, post-policy, and final observation; no mutation shim is introduced.
- The CC/Codex bridges honor `updatedInput` instead of logging the faithful-but-degraded warning.
## Risks
- Rewriting the `assistant/message` tool-call block changes what the model "sees it said"; whether any provider rejects that on replay is the open question that must be settled empirically before the decision shape freezes.
-- Until this lands, the unadvertised mutation path keeps its latent UI-desync inconsistency.
+- An earlier rewrite phase changes the ordering relationship among `assistant/message`, `tool/call`, hook audit events, and execution; the design must pin that ordering without weakening turn enclosure or call/result adjacency.
## Open questions
- Does rewriting the `assistant/message` tool-call block corrupt any provider's expectation on replay, or is a separate correction safer?
- Should the original arguments be preserved on the `tool/call` event (audit) and, if so, under what field?
+- Does the rewrite decision move before the log commit or become a dedicated earlier seam, and how do existing pre-tool allow/deny hooks avoid running twice?
- How does this interact with a future permission `ask` flow (a user approving a rewritten call)?
diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
index a6f932b1ed..c6bfbe967c 100644
--- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
+++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
@@ -8,13 +8,13 @@ Three pieces of public spine surface share one defect class: their only possible
1. **`SurfaceManager.invalidate()`** (`packages/core/session/src/surface.ts`). Its documented trigger — "the log has been replaced wholesale (e.g. after Session seed)" — is structurally unreachable: seeding happens inside the `Session` constructor, `_surface` is created lazily on first access, and the log reference is never reassigned afterward, so no constructed `SurfaceManager` ever observes a wholesale replacement. Sole caller: its own unit test. A rollback primitive protecting a scenario the implementation cannot produce.
2. **The `runLoop`, `Inbox`, and `InboxMessage` exports** (`packages/core/agent-loop/src/index.ts`). `runLoop` has no importer outside the package — the only callers are the package's own internals (the agent constructs its loop with it), so the public re-export has zero consumers; `Inbox`/`InboxMessage` likewise reach outside code only through the package's own inbox spec (switchable to the source module). The exports contradict the package's own docs — the inbox module doc says the public surface is `Agent.send()`/`Agent.steer()` — and the [architecture dependency rule](../../../architecture.md): nothing programs against `dsh-agent-loop`; a replacement loop is a different bundle built on `dsh-agent`, not a consumer of this package's internals. `ReactLoopAgent` stays exported (cross-package tests construct it by package name).
-3. **`ToolExecutionResult.callId`** (`packages/core/tools/src/index.ts`; the *input* `ToolExecution.callId` stays). Zero readers — and no listener can even construct a result: `tools/pre-execute`/`tools/post-execute` listeners return Decisions, the registry builds every result itself and always sets `callId` to the input `exec.callId`, and the post-execute dispatch snapshots the outcome before the waterfall precisely so a listener mutating the shared result reference cannot corrupt the id. The loop independently ignores `result.callId` in favor of its own `call.id`, and two regression tests exist solely to prove the field cannot matter (the loop's ignores-result-callId test and the registry's mutation guard). A field that is by construction a copy of its input, defended by snapshot machinery, and pinned by tests proving it is ignored is pure liability surface; the ACP bridge correlates via the session event's `data.callId`, never via the execution result.
+3. **`ToolExecutionResult.callId`** (`packages/core/tools/src/index.ts`; the input `ToolExecution.callId` stays). Zero consumers read it. A `tools/execute` wrapper may construct or replace a result, but the registry rejects any `callId` that differs from the immutable execution identity and rebuilds later outcomes from protected snapshots; `tools/post-execute` receives that same execution beside the result, and the observe-only `tools/result` notification receives both as immutable values. The loop independently correlates with its model call's `call.id`, while ACP correlates through the session event's `data.callId`. The result field is therefore a compulsory copy of information already present at every extension point, plus validation and regression tests whose only job is to prove the copy cannot disagree.
## Proposal
-Delete the method and its test; delete the three export lines and their `packages/core/agent-loop/README.md` rows, pointing the inbox spec at the source module; drop the result field from the type, the registry's construction sites (the deny result, the dispatch result, `toolErrorResult`, and the post-execute snapshot's `callId` leg), the loop's ignore-comment, the proves-ignored regression test, and the mutation guard's `callId` assertions — the hazard they all pin disappears with the field, while the result's `additionalContext` ferry (a consumed post-execute channel) stays untouched. Update the `ToolExecutionResult` paste in [tools.md](../../../core-data-structures/tools.md) (and its `scripts/type-equiv.manifest.json` row) and the result-shape row in `packages/core/tools/README.md`; for the `invalidate()` removal, amend the [session-surface RFC](../../implemented/architecture/2026-06-18-session-surface.md)'s full-rebuild-after-wholesale-replacement sentence per [implemented/AGENTS.md](../../implemented/AGENTS.md).
+Delete the method and its test; delete the three export lines and their `packages/core/agent-loop/README.md` rows, pointing the inbox spec at the source module; drop the result field from the type, the registry's construction sites (deny, dispatch, `toolErrorResult`, post-execute snapshots), its around-wrapper mismatch validation, the loop's ignore-comment, and the tests that prove the duplicate id cannot matter. The result's consumed `additionalContext` ferry and the execution object's authoritative `callId` stay untouched. Update the `ToolExecutionResult` paste in [tools.md](../../../core-data-structures/tools.md) (and its `scripts/type-equiv.manifest.json` row) and the result-shape row in `packages/core/tools/README.md`; for the `invalidate()` removal, amend the [session-surface RFC](../../implemented/architecture/2026-06-18-session-surface.md)'s full-rebuild-after-wholesale-replacement sentence per [implemented/AGENTS.md](../../implemented/AGENTS.md).
-Sequencing: the in-flight surface-cache work (tool-pairing balance caching) neither uses nor touches `invalidate`, so that removal lands after or alongside it mechanically. The execute pipeline is `tools/pre-execute` → dispatch → `tools/post-execute`, and post-execute listeners receive the execution object alongside the result — nothing needs the result's own id.
+Sequencing: the surface-cache work (tool-pairing balance caching) neither uses nor touches `invalidate`, so that removal can land after or alongside it mechanically. The full execution pipeline carries the immutable execution object through pre-policy, guards, around-dispatch wrappers, post-policy, and final result observation; nothing needs the result to repeat its id.
## Alternatives considered
@@ -25,7 +25,7 @@ A future consumer that swaps a session's log in place would want a reset primiti
## Acceptance criteria
- `invalidate()` and the result `callId` appear only in this RFC; `runLoop`/`Inbox`/`InboxMessage` remain package-internal only — no re-export from the package index and no outside-package importer; the agent-loop README lists only the consumed public surface; the inbox spec imports the source module.
-- The pre-/post-execute pipeline contract tests pass with the shrunk result type; the mutation-guard and proves-ignored tests shed their `callId` legs with the hazard they pin.
+- The complete tool-pipeline contract tests pass with the shrunk result type; the around-wrapper mismatch test, mutation-guard id assertions, and proves-ignored loop test disappear with the duplicate field.
## Risks
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index 6d97017f1d..04ac0b9d26 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -16,7 +16,7 @@ This table connects model-visible tool names to the plugin package and service s
| Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note |
| --- | --- | --- | --- | --- | --- |
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
-| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time. |
+| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry's only canonical wire contribution; the other visible capabilities are declared in a protected TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
| `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
@@ -118,7 +118,7 @@ Execute a TypeScript program against the available tools. Write the BODY of an a
Source: [`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code-mode.ts)
-Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.
+Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry's only canonical wire contribution; the other visible capabilities are declared in a protected TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.
## `@deepseek-ai/dsh-tool-bash`
diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md
index 09a863badc..ef6715dccb 100644
--- a/docs/tool-execution-pipeline.md
+++ b/docs/tool-execution-pipeline.md
@@ -3,7 +3,7 @@
# Tool Execution Pipeline
-This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls.
+This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards and `tools/result` are the owner-enforced boundaries around them.
```mermaid
flowchart TD
@@ -11,19 +11,24 @@ flowchart TD
toolCall["Session event: tool/call
logged before execution"]
presentCall["UI pending card
presentCall(args)"]
pre["tools/pre-execute waterfall
hooks, permission, sandbox"]
+ guards["Registered monotonic guards
deny or abstain; identity protected"]
denied["deny or ask
tool body skipped"]
around["tools/execute waterfall
timeout, retry, metrics (around dispatch)"]
toolBody["Registered tool execute() body"]
fsGate["fs/write-intent or fs/edit-intent
tool-fs mutations only"]
owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"]
post["tools/post-execute waterfall
accept, block, replace, add context"]
+ final["tools/result parallel notification
frozen authoritative outcome"]
context["Buffered additionalContext
context/message after all tool results"]
toolResult["Session event: tool/result
single model-facing outcome"]
+ allResults["All calls in the step settled
and tool/result events recorded"]
presentResult["UI completed card
presentResult(args, result)"]
model --> toolCall
toolCall --> presentCall
toolCall --> pre
- pre -->|allow| around
+ pre -->|allow| guards
+ guards -->|allow| around
+ guards -->|deny| denied
around --> toolBody
pre -->|deny or ask| denied
denied --> post
@@ -32,11 +37,13 @@ flowchart TD
toolBody --> owned
toolBody --> around
around --> post
- post --> context
- post --> toolResult
+ post --> final
+ final --> toolResult
toolResult --> presentResult
+ toolResult --> allResults
+ allResults --> context
```
-Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).
+Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The awaited `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution's opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).
Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs.
diff --git a/examples/README.md b/examples/README.md
index e8a41a366b..f500f25e9e 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -19,7 +19,7 @@ A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + th
Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details.
-Its `code-mode.cordis.yml` overlay flips the same tree to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so the model gets exactly one wire tool — `run_code` — plus a generated TypeScript SDK section, and composes the other tools by writing a program whose output it curates. Run with: `pnpm run demo:code-mode` (the REPL is the default UI; `acp` as the argument serves the acp-agent example's same-shaped overlay instead) — see the [Code Mode section](coding-agent/README.md#code-mode) for what to try.
+Its `code-mode.cordis.yml` overlay flips the same tree to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so its canonical wire contribution is the protected `run_code` transport plus a protected generated TypeScript SDK section, and the model composes the other tools by writing a program whose output it curates. Run with: `pnpm run demo:code-mode` (the REPL is the default UI; `acp` as the argument serves the acp-agent example's same-shaped overlay instead) — see the [Code Mode section](coding-agent/README.md#code-mode) for what to try.
## cordis-agent
diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md
index 7731aa8f09..37a43481e5 100644
--- a/examples/coding-agent/README.md
+++ b/examples/coding-agent/README.md
@@ -33,7 +33,7 @@ The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_
## Code Mode
-[`code-mode.cordis.yml`](code-mode.cordis.yml) is this same tree flipped to [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): an include overlay over `./cordis.yml` whose two patches insert the worker-thread code runtime (`@deepseek-ai/dsh-code-runtime-worker`, registering `ctx.codeRuntime`) and set `tools: { mode: code }` on the app. The model is then offered exactly ONE wire tool — `run_code` — plus a generated TypeScript SDK section declaring every other registered tool; it composes them by writing a program, each program tool call bridges back through the ordinary `tools/pre-execute`/`post-execute` pipeline one at a time and is logged as a `tool/code-dispatch` session event, and ONLY what the program prints or returns re-enters its context. (Flip the mode to `both` to offer native calls AND `run_code` side by side.)
+[`code-mode.cordis.yml`](code-mode.cordis.yml) is this same tree flipped to [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): an include overlay over `./cordis.yml` whose two patches insert the worker-thread code runtime (`@deepseek-ai/dsh-code-runtime-worker`, registering `ctx.codeRuntime`) and set `tools: { mode: code }` on the app. The registry contributes exactly one protected wire transport — reserved `run_code` — plus a protected TypeScript SDK section declaring the visible end-capability tools. The model composes those capabilities by writing a program; each program call carries an immutable link to its enclosing transport, bridges back through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation one at a time, and is logged as a `tool/code-dispatch` session event. Only what the program prints or returns re-enters model context. (Flip the mode to `both` to offer native calls and `run_code` side by side.)
```sh
pnpm run demo:code-mode # this overlay under the REPL (default UI)
diff --git a/packages/core/README.md b/packages/core/README.md
index 4e61293034..fa6ffc5a5a 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -7,7 +7,7 @@ The packages every harness build is assembled from: the session log, the system-
| `scope/` | Scoped-context registration primitive (scope tags, scope-filtered dispatch) | (library — no ctx key) |
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
-| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` |
+| `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` |
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
| `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) |
diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md
index 3f2ff08c0e..6753472005 100644
--- a/packages/core/agent-core/README.md
+++ b/packages/core/agent-core/README.md
@@ -13,7 +13,7 @@ This is the package to read to see **the whole plugin tree at once** — the tea
@deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary
@deepseek-ai/dsh-session event-sourced session log + store
@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly
-@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute
+@deepseek-ai/dsh-tools registry + guarded pre/around/post/final-result pipeline
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
index b5eb717dc8..a9fd8376f7 100644
--- a/packages/core/agent-loop/README.md
+++ b/packages/core/agent-loop/README.md
@@ -8,14 +8,14 @@ This is the only package in the harness that contains concrete loop logic. Every
### Public API
-Lifecycle (scoped): the composite creation effect mints the agent's scope (`agent.ctx`), enters the session through it (the session's dispatch carrier), registers the agent, runs `CreateAgentOptions.setup`, emits `agent/session-start`, then starts the loop; teardown runs stop/drain → unregister → detach session → unwind scope, keeping store/registry rollback synchronous on every failure path. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`.
+Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. Resume installs an owner-liveness sentinel before persistence load, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`.
- `ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
-- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).
-- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
+- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
+- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.
@@ -30,20 +30,22 @@ interface Config {
agents: Array<{
id: string // required
model?: string
+ resumeSessionId?: string // load this persisted session instead of creating one
}>
}
```
Agents listed in config are auto-created at startup. (There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context.) The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
-### Classes
+### Exported concrete class
-- `ReactLoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), the per-step `AbortController`, and the loop driver. Everything observable happens through session events and the `agent/*` event taxonomy.
-- `Inbox` — per-agent queued + steering FIFOs (`enqueue`, `steer`, `drainQueued`, `drainSteering`, `waitForQueued`).
+- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy.
+
+`Inbox`, `runLoop`, and the instance-bound enable/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals.
### Loop lifecycle (`loop.ts`)
-One invocation of `runLoop()` drives one agent for its whole lifetime:
+The internal loop driver runs one agent for its whole lifetime:
```
create agent → emit agent/session-start(source) ⟵ once, before turn 1
@@ -69,29 +71,33 @@ forever:
message = waterfall agent/step-result
session('assistant/message')
each tool-call: session('tool/call')
- → tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute]
+ → tools.execute() [pre waterfall → monotonic guards → around dispatch → post waterfall → final notification]
→ session('tool/result')
append buffered post-execute additionalContext as session('context/message')(s)
drain steering → session('steering/message')
cont = waterfall agent/turn-continuation → ContinuationDecision
({action:'continue', reason?} records reason as next-step steering)
- if action==stop (and no pending steering): break
+ pending steering can override an ordinary stop
+ terminal = serial agent/turn-stop → ContinuationStop | undefined
+ (after ordinary decision/reason/steering folding)
+ if terminal stop, or ordinary action==stop with no pending steering: break
session('turn/end')
await session/flush
- re-enqueue leftover steering as queued
+ terminal turn: discard steering added before/during close and flush; keep ordinary queued sends
+ ordinary turn: re-enqueue leftover steering as queued
idle unless more queued
```
-Error containment: a throwing plugin ends the **turn**, never the loop. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop.
+Error containment: a throwing plugin ends the **turn**, never the loop. A malformed or throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop.
Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.)
### What is NOT here
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
-- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/session-prefix`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
+- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → `tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
- Compaction: `agent/pre-step`
-- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute`
+- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.
- Persistence: `session/event` + `session/flush`
- UI: `session/event` (assistant token stream, boundaries, tool activity) + `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`)
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index e857676b0f..035332704e 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -8,56 +8,32 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
### Public API
-The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` composes a child's scoped world at creation — setup registers, it never drives.
+The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while the factory keeps the agent and session unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives: the concrete loop rejects driving verbs until the `agent/session-start` boundary.
- `ctx.agents.register(agent: Agent): () => Promise | void` — record an **already-constructed** agent. Disposed with the calling fiber.
+- Advanced ordered lifecycle: `enter(agent): () => void` inserts without announcing, and `announce(agent)` emits `agent/created` only for that exact live entry. The async factory uses this split after setup; ordinary plugins use `register()`.
- `ctx.agents.get(id: AgentId): Agent | undefined`
- `ctx.agents.list(): Agent[]`
#### Factory seam (creation)
-Agent *creation* is provided by whichever plugin implements `AgentFactory` (phase 1: `dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package.
+Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package.
- `ctx.agents.setFactory(factory: AgentFactory): () => Promise | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
-- `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`/`meta.parentSession`/`meta.seedLength` and optional `seed` events for forked children). Distinct from `register` (which only records). Throws if no factory is registered.
-- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured.
+- `ctx.agents.create(options: CreateAgentOptions): Promise` — snapshot caller-owned IDs/options/metadata/seed, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Agent/session IDs are reserved across setup; setup rejection or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emits `agent/disposed` during that rollback. Rejects if no factory is registered.
+- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured.
-`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
+`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures the loop's final `session/flush` before the session is detached and keeps scoped listeners alive through that flush. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
-### Events
+### Live events
-The full `agent/*` event taxonomy is declared via declaration merging in `dsh-agent` (not `dsh-agent-loop`), so plugins depend only on this package.
+`dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events.
-#### Lifecycle (emit)
+The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following `agent/session-start`; that non-vetoing notification is the first supported startup injection point. `agent/disposed` runs after the driver is quiescent and the agent leaves the registry, while ordered teardown may still be detaching its session and unwinding its scope.
-- `agent/created`, `agent/disposed` — registration/deregistration
-- `agent/status` — idle / running / disposed transition
-- `agent/queued` — message entered inbox (source-resolved, steering flag)
-- `agent/session-start` — the session lifecycle began (once, before turn 1), carrying a `SessionStartSource` (`startup` for a fresh or forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it cannot block startup; a listener seeds context via `agent.inject()` (a `context/message` the first request sees).
+Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#owner-final-policy-boundaries).
-#### Boundaries are durable session events, not `agent/*` emits
-
-Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that needs them reads the durable `turn/start`/`turn/end`/`step/start`/`step/end` events off the `session/event` feed (the session log is the live boundary feed, carrying the `Session` — the turn/step numbers and reasons ride on the event data). See [the event-domain-semantics RFC](../../../docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md) and [the remove-boundary-mirror-events RFC](../../../docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md).
-
-#### Interception seams
-
-`agent/pre-step` is a **serial** surface-mutation checkpoint; the rest are **waterfalls** that return a small, seam-specific typed **Decision** union (the unified idiom across the taxonomy — a CC/Codex bridge maps its `permissionDecision`/`decision`/`continue` fields onto these, a native plugin returns them directly):
-
-- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup).
-- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`.
-- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step; carries the assembled system prompt and the instance's composed session prefix so a token-pressure gate counts everything the request will carry.
-- `agent/request` — shape the call config before the model call: a frozen `LlmCallConfig` seed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log ([reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)); the loop logs whatever config the request actually uses as a `request/header*` event
-- `agent/session-prefix` — compose the session prefix: request-only messages placed in front of the ENTIRE derived history on every request. Fired ONCE per loop instance, lazily before its first pre-step (so pressure gates see this instance's real prefix, never a previous instance's logged one); the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the anchoring `request/header` snapshot, and reused verbatim afterwards — the prefix cannot change mid-session, so the provider prefix cache holds by construction (resume = a new instance = a recompose, attributably anchored by its `'resume'` snapshot). The home for session-stable openers that must not become durable history (a skills catalog, an AGENTS.md digest); `deriveMessages()` never returns it. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, `tools/post-execute` `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter
-- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
-- `agent/turn-continuation` — override the continue/stop decision via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard.
-
-Tool interception is the `tools/pre-execute` / `tools/post-execute` pair in [`dsh-tools`](../tools/README.md) (`PreToolDecision` allow/deny/ask, `PostToolDecision` accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam.
-
-#### Error notifications (emit)
-
-- `agent/error` — step/turn error
-
-The model's token stream is NOT an `agent/*` event: read it off the durable `session/event` feed as `assistant/chunk` (the same feed persistence and the ACP bridge use).
+Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
### Agent interface (`types.ts`)
diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md
index 0063ab8b2f..e57f475816 100644
--- a/packages/core/scope/README.md
+++ b/packages/core/scope/README.md
@@ -7,11 +7,12 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co
- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). Throws on a primitive key, or when `ctx`'s fiber is disposing (`INACTIVE_EFFECT`).
- `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
-- `Scope.dispose(): Promise` Idempotent, always-awaitable teardown of every registration made through the scope.
+- `Scope.dispose(): Promise` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.
- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global.
- `scopeTarget(base: T, key?: ScopeKey): Scoped` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics).
- `Scoped` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error.
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
+- `scopeHost(ctx, services)` Test/tooling host whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`.
## Design contract
diff --git a/packages/core/session/README.md b/packages/core/session/README.md
index 629cef5aec..094c3073df 100644
--- a/packages/core/session/README.md
+++ b/packages/core/session/README.md
@@ -9,7 +9,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
-- `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`).
+- `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -19,18 +19,14 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
- `ctx.sessions.prepare(id?, options?): Session` — validate the id/cwd and construct the `Session`, WITHOUT entering it into the store. Same options as `create`.
-- `ctx.sessions.enter(session): () => void` — wire `onAppend` → `session/event` and add the session to the store; returns the DETACH disposer. Does NOT emit `session/created` (the caller yields the disposer first, then calls `announce`, so a throwing listener rolls the attach back). The id was already validated by `prepare`, which runs in the same synchronous sequence, so `enter` does not re-check.
+- `ctx.sessions.enter(session): () => void` — wire `onAppend` → `session/event`, capture its scope carrier, and add the session to the store; returns the idempotent DETACH disposer, which clears both notification and carrier state. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved; a stale prepared object must not overwrite a live same-id session.
- `ctx.sessions.announce(session): void` — emit `session/created` for an entered session.
-`dsh-agent-loop`'s `AgentLoop.start` is the canonical consumer: it yields `enter`'s detach disposer, the registry unregister, and the loop-stop disposer into ONE composite effect, so teardown stops + awaits the loop (final flush captured) BEFORE detaching the session — whether the trigger is the `AgentHandle`'s `dispose()` or a fiber unload.
+`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload.
-### Events
+### Live service events
-| Event | Mode | Purpose |
-|---|---|---|
-| `session/created` | emit | A session was created |
-| `session/event` | emit (scope-filtered by the owning session's scope) | An event was appended (sync, fire-and-forget) |
-| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) |
+The store announces creation, publishes each append, and provides an awaited durability checkpoint. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly.
### Class: `Session`
diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md
index bf8c8bc4cc..d712cd4b5b 100644
--- a/packages/core/system-prompt/README.md
+++ b/packages/core/system-prompt/README.md
@@ -1,6 +1,6 @@
# dsh-system-prompt
-System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it.
+System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative named protections; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it.
## Config
@@ -13,23 +13,22 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
### Public API
-- `ctx.systemPrompt.section(section: PromptSection): () => Promise | void` Contribute a section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw. Disposed with the calling fiber.
+- `ctx.systemPrompt.section(section: PromptSection): () => Promise | void` Contribute a section. The registry snapshots `name`, `order`, and the text value/callback, so later caller-object mutation cannot rename a stored section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the schemas' names) is the pre-restriction universe `toolOrder` validates against. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
-- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Runs through the `system-prompt/assemble` waterfall (scope-filtered by `context.scope`). Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name.
+- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Inputs are snapshotted, empty protections throw, and disposal removes the protection.
+- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name.
-### Events
+### Live events
-| Event | Mode | Purpose |
-|---|---|---|
-| `system-prompt/assemble` | waterfall | Mutate/extend the assembly (with the caller's context) before it reaches the model |
-| `system-prompt/change` | emit | A section, tool provider, or variable was registered or unregistered (possibly for one scope); deliberately unfiltered |
+Prompt assembly is the scope-filtered transformable seam; registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope. Exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Named protections apply only after a successful assembly waterfall returns and are owned by the service rather than represented as another event listener.
### Key types
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context).
- `PromptSection` — `{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona (both registered by this plugin), tool guidance uses `100–199`; other negative orders also render before the persona.
- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
+- `PromptProtection` — `{ sections?: readonly string[], tools?: readonly string[] }`. Named contributions whose canonical pre-waterfall state is restored after all listeners; protections compose by set union rather than callback order, and global section names are reserved against scoped shadows.
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging.
@@ -40,6 +39,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
- The `system-prompt/assemble` waterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables).
+- `systemPrompt.protect()`: reserve canonical section/tool contributions for invariants that ordinary waterfall listeners must not be able to remove or replace.
### What is NOT here
diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md
index 2c423a61d4..41c40332f0 100644
--- a/packages/core/tools/README.md
+++ b/packages/core/tools/README.md
@@ -1,6 +1,6 @@
# dsh-tools
-Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context). The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both.
+Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both.
## Service: `ToolRegistry` (ctx key: `tools`)
@@ -11,44 +11,43 @@ tools:
mode: native # native (default) | code | both
```
-`native` contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. `code` contributes exactly ONE wire tool, `run_code`, plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)). `both` contributes every native definition AND `run_code` + the SDK section. In non-native modes `run_code` is reserved presentation infrastructure rather than a filterable capability: allow/deny restrictions cannot remove it, and registering, shadowing, or explicitly filtering that name fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
+`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry's canonical contribution is the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); an assembly listener may still deliberately add unrelated schemas. `both` contributes the visible native definitions, `run_code`, and the SDK section. In non-native modes both infrastructure pieces are protected rather than filterable capabilities: restrictions and assembly listeners cannot remove them, a scoped section cannot shadow the globally protected `tools:sdk`, and registering, shadowing, or explicitly filtering `run_code` fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
### Public API
-- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations).
+- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool as a frozen snapshot. Parameters must survive lossless-JSON validation before and after cloning; scalar fields are copied, and execute/presentation callbacks are bound once to the original definition as their method receiver, so later callback-property replacement cannot change dispatch. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations).
- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap).
-- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
-- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree.
+- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. Returned definitions are the registry's frozen snapshots.
+- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` The canonical executable view — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so presentation and dispatch resolve the same frozen definitions.
- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
-- `ctx.tools.execute(exec: ToolExecution): Promise` Execute one tool call through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline.
+- `ctx.tools.guard(guard: ToolGuard): () => Promise | void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
+- `ctx.tools.execute(exec: ToolExecutionInput): Promise` Snapshot one single-use call input into a pipeline-owned execution, assign its opaque correlation token, require `arguments` to be losslessly JSON-serializable before and after cloning, deep-freeze the detached arguments, and protect its identity before running `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. Validate the final result as losslessly JSON-serializable and freeze the complete execution before `tools/result` observers run. Invalid or unstable input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log.
### Injected services
`SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`.
-### Events
+### Live events
-| Event | Mode | Purpose |
-|---|---|---|
-| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision`. Scope-filtered by `exec.agent`: an `agent.ctx` listener gates only its own agent |
-| `tools/execute` | waterfall | Around-dispatch wrapper (timeout, retry, metrics): `(exec, next)` → the dispatched `ToolExecutionResult`; `next()` is dispatch-with-normalization (resolving through the caller's visible view). Scope-filtered by `exec.agent` like the gate |
-| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` |
-| `tools/change` | emit | A tool or restriction was registered or unregistered (possibly for one scope); deliberately unfiltered |
+The live registry pipeline has three transformable waterfalls followed by the owner-final `tools/result` observation boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live and observe-only; the similarly named `tool/result` is the durable session event the agent loop appends afterwards.
### Key types
-- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
-- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
-- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
+- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model. Registration stores a frozen snapshot with detached JSON parameters and once-bound callback identities.
+- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; `arguments` must be losslessly JSON-serializable, and callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
+- `ToolExecutionToken` — a frozen, property-free identity value assigned by the registry. It supports equality correlation only and exposes no live outer execution state.
+- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
+- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry validates the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
- `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
+- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
### Extension points
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
-- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
+- `tools/pre-execute` is the reorderable allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `ctx.tools.guard()` installs scope-aware monotonic policy after that waterfall when a denial must not be overridable by listener ordering. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` is dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown or unknown tool. A wrapper may change only `exec.signal` before `next()`—adding a per-call deadline, replacing a caller signal, or restoring absence afterwards—because call identity is protected before policy begins. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own error boundary so a thrown tool still reaches `post-execute` as an `isError`. Finally, `tools/result` observes the immutable authoritative result after every transform and error boundary. All follow the typed-decision idiom shared with the `agent/*` seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
### Typed tool parameter schemas
@@ -135,13 +134,13 @@ const bash = defineTool({
Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the reserved wire transport `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per visible end-capability tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. Scope restrictions change those SDK bindings but cannot remove or replace the transport itself.
-- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of every registered tool except `run_code` (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is TOTAL: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
-- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `:code:`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode).
+- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. The registry protects this section and the `run_code` wire schema after the assembly waterfall, so Code Mode cannot silently lose either half of its transport. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
+- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `:code:`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. A sub-call's `additionalContext` is deliberately dropped because inserting it inside a running parent call would break tool-call/result adjacency.
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
-The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL.
+The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free. With no deliberate schema-adding assembly listener, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens; protection guarantees that `run_code` and `tools:sdk` remain present, not that unrelated listener additions are erased. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL.
### What is NOT here (TODO)
-- **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially.
-- **Parallel execution** — the loop currently iterates tool calls sequentially.
+- **Concurrency metadata** — tool definitions do not declare whether executions are safe to overlap.
+- **Parallel execution** — the loop and Code Mode bridge execute tool calls sequentially until that metadata exists.
diff --git a/packages/subagent/README.md b/packages/subagent/README.md
index 87930167e9..62de4ffb08 100644
--- a/packages/subagent/README.md
+++ b/packages/subagent/README.md
@@ -5,13 +5,13 @@ 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-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — |
| `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`) |
| `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — |
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) |
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
-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), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. 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 library with no provider of its own — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock.
The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md
index 8e88505366..1c5aa13374 100644
--- a/packages/subagent/subagent-inprocess/README.md
+++ b/packages/subagent/subagent-inprocess/README.md
@@ -1,6 +1,6 @@
# @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.
+The shared **in-process subagent run driver**. A library with no provider or import-time 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. Each accepted run installs one provider-owned cleanup effect. 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
@@ -8,16 +8,16 @@ The shared **in-process subagent run driver**. A pure library (no provider, no r
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); a `request.outputSchema` is asserted against the supported subset (`assertSupportedOutputSchema` from [dsh-tools](../../core/tools/README.md)) and then snapshotted with `structuredClone` before any child exists — assertion first so a hostile value fails as `OutputSchemaError` (never a raw clone error), the snapshot so a post-`start()` caller mutation cannot drift the enforced schema;
-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 deployment persona needs no inheritance — it is a context-wide prompt section);
+1. snapshots the accepted request before asynchronous owner setup: the parent and signal remain identity capabilities but are never reread from the caller-owned record; tool filter, seed, agent options, output schema, and prompt are detached. It computes child depth = `depthOf(parent) + 1` and rejects `request.maxDepth` overflow with `SubagentDepthError`; `outputSchema` is asserted before cloning so a hostile value fails as `OutputSchemaError`, while the prompt passes the session log's lossless-JSON check before and after cloning;
+2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately, and cancellation during creation is recorded and applied when a child exists;
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); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent;
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`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field).
-`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`.
+`dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope); `cancel()` records its request even before publication and cancels the child immediately once available. A cancel landing before any `turn/end` 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.
+`{ seed?: SessionEvent[] }` — the optional child-session seed: absent for spawn, or the parent's balanced completed-turn prefix for fork.
### Structured output (package-internal runtime)
@@ -25,10 +25,10 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (
- the `structured_output` capture tool with the run's REAL schema as its registered `parameters`, validating each call (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError the model retries in-turn; a valid call STAGES the value in a `WeakMap` keyed by that call's `ToolExecution` object;
- the calling instruction as an ordinary order-190 scoped prompt section (the demand travels with the tool, as prompt state of exactly one agent);
-- a scoped `system-prompt/assemble` re-assert (`prepend: true`) that post-processes its downstream chain, replacing conflicting entries with the child's capture tool and instruction — the loop logs the rendered assembly as the step's `request/header`, so the demand is reconstructable log state;
-- a scoped `tools/post-execute` COMMIT (`prepend: true`): the staged value becomes the run's result when that same execution's downstream post-execute decision accepts it. Execution-object identity prevents an orphaned stage from matching a later call even when an adapter reuses the call id;
-- a scoped `tools/pre-execute` deny for any call arriving after the capture — terminal means terminal WITHIN the step;
-- a scoped `agent/turn-continuation` veto (`prepend: true`) stopping the child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step.
+- a scoped `systemPrompt.protect()` registration making the capture instruction and schema canonical after the complete assembly waterfall. Canonical absence is protected too: pure Code Mode removes `structured_output` from the wire and declares it through the SDK. The tool registry separately owns and protects its `tools:sdk` section and reserved `run_code` transport; protection guarantees those named contributions, while unrelated listener-added schemas remain the listener's responsibility. The loop logs the finalized assembly as the step's `request/header`, so the demand remains reconstructable;
+- a scoped `tools/result` observer as the commit point: it promotes a staged value only when that same execution's immutable, JSON-safe authoritative result after the complete pre-execute → guards → execute → post-execute pipeline succeeds. For a Code Mode SDK sub-dispatch, the child's opaque `parent` token matches the enclosing `run_code` execution's registry-assigned `token`, so promotion waits for that outer final result without exposing its live object; a runtime failure or post-policy block discards the value. Execution-object identity prevents call-id reuse or another execution from reaching the stage;
+- a scoped monotonic `tools.guard()` denial for every call arriving after capture. Guards run after the extensible pre-execute waterfall and cannot return allow, so terminal means terminal within the step regardless of listener order;
+- a scoped `agent/turn-stop` terminal policy stopping the child's turn once its output is captured. It runs after ordinary continuation and steering folding, and its terminal state survives turn close and flush, so later listeners cannot leak steering into another step or turn; ordinary queued prompts remain intact.
### `depthOf(agent): number`
diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md
index 132fd4d732..e133cb1e5b 100644
--- a/packages/subagent/subagent-spawn/README.md
+++ b/packages/subagent/subagent-spawn/README.md
@@ -6,11 +6,11 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../
## 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).
+`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, and manual disposal all converge there before child publication. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose).
## Capabilities
-`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`. It constructs the child, so it enforces a recursion cap and composes the child's persona, global-tool restriction, and [structured runtime](../subagent-inprocess/README.md) inside the agent-creation setup window. This backend registers nothing at apply.
+`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`. It constructs the child, so it enforces a recursion cap and composes the child's persona, global-tool restriction, and [structured runtime](../subagent-inprocess/README.md) inside the agent-creation setup window. At apply it registers this one named provider on `ctx.subagents`; per-run contributions belong to each child's scope.
## Config