zidane 7.0.15 → 7.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +21 -14
  2. package/dist/{acp-Bw2b_kka.js → acp-DTnq3bda.js} +4 -4
  3. package/dist/{acp-Bw2b_kka.js.map → acp-DTnq3bda.js.map} +1 -1
  4. package/dist/acp-cli.js +5 -5
  5. package/dist/acp.d.ts +1 -1
  6. package/dist/acp.js +1 -1
  7. package/dist/{agent-B_uQo4f1.js → agent-C5xr7lqh.js} +170 -22
  8. package/dist/agent-C5xr7lqh.js.map +1 -0
  9. package/dist/agent.d.ts +1 -1
  10. package/dist/agent.js +1 -1
  11. package/dist/{anthropic-QbHqqTkV.js → anthropic-D_aMBEj8.js} +88 -5
  12. package/dist/anthropic-D_aMBEj8.js.map +1 -0
  13. package/dist/{approvals-B9aIQJRQ.js → approvals-D3P3Wz58.js} +48 -72
  14. package/dist/approvals-D3P3Wz58.js.map +1 -0
  15. package/dist/{auth-plw6npda.js → auth-893jSFew.js} +5 -5
  16. package/dist/auth-893jSFew.js.map +1 -0
  17. package/dist/chat/pure.d.ts +4 -4
  18. package/dist/chat/pure.js +2 -2
  19. package/dist/chat.d.ts +6 -6
  20. package/dist/chat.js +5 -5
  21. package/dist/contexts/daytona.d.ts +1 -1
  22. package/dist/contexts/e2b.d.ts +1 -1
  23. package/dist/daemon-cli.js +1 -1
  24. package/dist/eval.d.ts +1 -1
  25. package/dist/eval.js +1 -1
  26. package/dist/extensions.d.ts +1 -1
  27. package/dist/extensions.js +1 -1
  28. package/dist/headless.d.ts +1 -1
  29. package/dist/headless.js +3 -3
  30. package/dist/{index-Df2CH6bn.d.ts → index-CyOGLSQN.d.ts} +27 -16
  31. package/dist/index-CyOGLSQN.d.ts.map +1 -0
  32. package/dist/{index-Dc_zYpEu.d.ts → index-D7H81_kd.d.ts} +125 -4
  33. package/dist/index-D7H81_kd.d.ts.map +1 -0
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.js +6 -6
  36. package/dist/{ipc-T_pPnHkp.js → ipc-DY40IbcP.js} +2 -2
  37. package/dist/{ipc-T_pPnHkp.js.map → ipc-DY40IbcP.js.map} +1 -1
  38. package/dist/ipc.d.ts +5 -5
  39. package/dist/ipc.js +3 -3
  40. package/dist/mcp.d.ts +1 -1
  41. package/dist/{messages-21XfHxyF.js → messages-aORUmIKe.js} +2 -2
  42. package/dist/{messages-21XfHxyF.js.map → messages-aORUmIKe.js.map} +1 -1
  43. package/dist/{openai-compat-DY-AKxCg.js → openai-compat-cIeLSED_.js} +23 -25
  44. package/dist/openai-compat-cIeLSED_.js.map +1 -0
  45. package/dist/output/stream-json.d.ts +1 -1
  46. package/dist/output/terminal.d.ts +1 -1
  47. package/dist/plugins.d.ts +1 -1
  48. package/dist/presets.d.ts +1 -1
  49. package/dist/presets.js +1 -1
  50. package/dist/providers/anthropic.d.ts +2 -2
  51. package/dist/providers/anthropic.js +2 -2
  52. package/dist/providers/arcee.d.ts +1 -1
  53. package/dist/providers/arcee.js +1 -1
  54. package/dist/providers/baseten.d.ts +1 -1
  55. package/dist/providers/baseten.js +1 -1
  56. package/dist/providers/cerebras.d.ts +1 -1
  57. package/dist/providers/cerebras.js +1 -1
  58. package/dist/providers/local.d.ts +1 -1
  59. package/dist/providers/local.js +1 -1
  60. package/dist/providers/openai-compat.d.ts +1 -1
  61. package/dist/providers/openai-compat.js +1 -1
  62. package/dist/providers/openai.d.ts +1 -1
  63. package/dist/providers/openai.js +2 -2
  64. package/dist/providers/openrouter.d.ts +1 -1
  65. package/dist/providers/openrouter.js +1 -1
  66. package/dist/providers/xai.d.ts +1 -1
  67. package/dist/providers/xai.js +1 -1
  68. package/dist/{providers-CoK1rZ1p.js → providers-CTkZSxvp.js} +2 -2
  69. package/dist/{providers-CoK1rZ1p.js.map → providers-CTkZSxvp.js.map} +1 -1
  70. package/dist/providers.d.ts +1 -1
  71. package/dist/providers.js +3 -3
  72. package/dist/restate.d.ts +1 -1
  73. package/dist/restate.js +2 -2
  74. package/dist/{server-DXM8ZbGi.js → server-DaZqNd61.js} +5 -5
  75. package/dist/{server-DXM8ZbGi.js.map → server-DaZqNd61.js.map} +1 -1
  76. package/dist/session/sqlite.d.ts +1 -1
  77. package/dist/{session-DPZnncaQ.js → session-23Stp71-.js} +2 -2
  78. package/dist/{session-DPZnncaQ.js.map → session-23Stp71-.js.map} +1 -1
  79. package/dist/session.d.ts +1 -1
  80. package/dist/session.js +2 -2
  81. package/dist/skills.d.ts +1 -1
  82. package/dist/{tool-formatters-BgdOYAy9.d.ts → tool-formatters-9_ABHXRG.d.ts} +2 -2
  83. package/dist/{tool-formatters-BgdOYAy9.d.ts.map → tool-formatters-9_ABHXRG.d.ts.map} +1 -1
  84. package/dist/tools/fetch-url.d.ts +1 -1
  85. package/dist/tools/web-search.d.ts +1 -1
  86. package/dist/tools.d.ts +1 -1
  87. package/dist/tools.js +1 -1
  88. package/dist/{transcript-anchors-CtnwuQJM.d.ts → transcript-anchors-CbZkEMIN.d.ts} +11 -5
  89. package/dist/transcript-anchors-CbZkEMIN.d.ts.map +1 -0
  90. package/dist/tui.d.ts +67 -11
  91. package/dist/tui.d.ts.map +1 -1
  92. package/dist/tui.js +2930 -2596
  93. package/dist/tui.js.map +1 -1
  94. package/dist/{turn-operations-khAvr7KJ.d.ts → turn-operations-DsVjoO3S.d.ts} +30 -4
  95. package/dist/{turn-operations-khAvr7KJ.d.ts.map → turn-operations-DsVjoO3S.d.ts.map} +1 -1
  96. package/dist/{turn-operations-DkJ1R0p8.js → turn-operations-Dt5L8smX.js} +31 -2
  97. package/dist/{turn-operations-DkJ1R0p8.js.map → turn-operations-Dt5L8smX.js.map} +1 -1
  98. package/dist/types-CWgENVLY.js.map +1 -1
  99. package/dist/types.d.ts +1 -1
  100. package/docs/ACP.md +6 -0
  101. package/docs/ARCHITECTURE.md +179 -356
  102. package/docs/ARTIFACTS.md +145 -0
  103. package/docs/CHAT.md +426 -1312
  104. package/docs/EXECUTION_CONTEXT.md +46 -19
  105. package/docs/EXTENSIONS.md +215 -623
  106. package/docs/HEADLESS.md +5 -0
  107. package/docs/INTERACTIONS.md +21 -22
  108. package/docs/IPC.md +319 -344
  109. package/docs/PLUGINS.md +2 -0
  110. package/docs/README.md +57 -0
  111. package/docs/RESTATE.md +199 -491
  112. package/docs/RUN_IN_BACKGROUND.md +107 -558
  113. package/docs/SKILL.md +172 -238
  114. package/docs/TUI.md +216 -591
  115. package/package.json +1 -1
  116. package/dist/agent-B_uQo4f1.js.map +0 -1
  117. package/dist/anthropic-QbHqqTkV.js.map +0 -1
  118. package/dist/approvals-B9aIQJRQ.js.map +0 -1
  119. package/dist/auth-plw6npda.js.map +0 -1
  120. package/dist/index-Dc_zYpEu.d.ts.map +0 -1
  121. package/dist/index-Df2CH6bn.d.ts.map +0 -1
  122. package/dist/openai-compat-DY-AKxCg.js.map +0 -1
  123. package/dist/transcript-anchors-CtnwuQJM.d.ts.map +0 -1
package/docs/SKILL.md CHANGED
@@ -7,7 +7,9 @@ paths: "src/**/*.ts, test/**/*.ts"
7
7
 
8
8
  # Zidane Agent Framework
9
9
 
10
- Minimal TypeScript agent loop built with Bun. Streams from an LLM, executes tools, loops until done.
10
+ Minimal TypeScript agent loop built with Bun. Streams from an LLM, executes tools, loops until done. This doc is the API reference for `createAgent`, providers, tools, hooks, sessions, skills, MCP, spawning. It owns the full `AgentHooks` table — other docs link here instead of restating it. Entry point: `src/agent.ts`, `src/index.ts`, `src/types.ts`.
11
+
12
+ Contents: [Core Concepts](#core-concepts) · [Creating an Agent](#creating-an-agent) · [Providers](#providers) · [Presets and Tools](#presets-and-tools) · [Hooks](#hooks) · [Agent State and Control](#agent-state-and-control) · [Background Tasks](#background-tasks) · [Sessions](#sessions) · [Sub-agent Spawning](#sub-agent-spawning) · [Interaction Tool](#interaction-tool) · [MCP Servers](#mcp-servers) · [Progressive Tool Disclosure](#progressive-tool-disclosure) · [Skills](#skills) · [Structured Output](#structured-output) · [Tracing](#tracing) · [Execution Contexts](#execution-contexts) · [Key Types](#key-types) · [File Layout](#file-layout)
11
13
 
12
14
  ## Core Concepts
13
15
 
@@ -95,7 +97,7 @@ await agent.run({
95
97
 
96
98
  Text-encoded documents inline with attachment headers (universal); base64 documents (PDFs) need native support (Anthropic). Unsupported part types throw a clear error.
97
99
 
98
- Precedence: `run.behavior` > `agent.behavior` > defaults. `prompt` is required unless the session already has turns. See `docs/ARCHITECTURE.md`.
100
+ Precedence: `run.behavior` > `agent.behavior` > defaults. `prompt` is required unless the session already has turns. See `docs/ARCHITECTURE.md` § Option Precedence.
99
101
 
100
102
  ## Providers
101
103
 
@@ -139,9 +141,9 @@ Provider params types: `AnthropicParams`, `OpenAIParams`, `OpenRouterParams`, `C
139
141
 
140
142
  `openrouter`, `cerebras`, `arcee`, and `local` are thin wrappers over `openaiCompat` with vendor/runtime defaults pinned. For new OAI-compatible backends (Baseten, Fireworks, Groq), use `openaiCompat` directly.
141
143
 
142
- Each factory also has a per-provider subpath (`zidane/providers/anthropic`, `zidane/providers/openai`, `zidane/providers/openai-compat`, `zidane/providers/{openrouter,xai,cerebras,baseten,arcee,local}`) so a bundle pulls only the provider it uses, not the whole `zidane/providers` barrel. Cost estimation loads pi-ai's price registry through a guarded dynamic import, so edge bundles may externalize `@earendil-works/pi-ai`: providers not built on it (anthropic, openrouter, the openai-compat family) then drop the multi-MB registry and `fillEstimatedCost` falls back to the local rate table. `zidane/providers/openai` keeps pi-ai since its client is built on it.
144
+ Each factory also has a per-provider subpath (`zidane/providers/anthropic`, `zidane/providers/openai`, `zidane/providers/openai-compat`, `zidane/providers/{openrouter,xai,cerebras,baseten,arcee,local}`) so a bundle pulls only the provider it uses. Cost estimation loads pi-ai's price registry via a guarded dynamic import; providers not built on it (anthropic, openrouter, the openai-compat family) fall back to a local rate table instead of pulling the multi-MB registry. `zidane/providers/openai` keeps pi-ai since its client is built on it.
143
145
 
144
- ### Vision + image tool results
146
+ ### Vision and image tool results
145
147
 
146
148
  `Provider.meta.capabilities` exposes `{ vision?, imageInToolResult? }`:
147
149
  - `vision: false` — image blocks become `"[image omitted — model does not support vision]"` before reaching the provider.
@@ -168,7 +170,7 @@ Flatten via `toolResultToText(result)`. MCP image blocks are auto-normalized via
168
170
 
169
171
  ### Prompt caching
170
172
 
171
- `behavior.cache` (default on). The provider inserts `cache_control: { type: 'ephemeral' }` on three stable prefixes — end of system, last tool, last content block of the last message — so shared prefixes hit cache across turns.
173
+ `behavior.cache` (default on). The provider inserts `cache_control: { type: 'ephemeral' }` on three stable prefixes — end of system, last tool, last content block of the last message — so shared prefixes hit cache across turns. See `docs/ARCHITECTURE.md` § Prompt Caching for the cache-break diagnosis flow.
172
174
 
173
175
  | Provider | Behavior |
174
176
  |---|---|
@@ -178,7 +180,7 @@ Flatten via `toolResultToText(result)`. MCP image blocks are auto-normalized via
178
180
  | `cerebras` | Off. |
179
181
  | `openai` (Codex) | Not affected (pi-ai wire format). |
180
182
 
181
- Cache hits surface on `TurnUsage.cacheRead` / `cacheCreation`. Anthropic + OpenRouter populate them per turn.
183
+ Cache hits surface on `TurnUsage.cacheRead` / `cacheCreation`. Anthropic + OpenRouter populate them per turn. Live break/drift telemetry is a hook concern — see `cache:break` / `cache:drift` in the [Hook reference](#hook-reference).
182
184
 
183
185
  ### Provider error classification
184
186
 
@@ -226,30 +228,24 @@ Presets are plain `Partial<AgentOptions>` — inline literals work too, no `defi
226
228
 
227
229
  ### Tool aliasing
228
230
 
229
- `toolAliases` maps canonical → wire names. **LLM-boundary-only**:
230
-
231
- - The alias is what the provider, model, and `displayName` see.
232
- - The **canonical** name lives in `session.turns`, `ToolHookContext.name`, and dispatch lookup. Alias changes never desync persisted history.
233
- - The factory rejects collisions (two canonicals → same alias).
234
-
235
- Alias only when semantically equivalent. `shell → Bash` is safe; `list_files → Glob` is not. For distinct semantics, add a real implementation (e.g. the built-in `glob`).
231
+ `toolAliases` maps canonical → wire names, LLM-boundary-only: the alias is what the provider/model/`displayName` see; the **canonical** name lives in `session.turns`, `ToolHookContext.name`, and dispatch lookup, so alias changes never desync persisted history. The factory rejects collisions (two canonicals → same alias). Alias only when semantically equivalent (`shell → Bash` is safe; `list_files → Glob` is not — add a real implementation like the built-in `glob` instead). Full rewrite flow: `docs/ARCHITECTURE.md` § Tool Aliasing.
236
232
 
237
233
  ### Built-in tools
238
234
 
239
235
  | Tool | Purpose |
240
236
  |---|---|
241
- | `shell` | Combined stdout+stderr tail-truncated at 32 KiB. `maxOutputBytes: 0` disables. |
237
+ | `shell` | Combined stdout+stderr, tail-truncated at 32 KiB by default. Supports `run_in_background` — see [Background Tasks](#background-tasks). |
242
238
  | `readFile` | Line range, default `offset=1, limit=2000`, 256 KiB cap. Paging footer; binary marker. |
243
239
  | `writeFile` | Returns `Created` / `Updated` / `No change needed: …` for no-op detection. |
244
240
  | `edit` | Surgical `old_string` → `new_string`. Clear errors on non-unique (unless `replace_all`) / not-found (with nearest-match preview). |
245
- | `multiEdit` | Sequential edits to one file. Best-effort, per-hunk — each step runs against the file as left by the previous APPLIED step; a per-step rejection is recorded against that step alone, siblings still run. All-applied → legacy `Edited <path>: applied N edits (M replacements).`; any failure → header + per-failure lines + `<edit-outcomes>` block. Per-edit approval is a host concern: the chat layer's `tool:gate` rebinds `ctx.input.edits` to the user-approved subset before the body runs, then a `tool:transform` hook merges body + approval outcomes and appends the canonical `<edit-outcomes>` annotation (see `docs/CHAT.md`). |
241
+ | `multiEdit` | Sequential, best-effort per-hunk edits to one file — each step runs against the file as left by the previous APPLIED step. Per-edit approval is a host concern: the chat layer's `tool:gate` rebinds `ctx.input.edits` to the approved subset, then `tool:transform` appends the canonical `<edit-outcomes>` annotation (see `docs/CHAT.md`). |
246
242
  | `listFiles` | Directory listing. |
247
- | `glob` | `**`, `*`, `?` pattern matching; skips git-ignored files by default (`gitignore` arg) via `git ls-files`, with a `Bun.Glob`/`fs.glob` fallback. Shells out in docker/sandbox. |
248
- | `grep` | ripgrep + `find`-via-`exec` fallback. Skips git-ignored files by default (`gitignore` arg). Full Claude Code Grep semantics. `head_limit=250`, `offset` paginates. |
249
- | `spawn` | Sub-agent delegation (via `createSpawnTool`). |
250
- | `createInteractionTool` | HITL factory (not a tool — instantiate per use). |
243
+ | `glob` | `**`, `*`, `?` pattern matching; skips git-ignored files by default via `git ls-files`, with a `Bun.Glob`/`fs.glob` fallback. |
244
+ | `grep` | ripgrep + `find`-via-`exec` fallback. Skips git-ignored files by default. `head_limit=250`, `offset` paginates. |
245
+ | `spawn` | Sub-agent delegation (via `createSpawnTool`). See [Sub-agent Spawning](#sub-agent-spawning). |
246
+ | `createInteractionTool` | HITL factory (not a tool — instantiate per use). See [Interaction Tool](#interaction-tool). |
251
247
 
252
- `basicTools` (exported) contains `shell`, `readFile`, `writeFile`, `listFiles`, `edit`, `multiEdit`. The default `basic` preset adds `spawn`. `glob` / `grep` are opt-in.
248
+ `basicTools` (exported) contains `shell`, `readFile`, `writeFile`, `listFiles`, `edit`, `multiEdit`. The default `basic` preset adds `spawn`. `glob` / `grep` are opt-in. Default limits, truncation, and dedup behavior for each: `docs/ARCHITECTURE.md` § Tool Ergonomics Defaults.
253
249
 
254
250
  Each tool is a `ToolDef`:
255
251
 
@@ -267,9 +263,7 @@ interface ToolDef {
267
263
  * The built-in `spawn` does. Inert outside durable execution; under a durable
268
264
  * runtime it is load-bearing — `wrapAgentTools` keeps such tools out of its
269
265
  * journaling wrapper, because the child's own journaled calls cannot nest
270
- * inside the outer tool's journal entry. Miss it on a host orchestrator tool
271
- * and the invocation fails on its first retry. See `docs/RESTATE.md`
272
- * § Subagents.
266
+ * inside the outer tool's journal entry. See `docs/RESTATE.md` § Subagents.
273
267
  */
274
268
  runsNestedAgent?: boolean
275
269
  }
@@ -277,19 +271,13 @@ interface ToolDef {
277
271
 
278
272
  `ToolContext` exposes `provider`, `signal`, `execution`, `handle`, `hooks`, `turnId`, `callId`, `runId?`, `parentRunId?`, `parentCallId?`, `session?`, `readState?`, `clock?`, `depth?`, plus preset fields (`name`, `system`, `tools`, `toolAliases`, `mcpServers`, `skills`, `behavior`) so `spawn` can inherit.
279
273
 
280
- `clock` is the run's time / UUID source (`AgentClock`) — prefer it over `Date.now()` / `crypto.randomUUID()` for anything that gets persisted or shown to the model, because a durable host replaces it with a journaled one so those values replay byte-identically. If you write your own durable adapter, set `AgentClock.journaled: true` on the clock you install: consumers read it to avoid deciding on a wall-clock timer inside a journaled region (the `spawn` tool switches its per-child timeout to a turn-boundary check on that signal).
274
+ `clock` is the run's time / UUID source (`AgentClock`) — prefer it over `Date.now()` / `crypto.randomUUID()` for anything persisted or shown to the model, because a durable host replaces it with a journaled one so those values replay byte-identically. Durable adapters set `AgentClock.journaled: true`; consumers read it to avoid a wall-clock timer inside a journaled region (`spawn` switches its per-child timeout to a turn-boundary check on that signal).
281
275
 
282
276
  Returns `string` (common) or `ToolResultContent[]` (images / mixed content). Routed natively via `tool_result.content` when supported, or via companion user message on plain Chat Completions. Flatten via `toolResultToText(result)`.
283
277
 
284
278
  ### Per-tool concurrency
285
279
 
286
- The unified dispatcher walks tool calls in submission order and decides per-call whether to fan out:
287
-
288
- - **Safe siblings fan out** — concurrent-safe tools form a fleet that runs in parallel up to `behavior.maxConcurrentTools` (default `10`).
289
- - **Unsafe tools act as barriers** — the dispatcher drains the in-flight fleet, runs the unsafe call alone, then unblocks the queue. Subsequent safe calls re-form a new fleet behind it.
290
- - **Results yield in submission order** regardless of completion order — the model sees a deterministic transcript.
291
-
292
- Built-in annotations (mirrors the convention that reads parallelize, writes serialize):
280
+ The unified dispatcher walks tool calls in submission order: concurrent-safe tools fan out up to `behavior.maxConcurrentTools` (default `10`); an unsafe call drains the in-flight fleet, runs alone as a barrier, then unblocks the queue. Results yield in submission order regardless of completion order. Scheduler algorithm: `docs/ARCHITECTURE.md` § Concurrency Scheduler.
293
281
 
294
282
  | Concurrency-safe | Barrier (default) |
295
283
  |---|---|
@@ -298,21 +286,15 @@ Built-in annotations (mirrors the convention that reads parallelize, writes seri
298
286
 
299
287
  `shell.isConcurrencySafe` parses `input.command`: `ls`, `cat`, `pwd`, `git status`, `rg`, `echo`, etc. fan out (full allow-list in `tools/shell.ts → SHELL_READ_ONLY_COMMANDS` + `GIT_READ_ONLY_SUBCOMMANDS`). Anything mutating, any redirect (`>`, `>>`, `|`), any command chain (`&&`, `||`, `;`), and any subshell (`$(…)`, `` `…` ``) barriers.
300
288
 
301
- **Cascade-cancel**. A `shell` error inside a concurrent fleet cancels its in-flight siblings via the per-batch `siblingAbort` controller. Other tool errors are isolated (one read's failure doesn't kill another read). Cancelled siblings get a `Cancelled: a sibling \`shell\` call …` tool_result so the model can decide whether to retry independently.
289
+ **Cascade-cancel.** A `shell` error inside a concurrent fleet cancels its in-flight siblings via the per-batch `siblingAbort` controller. Other tool errors are isolated (one read's failure doesn't kill another read). Cancelled siblings get a `Cancelled: a sibling \`shell\` call …` tool_result so the model can retry independently.
302
290
 
303
- **Authoring custom tools**. Default is fail-closed (`false`) — any custom tool you author serializes unless you opt in. Opt in via `isConcurrencySafe: true` when:
304
-
305
- - The tool only reads from disk / network / catalog.
306
- - The tool doesn't mutate any shared state (session metadata, files, in-memory caches your other tools read).
307
- - Parallel invocations against different inputs produce the same outputs as sequential invocations.
308
-
309
- Use `isConcurrencySafe: (input) => …` when safety depends on the call's arguments (the `shell` pattern).
291
+ **Authoring custom tools.** Default is fail-closed (`false`) — a custom tool serializes unless you opt in. Opt in via `isConcurrencySafe: true` only when the tool reads without mutating shared state and parallel calls with different inputs produce the same outputs as sequential ones. Use a predicate (`(input) => …`) when safety depends on the call's arguments (the `shell` pattern).
310
292
 
311
293
  ### Tool argument validation (auto-coerce)
312
294
 
313
- `validateToolArgs(input, schema)` runs between `tool:gate` and `tool:before`. Missing/null required fields fail. Top-level coercions normalize the common OSS-model mistakes (`"true"`/`"yes"`/`"1"` → `true`, numeric strings → numbers, JSON strings → arrays/objects). `execute` always receives the **coerced** input. `validation:reject` fires only when no type reconciles — distinct from `tool:error`.
295
+ `validateToolArgs(input, schema)` runs between `tool:gate` and `tool:before`. Missing/null required fields fail. Top-level coercions normalize common OSS-model mistakes (`"true"`/`"yes"`/`"1"` → `true`, numeric strings → numbers, JSON strings → arrays/objects). `execute` always receives the **coerced** input. `validation:reject` fires only when no type reconciles.
314
296
 
315
- Array recursion: properties declared `{ type: 'array', items: { ... }, maxItems?, minItems? }` walk each item against `items` (one level of nesting — required-field check + per-property coercion + enum). Items that can't be coerced are **dropped** rather than failing the whole call; the original indexes surface on `ValidationResult.droppedItems[fieldName]`. `maxItems` truncates and reports the discarded tail. The tool's `execute` receives the post-recursion array.
297
+ Array recursion: properties declared `{ type: 'array', items: {...}, maxItems?, minItems? }` walk each item against `items` (one level of nesting). Items that can't be coerced are **dropped** rather than failing the whole call; original indexes surface on `ValidationResult.droppedItems[fieldName]`. `maxItems` truncates and reports the discarded tail.
316
298
 
317
299
  ```ts
318
300
  import { validateToolArgs } from 'zidane'
@@ -325,102 +307,130 @@ const { valid, coercedInput, coercions } = validateToolArgs(
325
307
 
326
308
  ## Hooks
327
309
 
328
- Hooks fire at every lifecycle point via [hookable](https://github.com/unjs/hookable). Awaited in registration order; ctx is shared per firing (last-writer wins). Hooks marked **mutable** influence downstream behavior; others are observational. Framework gates (`allowed-tools`, `writeFileExistingPolicy`, `toolBudgets`, `repeatGuard`, `dedupTools`, lazy disclosure) short-circuit on existing `block` / `result`, so a policy gate always beats a consumer cache substitute regardless of registration order.
310
+ Hooks fire at every lifecycle point via [hookable](https://github.com/unjs/hookable). Awaited in registration order; ctx is shared per firing (last-writer wins). Hooks marked **mutable** influence downstream behavior; others are observational. Framework gates (`allowed-tools`, `writeFileExistingPolicy`, `toolBudgets`, `repeatGuard`, `dedupTools`, lazy disclosure) short-circuit on existing `block` / `result`, so a policy gate always beats a consumer cache substitute regardless of registration order. Firing order across a turn/run: `docs/ARCHITECTURE.md` § Hook Firing Order.
329
311
 
330
- For post-edit validation, use `createMutationValidationHooks()` from `zidane`. It observes successful `tool:after.outcome` values (`created`, `updated`, `edited`) and batches them at `tool-results:after`; hosts still own the validator command and can call `agent.followUp(failure.message)` from `onFailure`. It does not wrap or replace `write_file`, `edit`, or `multi_edit`.
312
+ For post-edit validation, use `createMutationValidationHooks()`. It observes successful `tool:after.outcome` values (`created`, `updated`, `edited`) and batches them at `tool-results:after`; hosts own the validator command and can call `agent.followUp(failure.message)` from `onFailure`. It does not wrap or replace `write_file` / `edit` / `multi_edit`.
331
313
 
332
314
  ### Hook reference
333
315
 
316
+ Every event in `AgentHooks` (`src/agent.ts`), one row each. `child:*` bubble events are listed separately below the table.
317
+
334
318
  | Hook | Fires | Mutable | Key ctx fields |
335
319
  |---|---|---|---|
336
- | `session:start` | Run begins | — | `sessionId, runId, prompt` |
337
- | `skills:resolve` | Once per agent, after discovery (lazy on first `warmup()` / `run()` / `activateSkill()`, eager when `eager: true`) | — | `skills` |
338
- | `skills:catalog` | Once per agent, after catalog build (same timing as `skills:resolve`) | **yes** (`catalog`) | `catalog, skills` |
339
- | `skills:activate` | Skill activated (via `skills_use` / `activateSkill` / session resume) | — | `skill, via` (`'model' \| 'explicit' \| 'resume'`) |
340
- | `skills:deactivate` | Skill deactivated | — | `skill, reason` (`'run-end' \| 'explicit' \| 'reset' \| 'model'`) — `'model'` fires when the model calls `skills_use({ mode: 'deactivate' })` to release an `allowed-tools` restriction it ran into |
341
- | `mcp:bootstrap:start` | Per MCP server, before connect + listTools | — | `name, transport` |
342
- | `mcp:bootstrap:end` | Per MCP server, after connect attempt (always fires) | — | `name, transport, durationMs, ok` + (`toolCount, lazy?, cached?` \| `error`) |
343
- | `mcp:connect` | Per MCP server after successful bootstrap (lazy-connect: after first call) | — | `name, transport, tools, lazy?` |
344
- | `mcp:error`, `mcp:close` | MCP connection lifecycle | — | `name, error?` |
345
- | `mcp:auth:required` | Server needs OAuth login (no tokens / auto-promoted on 401+RFC9728) | — | `name, transport, reason: 'no-tokens' \| 'auto-promoted'` |
346
- | `mcp:auth:url`, `mcp:auth:success`, `mcp:auth:error` | Interactive OAuth flow lifecycle | — | `name` (+ `url` \| `error`) |
347
- | `mcp:tools:filter` | Per-server, after config-side allow/deny/predicate, before registration | **yes** (`tools[]`) | `server, transport, tools: Array<{ name, description?, inputSchema? }>` |
348
- | `mcp:tools:list` | Per-server, after `listTools()` (or `cachedTools` resolution), before filtering — observational tap for host-side caching / inspection | — | `name, transport, tools: McpToolSchema[]` |
349
- | `system:before` | Run-level system prompt set (only fires when `run({ system })`); observational | — | `system` |
350
- | `context:transform` | Before each LLM call | **yes** (`messages`) | `messages` |
351
- | `system:transform` | Per-request system-prompt mutation (after `context:transform`) | **yes** (`system`) | `system, messages (readonly), turn, turnId, session?` |
352
- | `turn:before` | Turn starts | — | `turn, turnId, options` |
353
- | `oauth:refresh` | OAuth token refreshed | — | `provider, providerId, source, previousCredentials, credentials` |
354
- | `stream:thinking` | Each reasoning chunk | — | `delta, thinking, turnId` |
355
- | `stream:text` | Each text chunk | — | `delta, text, turnId` |
356
- | `stream:end` | Text stream complete (only if text present) | — | `text, turnId` |
357
- | `stream:server_tool_use` | Provider-executed tool invocation (e.g. Anthropic `web_search_20250305`) | — | `id, name, input, turnId` |
358
- | `stream:server_tool_result` | Provider-executed tool returned | — | `toolUseId, toolName, content, turnId` |
359
- | `turn:after` | Assistant turn complete (always fires, even on error) | — | `turn, turnId, usage, message, toolCounts: { turn, run }` |
360
- | `tool-results:after` | After the user-role tool-results turn is pushed (persistence seam — write the pair together) | — | `turn, turnId, message, results: readonly ToolResult[]` |
361
- | `tool:gate` | Before each tool call | **yes** (`block, reason, result?`) | `turnId, callId, name, displayName, input, runToolCounts` — set `block` to refuse with `tool_result.content = "Blocked: <reason>"` + `is_error: true` (pinned contract); set `result` to substitute and skip execute (fires `tool:transform` + `tool:after`); `block` wins over `result` |
320
+ | `system:before` | `run({ system })` set a run-level system prompt | — (observational despite the name; the loop reads `run({ system })` directly) | `system` |
321
+ | `agent:start` | Once per `agent.run()`, after `session:start`, before the first `turn:before` | — | `runId, parentRunId?, depth, agentName?, providerName?, startedAt, tracingContext?` |
322
+ | `turn:before` | Before each turn's `provider.stream()` call | **yes** (`options`, last-mile escape hatch — prefer `context:transform` for `messages`) | `turn, turnId, options` |
323
+ | `cache:break` | Turn read back materially less cached prefix than the previous turn (prompt-cache break); only fires when `installCacheBreakLogger` is installed | — | `turn, turnId, model, changed, details` (see `CacheBreakInfo`) |
324
+ | `cache:drift` | `turn:before`'s outgoing request diverges from the previous completed turn's; predictive counterpart of `cache:break` | — | `turn, turnId, model, changed: CacheDimensionName[], details` (see `CacheDriftInfo`) |
325
+ | `turn:after` | After each assistant turn (always, even on error) | — | `turn, turnId, usage, message, aborted?, toolCounts: { turn, run }, cumulativeUsage` |
326
+ | `tool-results:after` | After the user-role tool-results turn is pushed (persistence seam — write tool_use/tool_result pairs together) | — | `turn, turnId, message, results: readonly ToolResult[]` |
327
+ | `stream:start` | Immediately before `provider.stream()` | — | `startedAt` |
328
+ | `stream:thinking` | Each reasoning chunk | — | `delta, thinking` |
329
+ | `stream:text` | Each text chunk | — | `delta, text` |
330
+ | `stream:end` | Text stream complete (only if text present) | — | `text` |
331
+ | `stream:progress` | Throttled (~4/s) while the parent turn streams, plus one final tick before `stream:end`; not bubbled for subagents | — | `elapsedMs, outputTokens, estimated, inputTokens?, cacheRead?, cacheCreation?, estCostUsd?` — provider-counted when the wire reports usage mid-stream (`StreamCallbacks.onUsage`), else chars ÷ 4; `estCostUsd` absent for unpriced models and flat-rate (subscription) traffic |
332
+ | `stream:server_tool_use` | Provider-executed tool invocation closes (e.g. Anthropic `web_search_20250305`) | — | `id, name, input` |
333
+ | `stream:server_tool_result` | Matching provider-executed tool result closes | — | `toolUseId, toolName, content` |
334
+ | `stream:error` | Provider stream rejects before `stream:end` | — | `err, statusCode?, requestId?` — never fires on user aborts (those land on `agent:abort`) |
335
+ | `stream:retry` | Loop caught a retryable provider error, about to sleep and re-issue | — | `attempt, nextAttempt, maxAttempts, delayMs, err, statusCode?, requestId?` |
336
+ | `model:fallback` | Stream failed terminally on the active target; loop advances to the next entry of the run's fallback chain | — | `from: {provider,model}, to: {provider,model}, targetIndex, err` |
337
+ | `context:overflow` | Loop caught `context_exceeded`, ran emergency in-loop compaction, is re-issuing the stream | — | `attempt, err` |
338
+ | `compact:start` | `behavior.autoCompact` threshold crossed, about to run the summary call | — | `runId, sessionId?, turn, reason: 'auto', usedFraction, effectiveWindow` |
339
+ | `compact:progress` | Throttled, while the compaction summary streams | — | `+ estOutputTokens` |
340
+ | `compact:end` | Exactly once per `compact:start`, on success or failure | — | `+ status: 'success' \| 'error'` (success: replaced/dropped/restored counts + `effectiveTokens`; error: `error`) |
341
+ | `history:repair` | After a provider stream settles when the loop scrubbed provider-rejected reasoning state (e.g. Anthropic signature-400 poisoning) | — | `reason: 'invalid-anthropic-thinking-signature', runId, sessionId?, turnIds, removedBlocks` |
342
+ | `oauth:refresh` | OAuth token refreshed | — | `provider, providerId, source: 'params'\|'file', previousCredentials, credentials` |
343
+ | `content-ref:resolve` | Wire assembly sees a block carrying `ref`; resolves persisted content refs into provider-bound bytes | **yes** (`data`) | `block, data?, childId?, depth?` |
344
+ | `tool:gate` | Before each tool call | **yes** (`block, reason, result?`) | `turnId, callId, name, displayName, input, runToolCounts` — `block` refuses with `tool_result.content = "Blocked: <reason>"` + `is_error: true`; `result` substitutes and skips execute; `block` wins over `result` |
362
345
  | `tool:before` | Tool execution starts | — | `turnId, callId, name, displayName, input, runToolCounts, priorContent?` |
346
+ | `tool:dispatched` | Fires for every dispatch path (gate-block/gate-substitute/unknown/invalid-input/execute), before `tool:before` on the execute path | — | `outcome: 'execute'\|'gate-substitute'\|'gate-block'\|'unknown'\|'invalid-input', reason?, runToolCounts` |
363
347
  | `tool:unknown` | Model called an unregistered tool | **yes** (`result?, suppressError`) | `turnId, callId, name, displayName, input` |
364
- | `validation:reject` | Tool args failed validation (after auto-coerce) | — | `+ reason: string, schema: Record<string, unknown>` |
365
- | `validation:coerce` | Tool args were auto-healed (only fires when fields changed) | — | `+ coercions: readonly string[], schema: Record<string, unknown>` |
348
+ | `validation:reject` | Tool args failed validation (after auto-coerce) | — | `+ reason: string, schema` |
349
+ | `validation:coerce` | Tool args were auto-healed (only when fields changed) | — | `+ coercions: readonly string[], schema` |
366
350
  | `mcp:tool:gate` | Before each MCP call | **yes** (`block, reason, result?`) | `turnId, callId, server, tool, displayName, input` |
367
351
  | `mcp:tool:before` | MCP execution starts | — | `turnId, callId, server, tool, displayName, input` |
368
- | `mcp:tool:transform` | After MCP result | **yes** (`result`) | `+ result: string \| ToolResultContent[], outputBytes: number` |
369
- | `mcp:tool:after` | MCP complete | — | `+ result: string \| ToolResultContent[], outputBytes: number` |
352
+ | `mcp:tool:transform` | After MCP result | **yes** (`result`) | `+ result, outputBytes` |
353
+ | `mcp:tool:after` | MCP complete | — | `+ result, outputBytes` |
370
354
  | `mcp:tool:error` | MCP error | — | `+ error` |
371
- | `tool:transform` | After tool result | **yes** (`result`, `isError`) | `+ result: string \| ToolResultContent[], isError, outputBytes: number` |
372
- | `tool:after` | Tool complete | — | `+ result: string \| ToolResultContent[], outputBytes: number, runToolCounts, outcome?` |
355
+ | `tool:transform` | After tool result | **yes** (`result`, `isError`) | `+ result, isError, metadata, outputBytes, coercions?` |
356
+ | `artifact:recorded` | Once per artifact the session recorded from a tool result's `metadata.artifacts` namespace | — | `runId, turnId, callId, name, artifact: SessionArtifact` — see `docs/ARTIFACTS.md` |
357
+ | `tool-state:commit-failed` | A settled call's durable state (`ToolContext.state`) could not be committed to `AgentOptions.toolState` | **yes** (`handled`) | `runId, callId, toolName, ops: readonly ToolStateOp[], error, handled?` — set `handled = true` to suppress the default stderr log |
358
+ | `tool:after` | Tool complete | — | `+ result, outputBytes, runToolCounts, outcome?` |
373
359
  | `tool:error` | Tool error | **yes** (`result?`) | `+ error` — mutate `result` to substitute the payload sent back to the model |
374
- | `tool:cancelled` | Per-call cancel via `agent.cancelTool(callId, reason?)` (typically a TUI "cancel this tool" affordance) | — | `+ reason: string, runToolCounts` — wire result is the canonical `TOOL_USE_CANCELLED_MESSAGE` with `isError: true`; `tool:transform` + `tool:after` do **not** fire for the cancelled call |
375
- | `background:start` | Background task spawned via `shell({ run_in_background: true })` | — | `taskId, pid, command, cwd, outputPath, startedAt` — observational; subscribe for telemetry / "task started" toasts |
376
- | `background:exit` | Background task terminated (natural exit, `shell_kill`, or `agent.destroy()` teardown) | — | `taskId, status: 'exited' \| 'killed', exitCode, signal?, outputPath, durationMs, command` — fires at-most-once per task; the agent's internal listener queues a `<task-notification>` for injection on the next user-turn |
377
- | `execution:ready` | Execution handle bound (first run's lazy `spawn()`, or immediately when `AgentOptions.handle` was injected) | — | `handle, spawned` — fires once per agent; `spawned: false` for an injected handle. Durable hosts journal the minted handle id here for re-attach on the next invocation |
378
- | `usage` | Per-turn usage | — | `turn, turnId, usage, totalIn, totalOut` |
379
- | `output` | Structured output extracted | — | `output, schema` |
380
- | `budget:exceeded` | Per-turn tool-output bytes over `behavior.toolOutputBudget` | — | `turn, turnId, bytes, budget` |
381
- | `tool-budget:exceeded` | Per-tool call cap from `behavior.toolBudgets` tripped | — | `tool, count, max, turnId, mode: 'steer' \| 'block'` |
382
- | `repeat-guard:exceeded` | Repeat detector from `behavior.repeatGuard` tripped | — | `tool, count, threshold, turnId, action: 'block' \| 'steer' \| 'abort'` — `'steer'` means the tool ran and got steering appended; `'abort'` fires before the run is aborted via the agent's `AbortController` |
360
+ | `tool:cancelled` | Per-call cancel via `agent.cancelTool(callId, reason?)` | — | `+ reason: string, runToolCounts` — wire result is `TOOL_USE_CANCELLED_MESSAGE`; `tool:transform` / `tool:after` do **not** fire |
361
+ | `context:transform` | Before each LLM call | **yes** (`messages`) | `messages` |
362
+ | `system:transform` | Per-request system-prompt mutation (after `context:transform`) | **yes** (`system`) | `system, messages (readonly), turn, turnId, session?` |
383
363
  | `steer:inject` | Steering/follow-up injected | — | `message` |
384
- | `session:turns` | Turns persisted | — | `sessionId, turns, count` |
385
- | `session:save` | Session store write | — | `sessionId` |
386
- | `session:meta` | Session metadata write | — | `sessionId, key: string, value: unknown` |
387
364
  | `spawn:before`, `spawn:complete`, `spawn:error` | Sub-agent lifecycle | — | `id, task, callId?, stats?, error?` (`callId` = the spawn call's own id, i.e. the child's `parentCallId`) |
388
- | `session:start`, `session:end` | Run boundaries (always fire) | — | `sessionId, runId` + (`prompt` \| `status, turnRange`) |
365
+ | `mcp:connect` | Server's client is live (bootstrap, or first `tools/call` for `lazyConnect`) | — | `name, transport, tools, lazy?` |
366
+ | `mcp:error` | Fatal MCP connection failure | — | `name, error` |
367
+ | `mcp:warn` | Diagnostic line from a server | — | `name, message` |
368
+ | `mcp:close` | MCP connection closed | — | `name` |
369
+ | `mcp:bootstrap:start` | Per server, before connect + listTools | — | `name, transport` |
370
+ | `mcp:bootstrap:end` | Per server, after connect attempt (always fires) | — | `name, transport, durationMs, ok` + (`toolCount, lazy?, cached?` \| `error`) |
371
+ | `mcp:bootstrap:settled` | Once per bootstrap batch, after every server's attempt settled | — | `results: ReadonlyArray<{name, transport, durationMs} & ({ok:true, toolCount, lazy?}\|{ok:false, skipped?, error?})>`, sorted by name |
372
+ | `mcp:tools:list` | Per server, after `listTools()` (or `cachedTools` resolution), before filtering | — | `name, transport, tools: McpToolSchema[]` |
373
+ | `mcp:auth:required` | Server needs OAuth login (no tokens / auto-promoted on 401+RFC9728) | — | `name, transport, reason: 'no-tokens'\|'auto-promoted'` |
374
+ | `mcp:auth:url`, `mcp:auth:success`, `mcp:auth:error` | Interactive OAuth flow lifecycle | — | `name` (+ `url` \| `error`) |
375
+ | `mcp:tools:filter` | Per server, after config-side allow/deny/predicate, before registration | **yes** (`tools[]`) | `server, transport, tools: Array<{name, description?, inputSchema?}>` |
376
+ | `background:start` | Background task spawned via `shell({ run_in_background: true })` | — | `taskId, pid, command, cwd, outputPath, startedAt` |
377
+ | `background:exit` | Background task terminated (natural exit, `shell_kill`, or `agent.destroy()` teardown) | — | `taskId, status: 'exited'\|'killed', exitCode, signal?, outputPath, durationMs, command` — at most once per task |
378
+ | `background:stall` | Running task produced no output for `behavior.backgroundStallWatchdogMs` | — | `taskId, command, outputPath, stalledForMs, bytesWritten` — one-shot per quiet period, re-arms after fresh output |
379
+ | `background:reconcile` | Merged push+pull background-exit list, before rendering `<task-notification>` blocks | **yes** (`exits`) | `exits: TaskExitInfo[]` — durable hosts journal it here |
380
+ | `background:reassign` | A destroyed subagent's still-running task was promoted to this agent's handle; fires on the NEW owner's bus | — | `taskId, fromHandleId, toHandleId, childId?, pid, command, outputPath, startedAt` — see [RUN_IN_BACKGROUND.md](./RUN_IN_BACKGROUND.md) |
381
+ | `execution:ready` | Execution handle bound (first run's lazy `spawn()`, or immediately for an injected handle) | — | `handle, spawned` — fires once per agent |
382
+ | `skills:resolve` | Once per agent, after discovery (lazy on first `warmup()`/`run()`/`activateSkill()`, eager when `eager: true`) | — | `skills` |
383
+ | `skills:catalog` | Once per agent, after catalog build (same timing as `skills:resolve`) | **yes** (`catalog`) | `catalog, skills` |
384
+ | `skills:activate` | Skill activated (via `skills_use` / `activateSkill` / session resume) | — | `skill, via: 'model'\|'explicit'\|'resume'` |
385
+ | `skills:deactivate` | Skill deactivated | — | `skill, reason: 'run-end'\|'explicit'\|'reset'\|'model'` — `'model'` when the model calls `skills_use({ mode: 'deactivate' })` |
386
+ | `pairing:repair` | Defensive tool_use/tool_result pairing pass repaired corrupted history | — | `mode, callId?, messageIndex, turnId?` |
387
+ | `tracing:redact` | Privacy seam before an observability adapter attaches a payload as a span attribute / log line | **yes** (`redacted`) | `kind: 'system'\|'prompt'\|'tool-input'\|'tool-result'\|'mcp-tool-input'\|'mcp-tool-result'\|'stream-text'\|'stream-thinking', value, redacted, meta?` — synchronous only |
388
+ | `usage` | Per-turn usage | — | `turn, turnId, usage, totalIn, totalOut` |
389
+ | `output` | Structured output extracted | — | `output, schema` |
390
+ | `budget:exceeded` | Per-turn tool-output bytes over `behavior.toolOutputBudget` (measured post-`tool:transform`) | — | `turn, turnId, bytes, budget` |
391
+ | `tool-budget:exceeded` | Per-tool call cap from `behavior.toolBudgets` tripped | — | `tool, count, max, turnId, mode: 'steer'\|'block'` |
392
+ | `repeat-guard:exceeded` | Repeat detector from `behavior.repeatGuard` tripped | — | `tool, count, threshold, turnId, action: 'block'\|'steer'\|'abort'` |
389
393
  | `agent:abort` | `agent.abort()` called | — | `{}` |
390
394
  | `agent:done` | Run finished (all exit paths) | — | `AgentStats` |
395
+ | `replay:done` | Durable-runtime adapter (e.g. `zidane/restate`) finished replaying/executing one handler invocation | — | `{}` — never fired by core; at most once per invocation |
396
+ | `session:start`, `session:end` | Run boundaries (always fire) | — | `sessionId, runId` + (`prompt` \| `status, turnRange`) |
397
+ | `session:turns` | Turns persisted | — | `sessionId, turns, count` |
398
+ | `session:meta` | Session metadata write | — | `sessionId, key, value` |
399
+ | `session:save` | Session store write | — | `sessionId` |
391
400
 
392
- ### Mutation contract
401
+ Every `tool:*` / `mcp:tool:*` row above also omits four optional identity fields for readability — see [Identity fields](#identity-fields-on-tool-hook-contexts).
393
402
 
394
- Mutable ctx fields: `tool:gate` (`block`, `reason`, `result`), `tool:transform` (`result`, `isError`), `mcp:tool:transform` (`result`), `tool:error` + `tool:unknown` (`result`), `context:transform` (`messages`), `system:transform` (`system`), `skills:catalog` (`catalog`), `mcp:tools:filter` (`tools`). `system:before` is observational despite the name — `run({ system })` is read directly, ctx mutations are dropped.
403
+ ### Mutation contract
395
404
 
396
405
  `outputBytes` is the wire size — **pre-mutation** on `*:transform` (truncation handlers decide), **post-mutation** on `*:after` (what the model sees). Reproduce via `toolOutputByteLength(content)`. `ctx.coercions` is **omitted** on `tool:before` / `tool:after` / `tool:transform` when no coercion happened; guard with `if (ctx.coercions)`.
397
406
 
398
407
  ### Identity fields on tool hook contexts
399
408
 
400
- Every `tool:*` and `mcp:tool:*` hook ctx additionally carries four optional identity fields (omitted from the per-row "key ctx fields" column above to keep the table readable):
409
+ Every `tool:*` and `mcp:tool:*` hook ctx additionally carries four optional identity fields (omitted from the table above to keep it readable):
401
410
 
402
- - `runId?: string` — the `SessionRun.id` this call belongs to. Lets one listener disambiguate calls across parallel runs / subagent trees without subscribing to `child:tool:*`.
403
- - `parentRunId?: string` — set when the call's agent is a subagent (mirrors `SessionRun.parentRunId`); absent on top-level runs. Drives UI grouping for subagent-scoped state (todowrite batches, edit groups, etc.) without resolving the run row. Only wired when the spawn shares the parent's session (`persist`) — for grouping that must hold on every subagent, use `parentCallId`.
404
- - `parentCallId?: string` — the `callId` of the `spawn` call that created this run; absent on top-level runs. Set for every child the built-in `spawn` tool starts (it forwards its own call id; no session required), so hosts mirroring subagent work into a client protocol can use it as the "parent tool use" grouping key — absent at depth 0, a real tool-call id at depth ≥ 1. Two qualifications: it is not implied by `depth >= 1` (a host that spawns by calling `agent.run({ depth })` itself must also pass `AgentRunOptions.parentCallId`), and it resolves against the parent's own `tool:*` stream only at depth 1 — at depth ≥ 2 it names a call in the child's turn, which the top-level listener saw only as `child:tool:*`. Same ancestry semantics as `childId` / `depth` on `child:*` events. The spawn payloads (`spawn:before` / `:complete` / `:error`) carry the same id as `callId`, which is where a host learns the `childId` → call mapping for a child that never dispatches a tool.
411
+ - `runId?: string` — the `SessionRun.id` this call belongs to. Disambiguates calls across parallel runs / subagent trees without subscribing to `child:tool:*`.
412
+ - `parentRunId?: string` — set when the call's agent is a subagent (mirrors `SessionRun.parentRunId`); absent on top-level runs. Only wired when the spawn shares the parent's session (`persist`) — for grouping that must hold on every subagent, use `parentCallId`.
413
+ - `parentCallId?: string` — the `callId` of the `spawn` call that created this run; absent on top-level runs, set for every child the built-in `spawn` tool starts. Not implied by `depth >= 1` (a host that spawns via `agent.run({ depth })` directly must also pass `AgentRunOptions.parentCallId`); at depth ≥ 2 it names a call the top-level listener only saw as `child:tool:*`.
405
414
  - `depth?: number` — subagent depth, 0 = top-level. Mirrors `ToolContext.depth`.
406
415
 
407
- All four land on `ToolContext` as well (visible to the tool body via `ctx.runId` / `ctx.parentRunId` / `ctx.parentCallId` / `ctx.depth`). The loop centralizes their construction in `buildToolHookBase`, so a new tool-related hook event automatically inherits them.
416
+ All four also land on `ToolContext` (`ctx.runId` / `ctx.parentRunId` / `ctx.parentCallId` / `ctx.depth`), centralized in `buildToolHookBase` so new tool-related hooks inherit them automatically.
408
417
 
409
418
  ### Child (sub-agent) bubble events
410
419
 
411
- `spawn`'s stream/tool/turn events bubble to the parent with `childId` + `depth`:
420
+ `spawn`'s stream/tool/turn/artifact events bubble to the parent with `childId` + `depth`:
412
421
 
413
422
  ```
414
423
  child:stream:text / child:stream:thinking / child:stream:end
415
- child:stream:server_tool_use / child:stream:server_tool_result
424
+ child:stream:server_tool_use / child:stream:server_tool_result / child:stream:error / child:stream:retry
416
425
  child:tool:gate / child:mcp:tool:gate ← mutable: block/reason/result propagate to the child
417
426
  child:tool:transform ← mutable: parent can rewrite the child's tool_result
418
- child:tool:before / child:tool:after / child:tool:error / child:tool:cancelled
419
- child:background:start / child:background:exit ← bubbled from subagent's background tasks
427
+ child:tool:before / child:tool:dispatched / child:tool:after / child:tool:error / child:tool:cancelled
428
+ child:background:start / child:background:exit / child:background:reassign
420
429
  child:turn:after
430
+ child:artifact:recorded
421
431
  ```
422
432
 
423
- Render nested activity without listening on the child instance. The `child:*:gate` and `child:tool:transform` events are special: they share the same `ctx` reference the child's loop awaits on (see `BUBBLED_MUTABLE_EVENTS` in `src/tools/spawn.ts`), so a parent gate handler can refuse / substitute / annotate a child's tool call without registering on the child agent. The chat layer's per-edit annotation flow relies on `child:tool:transform` to append `<edit-outcomes>` blocks onto a subagent's `multi_edit` / `edit` / `write_file` results.
433
+ Render nested activity without listening on the child instance. The `child:*:gate` and `child:tool:transform` events share the same `ctx` reference the child's loop awaits on (`BUBBLED_MUTABLE_EVENTS` in `src/tools/spawn.ts`), so a parent gate handler can refuse / substitute / annotate a child's tool call without registering on the child agent. `child:background:reassign` fires when a grandchild's background tasks get promoted to the child's handle on the grandchild's destroy — the chain runs at each spawn boundary until the top-level parent owns every task.
424
434
 
425
435
  ### Hook recipes
426
436
 
@@ -453,7 +463,7 @@ agent.hooks.hook('context:transform', (ctx) => {
453
463
  })
454
464
  ```
455
465
 
456
- `mcp:tool:transform`, `tool:unknown`, and `context:transform` are the highest-leverage entries for cases v3 doesn't auto-handle. Most production agents end up with one of each.
466
+ `mcp:tool:transform`, `tool:unknown`, and `context:transform` are the highest-leverage entries for cases the framework doesn't auto-handle. Most production agents end up with one of each.
457
467
 
458
468
  ## Agent State and Control
459
469
 
@@ -470,7 +480,7 @@ await agent.waitForIdle()
470
480
  await agent.destroy() // clean up execution + MCP
471
481
  ```
472
482
 
473
- `cancelTool` returns `true` when a live call was flipped, `false` otherwise (idempotent). The matching call's `ctx.signal` aborts, `tool:cancelled` fires, and the wire result becomes `TOOL_USE_CANCELLED_MESSAGE` with `isError: true` — other parallel calls in the same batch keep running, and the assistant turn closes normally on the next batch boundary. Distinguished from `abort()` (whole-run abort, batch-layer `INTERRUPT_MESSAGE_FOR_TOOL_USE`) and `TOOL_USE_SKIPPED_MESSAGE` (steered out) so consumers can split the three causes by string match. A cancel issued **before** the call dispatches (the host saw the id in the streamed turn) is parked and applied the moment the call registers, not dropped (it still returns `false` — no live call was flipped yet). `createAgent({ toolKill: { defaultMessage } })` sets the reason stamped on the signal / `tool:cancelled` when `cancelTool(callId)` is called without one.
483
+ `cancelTool` returns `true` when a live call was flipped, `false` otherwise (idempotent). The matching call's `ctx.signal` aborts, `tool:cancelled` fires, and the wire result becomes `TOOL_USE_CANCELLED_MESSAGE` with `isError: true` — other parallel calls in the same batch keep running. Distinguished from `abort()` (whole-run abort, batch-layer `INTERRUPT_MESSAGE_FOR_TOOL_USE`) and `TOOL_USE_SKIPPED_MESSAGE` (steered out) so consumers can split the three causes by string match. A cancel issued **before** the call dispatches is parked and applied the moment the call registers (still returns `false` — no live call was flipped yet). `createAgent({ toolKill: { defaultMessage } })` sets the reason stamped when `cancelTool(callId)` is called without one.
474
484
 
475
485
  Agents also implement `Symbol.asyncDispose` (aliased to `destroy`) so they work with TC39 explicit-resource-management:
476
486
 
@@ -482,68 +492,18 @@ await agent.run({ prompt: 'go' })
482
492
 
483
493
  Requires Node 20+ (or Bun). Idempotent, same semantics as calling `destroy()` directly.
484
494
 
485
- ## Background tasks
495
+ ## Background Tasks
486
496
 
487
- The `shell` tool accepts an optional `run_in_background: true` flag that returns immediately with a task handle (`bash_<n>`) instead of blocking the model's turn. The companion `shell_kill` tool terminates a running task, and `wait_task({ task_id, timeout_ms })` blocks until a task exits (or the timeout elapses) for the rare cases where the model's next step genuinely depends on the outcome. The framework owns the lifecycle:
497
+ `shell({ run_in_background: true })` returns immediately with a task handle instead of blocking the model's turn; `shell_kill` terminates a task; `wait_task` blocks until one exits. Enable by wiring a task log directory:
488
498
 
489
499
  ```ts
490
500
  import { createAgent, resolveTasksDir } from 'zidane'
491
501
 
492
502
  const tasksDir = resolveTasksDir({ userDir: '~/.zidane', sessionId: session.id })
493
- const agent = createAgent({
494
- provider,
495
- session,
496
- behavior: { tasksDir }, // host opt-in for background mode
497
- // ... rest of options
498
- })
503
+ const agent = createAgent({ provider, session, behavior: { tasksDir } })
499
504
  ```
500
505
 
501
- **Disabling background mode.** Background mode is auto-disabled at the schema level (the `run_in_background` field is dropped from the `shell` tool's input schema, and the background-mode paragraphs are dropped from its description) when either:
502
-
503
- - `behavior.tasksDir` is **unset** — the host hasn't wired the log dir, so the model would only ever get an error if it tried. The auto-disable means the model never sees the flag in the first place; no wasted turns.
504
- - `behavior.disableBackgroundTasks: true` is set — explicit opt-out for hosts who have `tasksDir` for some other reason (legacy fixture, integration test) but don't want the model spawning background work this run.
505
- - `behavior.disableBackgroundTasks: 'non-durable'` is set and the execution context's `capabilities.detachedTasks` is below `'durable'` — the capability-aware form durable-execution hosts use (`restateBehavior()` defaults to it), so remote contexts whose tasks survive worker crashes keep background mode while host-local processes don't.
506
- - the execution context's `capabilities.detachedTasks` is `'none'` — the context can't background at all, so the schema would advertise a dead flag.
507
-
508
- The path is per-session by convention (`<userDir>/sessions/<sessionId>/tasks/`) so a session delete cleans up log files alongside other session state.
509
-
510
- The auto-rewrite only fires for the identity-equal framework `shell` tool — hosts who register a custom shell-named tool keep ownership of their spec. For explicit control, hosts can import `createShellTool({ allowBackground })` from `zidane` and register the tailored variant directly.
511
-
512
- **Task lifecycle.**
513
-
514
- 1. Model calls `shell({ command: 'npm run build', run_in_background: true })`.
515
- 2. The shell tool spawns a process group via `ExecutionContext.execBackground`, opens `<tasksDir>/<task-id>.<context-timestamp>.log` (append mode), pipes stdout+stderr into it, and returns immediately with the task id + pid + log path. `background:start` fires. The `<context-timestamp>` segment (`YYYYMMDD-HHMMSS-mmm`, UTC, shared across every task in the same `ExecutionContext` instance) keeps two contexts on the same session from ever resolving to the same log file — the model-facing task id (`bash_1`) stays short.
516
- 3. The process exits naturally OR via `shell_kill({ task_id })`. `background:exit` fires exactly once, with `status: 'exited' | 'killed'`.
517
- 4. The agent's internal `background:exit` listener queues a `TaskExitInfo` (keyed by `taskId` in a `Map`).
518
- 5. On the NEXT `agent.run()`, the agent **reconciles**: the push-fed queue is merged with a `listBackground` sweep for terminated tasks the model hasn't been told about (the pull side is what makes remote/durable contexts work — `onExit` is optional on the contract, since a remote context can't push from a timer into the host). The merged list passes through the mutable `background:reconcile` hook (durable hosts journal it there), then each entry becomes a leading `text` content block on the seeded user turn carrying `<task-notification>…</task-notification>` XML — the model reads task completions inline with its prompt, no polling needed. A per-agent `notified` latch keeps the pull sweep from re-notifying the same terminated task on later runs.
519
-
520
- **Suppression.** The agent's `tool:after` listener watches for `shell_kill` (deletes from the queue by `task_id`), `wait_task` (deletes by `task_id` when the wait returned exit info — timeouts don't suppress, the eventual exit is still news), and `read_file` / `read` (deletes by matching the file's `path` against any pending entry's `outputPath`). This keeps the model from receiving a redundant `<task-notification>` for an exit it already learned about through other means.
521
-
522
- **Guardrails.** Two opt-in behavior knobs protect long unattended jobs:
523
-
524
- - `behavior.backgroundOutputCap` (bytes) — past the cap the task keeps running but further output is dropped; a `<output-truncated bytes-dropped="N"/>` marker is appended to the log when the task settles.
525
- - `behavior.backgroundStallWatchdogMs` — when a running task produces no output for the window, a `background:stall` hook fires and a one-shot `<task-stall>` notification is queued for the model's next turn (re-arms after fresh output). The process is NOT killed — the model decides, typically by reading the log tail and `shell_kill`ing a task stuck at an interactive prompt.
526
-
527
- **Output reading.** The model reads task output via the normal `read_file` tool — no special API. The file lives at the absolute path returned in the spawn result; offset + limit work for tail-watching. The file stays around until the session is deleted.
528
-
529
- **Shutdown.** Two paths:
530
-
531
- 1. **Clean shutdown (`agent.destroy()`)** — SIGTERMs every still-running task (entire process groups, so no orphans), awaits each log stream's flush, then tears down the execution handle. Notification queue cleared. The TUI invokes this via its session-teardown flow.
532
- 2. **Process-exit safety net (Ctrl+C, crash)** — `ProcessContext` lazy-registers a `process.on('exit')` handler on the first spawn that synchronously SIGTERMs every still-running task's process group. Output streams may not flush their last buffer (Node's `'exit'` listeners can't await), but the children DO die — no orphan `npm run dev` outliving a Ctrl+C'd TUI. The handler is deregistered in `destroy()` so repeated session-swap cycles don't pile listeners onto the global event emitter.
533
-
534
- **Replay.** Persisted `<task-notification>` text blocks survive in the session's turn history; `eventsFromTurns` re-synthesizes them as `'task-notification'` `StreamEvent`s on load so the TUI's banner renderer paints them the same way live + replayed.
535
-
536
- **Subagents.** A `spawn`'d subagent can call `shell({ run_in_background: true })` — but it has no "next user-turn" of its own to receive the `<task-notification>` on (a subagent's `agent.run()` ends as soon as the model returns `done: true`). The plumbing handles this transparently:
537
-
538
- 1. Subagent starts the task → registered under the subagent's `ExecutionHandle`.
539
- 2. Subagent's `agent.run` finishes (the model SHOULD return a brief summary that includes the `task_id` and end its turn — the shell tool's success-result is depth-aware and tells the model exactly this).
540
- 3. `spawn.ts`'s `finally` calls `ExecutionContext.reassignBackgroundTasks(childHandle, parentHandle, newOnExit)` BEFORE `agent.destroy()`. The task's `handleId` is rewritten to the parent's, and the task's natural-exit callback is rewired from the (about-to-be-destroyed) subagent's hook bus to the parent's. `background:reassign` fires on the parent's bus.
541
- 4. Subagent's `destroy()` walks its registry and finds no surviving tasks (they've all been promoted), so it kills nothing.
542
- 5. When the task eventually exits, `background:exit` fires on the PARENT's bus → enqueued → drained on the parent's next user-turn.
543
-
544
- For nested subagents (grandchild spawns a background task while running inside a child that itself was spawned by the parent), the reassign chain runs at each spawn finally: grandchild → child at grandchild-finish, child → parent at child-finish. The task ends up owned by the top-level parent. `child:background:reassign` bubbles through so observability hooks see the full chain.
545
-
546
- **Context support.** Background mode is OPTIONAL on `ExecutionContext` — the methods (`execBackground`, `killBackground`, `listBackground`, `waitBackground`) are declared as `?:` so existing contexts compile unchanged. The `ProcessContext` (in-process default) implements all of them; remote sandboxes that don't support detached spawns can leave them undefined and the shell tool surfaces a clean error. Contexts declare their task-lifetime guarantee via `capabilities.detachedTasks` (`'none' | 'process-lifetime' | 'durable'`; `resolveDetachedTasksCapability` infers `'process-lifetime'` for legacy contexts that implement `execBackground` without the field). The same is true for `agent.destroy()`'s teardown — it walks the registry only if the context exposes one.
506
+ Without `behavior.tasksDir` (or with `behavior.disableBackgroundTasks`, or a context whose `capabilities.detachedTasks` doesn't qualify), the `run_in_background` field is dropped from `shell`'s schema entirely — the model never sees a flag it can't use. Task completions are delivered as `<task-notification>` XML on the model's next turn, no polling. Full lifecycle (guardrails, shutdown, subagent task reassignment, replay, per-context support): `docs/RUN_IN_BACKGROUND.md`.
547
507
 
548
508
  ## Sessions
549
509
 
@@ -566,31 +526,21 @@ const loaded = await loadSession(store, session.id)
566
526
  | `createRemoteStore({ url })` | `zidane` | Server-side | Delegated |
567
527
  | `createFileMapStore(adapter)` | `zidane` | `meta.json` + `turns.jsonl` | Single session, auto-creates |
568
528
 
569
- Turns persist incrementally via `session:turns` — crashes leave partial-but-valid history. Call `session.save()` only when your store batches writes. `session.isEmpty` is a "first prompt" signal.
570
-
571
- `createFileMapStore(adapter)` wraps any `{ get, save, delete }` backend. Format is `turns.jsonl` (one `SessionTurn` per line) + `meta.json` — JSONL is debuggable and survives partial corruption (parse up to the first bad line).
529
+ Turns persist incrementally via `session:turns` — crashes leave partial-but-valid history. Call `session.save()` only when your store batches writes. `session.isEmpty` is a "first prompt" signal. `createFileMapStore(adapter)` wraps any `{ get, save, delete }` backend; JSONL is debuggable and survives partial corruption (parse up to the first bad line).
572
530
 
573
531
  ### SQLite schema migrations (v3 → v4)
574
532
 
575
- `createSqliteStore` migrates older DBs in place on first open. Before any destructive change, the migration writes a point-in-time snapshot to `<db>.zidane-pre-v4.bak` (WAL checkpointed first so the copy is consistent). The snapshot is created once and never overwritten on subsequent opens — re-runs of the migration reuse it. A one-line stderr notice on first migration tells you the path and the rollback procedure.
576
-
577
- **Rollback procedure.** If a session loads incorrectly after migration:
578
-
579
- 1. Quit every zidane process holding the DB.
580
- 2. Restore the snapshot and clear the now-stale WAL sidecars:
581
-
582
- ```sh
583
- DB=~/.zidane/sessions.db # adjust to your path
584
- cp "$DB.zidane-pre-v4.bak" "$DB"
585
- rm -f "$DB-wal" "$DB-shm"
586
- ```
533
+ `createSqliteStore` migrates older DBs in place on first open, writing a point-in-time snapshot to `<db>.zidane-pre-v4.bak` before any destructive change (created once, never overwritten on later opens). A one-line stderr notice on first migration reports the path.
587
534
 
588
- 3. Either pin the previous zidane version (it reads v3 directly) or upgrade to a fixed release (the new migration is idempotent — already-correct rows are skipped via a `turns` row-count check; bad rows are re-attempted). Either path leaves the `.bak` untouched so you can roll back again.
589
- 4. Restart zidane.
535
+ **Rollback:** quit every zidane process holding the DB, then:
590
536
 
591
- **Per-session recovery** (don't want to roll back the whole DB): the `.bak` is a fully valid v3 SQLite file. Open it with `sqlite3` (`SELECT data FROM sessions WHERE id = ?`) or an older zidane build, extract the JSON blob, hand-import the turns you need.
537
+ ```sh
538
+ DB=~/.zidane/sessions.db # adjust to your path
539
+ cp "$DB.zidane-pre-v4.bak" "$DB"
540
+ rm -f "$DB-wal" "$DB-shm"
541
+ ```
592
542
 
593
- Delete the `.bak` once you've confirmed every session loads correctly; it can be reclaimed once you're satisfied.
543
+ Pin the previous zidane version (reads v3 directly) or upgrade to a fixed release — the migration is idempotent, so re-running it skips already-correct rows via a `turns` row-count check. For per-session recovery without a full rollback, the `.bak` is a valid v3 SQLite file: open it with `sqlite3` (`SELECT data FROM sessions WHERE id = ?`) and hand-import the JSON blob. Delete the `.bak` once every session loads correctly.
594
544
 
595
545
  ## Sub-agent Spawning
596
546
 
@@ -618,17 +568,15 @@ Three **independent** flags control what crosses the parent/child boundary:
618
568
 
619
569
  | Flag | Default | What it shares |
620
570
  |---|---|---|
621
- | `persist` | `false` | The parent's `Session`. Child turns get appended to the same `session.turns` stream with the child's `runId`; the child's `SessionRun` carries `parentRunId` so the tree is reconstructible. As a side-effect the read-state map (Session-keyed) is also shared. |
571
+ | `persist` | `false` | The parent's `Session`. Child turns get appended to the same `session.turns` stream with the child's `runId`; the child's `SessionRun` carries `parentRunId` so the tree is reconstructible. As a side effect the read-state map is also shared. |
622
572
  | `shareReadState` | `false` | Only the read-state map. The child gets `AgentOptions.readState` populated with the parent's resolved map but **no session** — `read_file` populations land in the parent's tracking, but child turns stay in-memory. |
623
- | `shareHandle` | `false` | The parent's `ExecutionHandle`. The child skips its own `execution.spawn()` and never destroys the handle (host owns the lifecycle), so children see the parent's filesystem and background-task namespace instead of an isolated scratch space. Matters most for remote contexts (sandbox / container), where a per-child allocation is real — and under a durable runtime is an unjournaled side effect that orphans a sandbox on every replayed attempt. See `docs/RESTATE.md` § Subagents. |
624
-
625
- Always inherited (not flags): the parent's `AgentClock`, so a durable host's journaled time/UUID source stamps child metadata too, and `AgentBehavior`, which carries the durable tool-batch scheduler.
573
+ | `shareHandle` | `false` | The parent's `ExecutionHandle`. The child skips its own `execution.spawn()` and never destroys the handle (host owns the lifecycle). Matters most for remote contexts (sandbox / container) — under a durable runtime it's an unjournaled side effect that orphans a sandbox on every replayed attempt. See `docs/RESTATE.md` § Subagents. |
626
574
 
627
- Use `shareReadState: true` (without `persist`) when you want the parent's `requireReadBeforeEdit` gate to honor reads the child performed, without polluting the parent's session with child turns. The gate's error message now hints at this when the entry is missing — re-read in the calling context OR opt into one of the sharing flags.
575
+ Always inherited (not flags): the parent's `AgentClock` and `AgentBehavior` (carries the durable tool-batch scheduler). Use `shareReadState: true` without `persist` when the parent's `requireReadBeforeEdit` gate should honor reads the child performed, without polluting the parent's session with child turns.
628
576
 
629
577
  ### Subagent type registry
630
578
 
631
- When configured, the spawn tool's input schema gains a `subagent_type` enum the model uses to pick a per-type preset. Mirrors the Claude Code SDK surface — trained models routinely emit `subagent_type: 'Explore' | 'Plan' | 'Verification' | 'general-purpose'` and the registry routes that decision into the framework instead of forcing every host to invent its own dispatch layer.
579
+ When configured, the spawn tool's input schema gains a `subagent_type` enum the model uses to pick a per-type preset. Mirrors the Claude Code SDK surface (`Explore` / `Plan` / `Verification` / `general-purpose`):
632
580
 
633
581
  ```ts
634
582
  createSpawnTool({
@@ -640,7 +588,7 @@ createSpawnTool({
640
588
  })
641
589
  ```
642
590
 
643
- Per-type overlay fields: `system` (preset system prompt — per-call `input.system` still wins), `model` (default model for the type — per-call `input.model` wins), `tools: string[]` (canonical-name allow-list, strict subset of the parent's tools), `readonly: true` (convenience for `['read_file', 'grep', 'glob', 'list_files']`), `description` (rendered into the schema for model self-selection). Unknown type names degrade to `'general-purpose'` (no overlay, no error) so models emitting types the host didn't wire keep working. Empty / unset registry drops the schema field entirely.
591
+ Per-type overlay fields: `system` (per-call `input.system` still wins), `model` (per-call `input.model` wins), `tools: string[]` (canonical-name allow-list, subset of the parent's tools), `readonly: true` (shorthand for `['read_file', 'grep', 'glob', 'list_files']`), `description` (rendered for model self-selection). Unknown type names degrade to `'general-purpose'` (no overlay, no error). Empty / unset registry drops the schema field entirely.
644
592
 
645
593
  ## Interaction Tool
646
594
 
@@ -657,6 +605,8 @@ const askUser = createInteractionTool({
657
605
  // tools: { ...basicTools, ask_user: askUser }
658
606
  ```
659
607
 
608
+ See `docs/INTERACTIONS.md` for the `present_plan` / `ask_user` protocol built on this.
609
+
660
610
  ## MCP Servers
661
611
 
662
612
  MCP connects lazily on first run, bootstrapped in parallel — the first `run()` waits on the slowest. Tools namespaced `mcp_{server}_{tool}`. Partial-failure tolerant — only all-fail throws.
@@ -672,7 +622,7 @@ const agent = createAgent({
672
622
  })
673
623
  ```
674
624
 
675
- Per-server knobs: `bootstrapTimeout` (default **10 s**, caps `connect + listTools` — critical for flaky remotes), `toolTimeout` (default **30 s**, caps `callTool`), `closeTimeout` (default **5 s**, caps each server's shutdown inside `McpConnection.close()`; on timeout a stdio child is SIGKILLed so `destroy()` never hangs or leaks a subprocess), `strictEnv` (stdio only; pass `env` verbatim, off by default so `env: {}` doesn't strip `PATH`).
625
+ Per-server knobs: `bootstrapTimeout` (default **10 s**, caps `connect + listTools`), `toolTimeout` (default **30 s**, caps `callTool`), `closeTimeout` (default **5 s**, caps shutdown inside `McpConnection.close()`; on timeout a stdio child is SIGKILLed), `strictEnv` (stdio only; pass `env` verbatim, off by default so `env: {}` doesn't strip `PATH`).
676
626
 
677
627
  ### Hiding bootstrap latency
678
628
 
@@ -681,14 +631,11 @@ await Promise.all([agent.warmup(), authenticate(), loadConfig()]) // pre-warm
681
631
  const agent = createAgent({ provider, mcpServers, eager: true }) // or kick off automatically
682
632
  ```
683
633
 
684
- `warmup()` is idempotent + concurrency-safe — callers share one promise, rejections clear for retry, post-`destroy()` is a no-op.
685
-
686
- Two hooks fire per bootstrap regardless of outcome — attribute cold-start latency per server:
634
+ `warmup()` is idempotent + concurrency-safe — callers share one promise, rejections clear for retry, post-`destroy()` is a no-op. `mcp:bootstrap:end` fires per server regardless of outcome — attribute cold-start latency:
687
635
 
688
636
  ```ts
689
637
  agent.hooks.hook('mcp:bootstrap:end', (ctx) => {
690
- // ctx.name, ctx.transport, ctx.durationMs, ctx.ok
691
- // ok ? ctx.toolCount : ctx.error
638
+ // ctx.name, ctx.transport, ctx.durationMs, ctx.ok ? ctx.toolCount : ctx.error
692
639
  })
693
640
  ```
694
641
 
@@ -696,45 +643,36 @@ agent.hooks.hook('mcp:bootstrap:end', (ctx) => {
696
643
 
697
644
  ### Stdio env handling
698
645
 
699
- For `transport: 'stdio'`, `env` **merges** on top of the MCP SDK's inherited-env whitelist (POSIX: `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM`, `USER`; Win32: `APPDATA`, `PATH`, …) — `env: { MY_TOKEN: '…' }` no longer strips `PATH` and trips `spawn ENOENT`. Use `strictEnv: true` only for fully-isolated child envs (tight sandboxes).
646
+ For `transport: 'stdio'`, `env` **merges** on top of the MCP SDK's inherited-env whitelist (POSIX: `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM`, `USER`; Win32: `APPDATA`, `PATH`, …) — `env: { MY_TOKEN: '…' }` no longer strips `PATH` and trips `spawn ENOENT`. Use `strictEnv: true` only for fully-isolated child envs.
700
647
 
701
648
  ### Where a stdio server runs (`scope`)
702
649
 
703
- `scope: 'host'` (default) spawns the server as a child of the agent process. `scope: 'context'` spawns it **inside** the agent's execution context instead, via `ExecutionContext.execStream`:
650
+ `scope: 'host'` (default) spawns the server as a child of the agent process. `scope: 'context'` spawns it **inside** the agent's execution context instead, via `ExecutionContext.execStream` — required when a sandboxed context (Docker / E2B / Daytona) must share a filesystem with an MCP tool that writes files the agent's own tools then read:
704
651
 
705
652
  ```ts
706
653
  mcpServers: [
707
- // Touches the workspace → must share the agent's filesystem.
708
654
  { name: 'fs', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'], scope: 'context' },
709
- // Network + host credentials only → leave it on the host.
710
- { name: 'github', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] },
655
+ { name: 'github', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] }, // network-only → leave on host
711
656
  ]
712
657
  ```
713
658
 
714
- Without this, a sandboxed context (Docker / E2B / Daytona) leaves the agent's tools and its MCP servers on different filesystems — the model writes a file through an MCP tool and `read_file` can't find it. Requires a context advertising `capabilities.stream` (`process`, `docker`, `e2b`, `daytona`; not `createMockContext`). Ignored for `sse` / `streamable-http`. Note this is filesystem coherence, **not** isolation: remote MCP servers are unreachable by this mechanism.
715
-
716
- Two operational requirements:
717
-
718
- - **The context image must contain the server's runtime.** A missing `node` / `uvx` / `bunx` surfaces as a bootstrap error for that server (the rest of the agent keeps working). Verified available out of the box: E2B ships `node` + `npx` + `python3`, Daytona ships those plus `bun` and `uvx`.
719
- - **The `docker` context additionally needs the `docker` CLI on `PATH`** for `scope: 'context'` servers specifically — unlike every other Docker operation, which only needs the daemon socket. The CLI is used because dockerode's hijacked exec (the only HTTP-API route to a writable stdin) hangs under Bun. `DOCKER_HOST` is still honoured, so remote daemons work.
659
+ Requires a context advertising `capabilities.stream` (`process`, `docker`, `e2b`, `daytona`; not `createMockContext`). Ignored for `sse` / `streamable-http`. Filesystem coherence, **not** isolation — remote MCP servers stay unreachable. The context image must contain the server's runtime (missing `node`/`uvx`/`bunx` → bootstrap error for that server only); `docker` additionally needs the `docker` CLI on `PATH` for `scope: 'context'` (dockerode's hijacked exec hangs under Bun; `DOCKER_HOST` still honored).
720
660
 
721
661
  ### Per-server tool filtering
722
662
 
723
- `enabledTools` / `disabledTools` / `toolFilter` apply at config time (before registration); the `mcp:tools:filter` hook is the runtime escape hatch when host-side policy depends on the user / environment. Config filters compose: allow-list first, then deny-list, then predicate, then hook. `cachedTools` skips the `tools/list` round-trip when the host can supply the schemas itself; `lazyConnect: true` (requires `cachedTools`, incompatible with `auth: 'oauth'`) defers `client.connect()` to the first `tools/call`.
663
+ `enabledTools` / `disabledTools` / `toolFilter` apply at config time (before registration); `mcp:tools:filter` is the runtime escape hatch when host-side policy depends on user/environment. Config filters compose: allow-list, then deny-list, then predicate, then hook. `cachedTools` skips the `tools/list` round-trip when the host can supply schemas itself; `lazyConnect: true` (requires `cachedTools`, incompatible with `auth: 'oauth'`) defers `client.connect()` to the first `tools/call`.
724
664
 
725
665
  ### Server instructions
726
666
 
727
- Servers can return an `instructions` payload on the MCP `initialize` handshake — free-form text telling the model how to use the server's tools ("project is provisioned, use `apply_migration` directly"). Zidane captures these via the SDK's `getInstructions()` and renders them into the system prompt as a `# MCP Server Instructions` section, grouped by server name in `mcpServers` config order. Lives on the static (cached) half of the system prompt, so it rides the prefix cache.
728
-
729
- Opt out with `behavior.surfaceMcpInstructions: false` (default `true`). Only eager-connect servers contribute — `lazyConnect: true` servers would have to force a connection to fetch the field, which defeats the deferral.
667
+ Servers can return an `instructions` payload on the MCP `initialize` handshake. Zidane captures it via the SDK's `getInstructions()` and renders it into the system prompt as a `# MCP Server Instructions` section, grouped by server name in `mcpServers` config order — lives on the static (cached) half of the prompt. Opt out with `behavior.surfaceMcpInstructions: false` (default `true`). Only eager-connect servers contribute (`lazyConnect: true` would have to force a connection to fetch the field).
730
668
 
731
669
  ### Tool naming
732
670
 
733
- Canonical wire form is `mcp_<server>_<tool>` (single underscore). Models trained on the Claude Code SDK may emit the double-underscore form (`mcp__<server>__<tool>`); zidane auto-recognises that as an inbound alias for every MCP tool, so a model calling `mcp__acme__apply_migration` against a server registered as `mcp_acme_apply_migration` resolves correctly. Inbound-only — the wire still advertises the single-underscore canonical, so the provider tool-list bytes don't change.
671
+ Canonical wire form is `mcp_<server>_<tool>` (single underscore). Models trained on the Claude Code SDK may emit the double-underscore form (`mcp__<server>__<tool>`); zidane auto-recognizes that as an inbound alias for every MCP tool. Inbound-only — the wire still advertises the single-underscore canonical.
734
672
 
735
- ## Progressive tool disclosure
673
+ ## Progressive Tool Disclosure
736
674
 
737
- `behavior.toolDisclosure: 'lazy'` swaps MCP tools for a name-only catalog and auto-injects a `tool_search` native tool. Native + skill tools stay eager.
675
+ `behavior.toolDisclosure: 'lazy'` swaps MCP tools for a name-only catalog and auto-injects a `tool_search` native tool. Native + skill tools stay eager. Flow diagram: `docs/ARCHITECTURE.md` § Progressive Tool Disclosure.
738
676
 
739
677
  ```ts
740
678
  createAgent({
@@ -748,11 +686,9 @@ createAgent({
748
686
  })
749
687
  ```
750
688
 
751
- The system prompt gains a `<searchable_tools>` block. `tool_search({ query, names, server, limit })` returns matching schemas and unlocks them for the rest of the run. A `tool:gate` middleware refuses un-surfaced lazy tools (production providers also enforce this server-side). Catalog + search results show the **wire** name; the unlock set keys on canonical so dispatch / `session.turns` stay alias-stable.
689
+ The system prompt gains a `<searchable_tools>` block. `tool_search({ query, names, server, limit })` returns matching schemas and unlocks them for the rest of the run. A `tool:gate` middleware refuses un-surfaced lazy tools. Catalog + search results show the **wire** name; the unlock set keys on canonical so dispatch / `session.turns` stay alias-stable.
752
690
 
753
- `behavior.toolSearch` knobs (no-op in eager mode): `tool: false` opts out of auto-injection (catalog still emits, call-to-action drops); `limit: 20` caps default results. Per-server `disclosure: 'eager' | 'lazy'` overrides the agent default; run-level overrides agent-level.
754
-
755
- Cost: one cache miss per discovery wave (tool list grows); subsequent turns hit cache normally.
691
+ `behavior.toolSearch` knobs (no-op in eager mode): `tool: false` opts out of auto-injection; `limit: 20` caps default results. Per-server `disclosure: 'eager' | 'lazy'` overrides the agent default; run-level overrides agent-level. Cost: one cache miss per discovery wave; subsequent turns hit cache normally.
756
692
 
757
693
  ## Skills
758
694
 
@@ -784,7 +720,7 @@ metadata:
784
720
  Instructions here…
785
721
  ```
786
722
 
787
- ### Loading + enabling
723
+ ### Loading and enabling
788
724
 
789
725
  ```ts
790
726
  createAgent({
@@ -811,33 +747,27 @@ When the catalog is non-empty and `tool !== false`, three tools auto-inject:
811
747
 
812
748
  | Tool | Purpose |
813
749
  |---|---|
814
- | `skills_use` | Activate or deactivate a skill by name. `mode: 'activate'` (default, spec-mandated) loads the skill body and wraps it in `<skill_content spec_version="0.1">`; `mode: 'deactivate'` (zidane extension) releases an active skill so its `allowed-tools` restrictions stop applying — fires `skills:deactivate` with `reason: 'model'`. Idempotent in both directions. |
750
+ | `skills_use` | Activate or deactivate a skill by name. `mode: 'activate'` (default, spec-mandated) loads the skill body wrapped in `<skill_content spec_version="0.1">`; `mode: 'deactivate'` (zidane extension) releases an active skill so its `allowed-tools` restrictions stop applying — fires `skills:deactivate` with `reason: 'model'`. Idempotent both ways. |
815
751
  | `skills_read` | Read a bundled resource (`references/`, `assets/`) of an active skill. |
816
752
  | `skills_run_script` | Run `scripts/*.sh` / `*.py` with args. Returns `{ exitCode, stdout, stderr }`. |
817
753
 
818
754
  Shell interpolation (`` !`cmd` ``) runs **per activation** — `` !`gh pr diff` `` reflects the current PR each time.
819
755
 
820
- Programmatic activation:
821
-
822
756
  ```ts
823
757
  await agent.activateSkill('pdf-processing') // skills:activate, via: 'explicit'
824
758
  agent.activeSkills // readonly ActiveSkill[]
825
759
  await agent.deactivateSkill('pdf-processing') // skills:deactivate, reason: 'explicit'
826
760
  ```
827
761
 
828
- `activateSkill` auto-resolves the catalog on its first call, so it's safe to invoke before `run()` (e.g. a TUI activating a `/slash` skill at submit time). Pre-warm via `await agent.warmup()` or `eager: true` to pay the resolution cost up front. Throws `Cannot activate skill "<name>" — skills are disabled for this agent` only when `skills.enabled === false` / `enabled: []`.
762
+ `activateSkill` auto-resolves the catalog on its first call, so it's safe to invoke before `run()`. Pre-warm via `await agent.warmup()` or `eager: true` to pay the resolution cost up front. Throws `Cannot activate skill "<name>" — skills are disabled for this agent` only when `skills.enabled === false` / `enabled: []`.
829
763
 
830
- ### Enforcement + resume
764
+ ### Enforcement and resume
831
765
 
832
- When any active skill declares `allowed-tools`, a `tool:gate` middleware blocks calls outside the union (the three skills tools are always implicitly allowed). Matching uses `displayName`, supports exact names (`Read`) and patterns (`Bash(git:*)`). Blocked calls throw `AgentToolNotAllowedError`, whose `.message` ends with a recovery hint pointing the model at `skills_use({ mode: 'deactivate', name })` — without this the model tends to invent folk theories about "sticky restrictions" instead of releasing the skill itself.
766
+ When any active skill declares `allowed-tools`, a `tool:gate` middleware blocks calls outside the union (the three skills tools are always implicitly allowed). Matching uses `displayName`, supports exact names (`Read`) and patterns (`Bash(git:*)`). Blocked calls throw `AgentToolNotAllowedError`, whose `.message` points the model at `skills_use({ mode: 'deactivate', name })`. Full activation/deactivation state machine: `docs/ARCHITECTURE.md` § Skills Activation Lifecycle.
833
767
 
834
- `allowed-tools` accepts three authoring shapes (all normalize to `string[]`): spec-canonical space-separated string (`allowed-tools: Read Bash(git:*)`), YAML block list (`- Read` lines), and YAML flow list (`[Read, Bash(git:*)]`). Earlier zidane builds only handled the first; the others silently mangled the union and opened the gate. The lenient parser is in `src/skills/discovery.ts` — malformed shapes drop the field with a diagnostic instead of coercing to garbage.
768
+ `allowed-tools` accepts three authoring shapes, all normalized to `string[]`: spec-canonical space-separated string (`Read Bash(git:*)`), YAML block list, YAML flow list (`[Read, Bash(git:*)]`). The lenient parser (`src/skills/discovery.ts`) also tolerates `allowedTools` / `allowed_tools` alt-spellings, migrating to the canonical slot with a `frontmatter-key-alt-spelling` diagnostic.
835
769
 
836
- The parser also tolerates **`allowedTools`** (camelCase) and **`allowed_tools`** (snake_case) as alt-spellings of the canonical `allowed-tools` key — the value is migrated to the canonical slot and a `frontmatter-key-alt-spelling` diagnostic is attached. This is a courtesy for authors used to JS / Python idioms; the spec mandates kebab-case and the diagnostic is the nudge to rename. The canonical key wins when both are present.
837
-
838
- On session resume, `skills_use` calls in the turn history rebuild the active set with **last-mode-wins per skill**: a trailing `mode: 'deactivate'` block correctly STAYS inactive on the next run (without this, the rehydrator would silently re-activate skills the model just released, defeating the recovery path). Activations fire with `via: 'resume'`; `skills:deactivate` does NOT fire during rehydration — the state started empty so there's nothing to deactivate.
839
-
840
- Programmatic host-side activations (`agent.activateSkill(name)`) do NOT produce a `skills_use` block in history, so they don't survive the run-end deactivate-all pass automatically. Hosts that want persistence across runs (TUI slash-commands, for example) must re-activate before each `agent.run()`; the TUI does this via a pinned-skills set persisted in `session.metadata['zidane.activeSkills']` — see `docs/TUI.md`.
770
+ On resume, `skills_use` calls in turn history rebuild the active set with **last-mode-wins per skill**: a trailing `mode: 'deactivate'` stays inactive. Activations fire with `via: 'resume'`; `skills:deactivate` does not fire during rehydration. Programmatic `agent.activateSkill(name)` calls leave no history block, so they don't survive run-end deactivation — hosts wanting persistence (TUI slash-commands) must re-activate before each `run()` (the TUI uses `session.metadata['zidane.activeSkills']`, see `docs/TUI.md`).
841
771
 
842
772
  ### Installing skills (GitHub / npm / skills.sh)
843
773
 
@@ -849,18 +779,16 @@ await installSkillFromSpec({ source: 'https://skills.sh/anthropics/skills/fronte
849
779
  await installSkillFromSpec({ source: 'owner/repo#main:skills/foo', scope: 'user', preferSkillsCli: false })
850
780
  ```
851
781
 
852
- Shares the extensions installer's source layer: `owner/repo[#ref][:subdir]`, GitHub URLs (incl. `/tree/…`), `pkg[@version]`, `@scope/pkg` — plus pasted `skills.sh/<owner>/<repo>[/<skill>]` catalog URLs (skills.sh uses GitHub as its registry, so both engines cover the same universe). Engine selection: the **skills.sh CLI** when `skills` is on PATH (installs to the cross-agent `.agents/skills/`, feeds their lockfile/leaderboard; `-y` non-interactive, user scope maps to `-g`), else the **native fetch** into `~/.zidane/skills/<name>` or `<gitRoot>/.zidane/skills/<name>` — both already on the default scan paths. `preferSkillsCli: false` forces native.
782
+ Shares the extensions installer's source layer: `owner/repo[#ref][:subdir]`, GitHub URLs, `pkg[@version]`, `@scope/pkg`, plus `skills.sh/<owner>/<repo>[/<skill>]` catalog URLs. Engine: the **skills.sh CLI** when on PATH (installs to `.agents/skills/`), else native fetch into `~/.zidane/skills/<name>` or `<gitRoot>/.zidane/skills/<name>`; `preferSkillsCli: false` forces native.
853
783
 
854
- Unlike extensions, validation **never executes code** — candidates are validated by parsing `SKILL.md` (the same lenient parser discovery uses). Candidate discovery is a bounded recursive walk: **any** directory containing a `SKILL.md` (up to 4 levels deep, dot-dirs/`node_modules` skipped, never descending into a skill's own subtree) — so flat collections, `skills/<name>/`, and nested category layouts (`skills/engineering/<name>/`) all work. Multi-skill repos throw `multiple-candidates` carrying root-relative paths; `skill` selects by bare name or, when names collide across categories, by path (`skill: 'engineering/tdd'`). Same hardening as extensions: staged fetch, physical symlink containment for `:subdir`, overwrite (opt-in) with backup/rollback, and a `.zidane-install.json` provenance sidecar (`readSkillProvenance`). Errors are typed `SkillInstallError`s with `.code`.
784
+ Validation **never executes code** — candidates are parsed with the same lenient `SKILL.md` parser as discovery, via a bounded recursive walk (any dir with a `SKILL.md`, ≤4 levels deep). Multi-skill repos throw `multiple-candidates`; `skill` selects by name or path on collision. Same hardening as extensions: staged fetch, symlink containment, opt-in overwrite with backup/rollback, `.zidane-install.json` provenance sidecar. Errors are typed `SkillInstallError`s with `.code`.
855
785
 
856
- In the TUI: **Settings → Skills → `ctrl+g`** (source → scope flow, same gesture as the Extensions tab). Settings → MCPs has the matching `ctrl+g` add-server form (writes the scope's `mcps.json`, shape-preserving upsert).
786
+ In the TUI: **Settings → Skills → `ctrl+g`** (source → scope flow, same gesture as Extensions).
857
787
 
858
788
  ### Lenient load, strict authoring
859
789
 
860
790
  Third-party skills with minor deviations load anyway via `parseSkillFile` (warnings in `SkillConfig.diagnostics`). `defineSkill` / `writeSkillToDisk` run `validateSkillForWrite` — spec-strict.
861
791
 
862
- Hooks: `skills:resolve` (`{ skills }`), `skills:catalog` (`{ catalog, skills }`), `skills:activate` (`{ skill, via: 'model'|'explicit'|'resume' }`), `skills:deactivate` (`{ skill, reason: 'run-end'|'explicit'|'reset'|'model' }`).
863
-
864
792
  ## Structured Output
865
793
 
866
794
  Force the final response to a JSON Schema. A synthetic `__output__` tool is injected with `toolChoice` on the **final turn only** (after the model signals done); mid-run tool calls are unaffected. Result lands on `stats.output` and fires the `output` hook.
@@ -906,6 +834,8 @@ createDockerContext({ image: 'node:22', cwd: '/workspace' })
906
834
  createSandboxContext(myProvider) // implement SandboxProvider (E2B, Rivet, …)
907
835
  ```
908
836
 
837
+ Capability model, remote/sandbox contexts, and the `ExecutionHandle` lifecycle: `docs/EXECUTION_CONTEXT.md`.
838
+
909
839
  ## Key Types
910
840
 
911
841
  | Type | Description |
@@ -913,7 +843,7 @@ createSandboxContext(myProvider) // implement SandboxProvider (E2B, Rivet, …)
913
843
  | `AgentOptions` | `createAgent()` config (+ `eager?` to pre-warm MCP). |
914
844
  | `AgentRunOptions` | `agent.run()` config. |
915
845
  | `AgentBehavior` | All `behavior.*` fields. |
916
- | `AgentStats` | Run result. `totalIn` / `totalOut` / `totalCacheRead` / `totalCacheCreation` / `cost` are cumulative (parent + children); `turns` / `turnUsage` parent-only. Plus `elapsed`, `output`, `children?`, `timeTillFirstTokenMs`. |
846
+ | `AgentStats` | Run result. `totalIn`/`totalOut`/`totalCacheRead`/`totalCacheCreation`/`cost` are cumulative (parent + children); `turns`/`turnUsage` parent-only. Plus `elapsed`, `output`, `children?`, `timeTillFirstTokenMs`. |
917
847
  | `Agent` | Instance (run, abort, steer, hooks, …). |
918
848
  | `Preset` | `Omit<Partial<AgentOptions>, 'provider' \| 'execution' \| 'session' \| 'mcpConnector'>`. |
919
849
  | `ToolDef` | `{ spec, execute }`. `execute` returns `string \| ToolResultContent[]`. |
@@ -931,20 +861,19 @@ createSandboxContext(myProvider) // implement SandboxProvider (E2B, Rivet, …)
931
861
  | `SkillConfig` / `SkillsConfig` | Parsed skill definition / config (`scan, write, exclude, enabled`). |
932
862
  | `InteractionToolOptions` | `createInteractionTool` config (`schema, onRequest`). |
933
863
  | `PromptPart` | `{ type: 'text' \| 'image' \| 'document', ... }`. |
934
- | `RunHookMap` | `run({ hooks })` shape. |
935
- | `AgentBehavior.maxConcurrentTools` | Cap on in-flight tools per turn (default `10`, clamped at `1`). See "Per-tool concurrency" above. |
936
- | `ToolDef.isConcurrencySafe` | `boolean \| ((input) => boolean)`. Defaults to `false`. See "Per-tool concurrency" above. |
937
- | `ToolHookContext` / `McpToolHookContext` / `SessionHookContext` / `StreamHookContext` / `SpawnHookContext` | Hook ctx base types. `ToolHookContext` / `McpToolHookContext` carry `runId? / parentRunId? / parentCallId? / depth?` identity fields on every `tool:*` / `mcp:tool:*` event — see Identity fields above. |
938
- | `ReadStateMap` / `ReadStateEntry` | Per-file read-tracking map (`Map<canonicalAbsPath, { contentHash, offset, limit, maxBytes, lineNumbers?, mtimeMs }>`). Construct one manually for `AgentOptions.readState` to share across an agent tree; or get the session-bound one via `getReadState(session)` / the lookup-aware `resolveReadStateMap(ctx)`. Canonical keys come from `readStateKey(cwd, path)`. |
864
+ | `AgentHookMap` | `{ [event]: handler \| handler[] }` — agent-lifetime hooks on `AgentOptions.hooks`. |
865
+ | `RunHookMap` | Same shape, per-run scope (`agent.run({ hooks })`); handlers auto-detach. |
866
+ | `AgentBehavior.maxConcurrentTools` | Cap on in-flight tools per turn (default `10`, clamped at `1`). See [Per-tool concurrency](#per-tool-concurrency). |
867
+ | `ToolDef.isConcurrencySafe` | `boolean \| ((input) => boolean)`. Defaults to `false`. |
868
+ | `ToolHookContext` / `McpToolHookContext` / `SessionHookContext` / `StreamHookContext` / `SpawnHookContext` | Hook ctx base types. `ToolHookContext` / `McpToolHookContext` carry the `runId?`/`parentRunId?`/`parentCallId?`/`depth?` identity fields — see [Identity fields](#identity-fields-on-tool-hook-contexts). |
869
+ | `ReadStateMap` / `ReadStateEntry` | Per-file read-tracking map (`Map<canonicalAbsPath, { contentHash, offset, limit, maxBytes, lineNumbers?, mtimeMs }>`). Construct one manually for `AgentOptions.readState` to share across an agent tree; or get the session-bound one via `getReadState(session)` / `resolveReadStateMap(ctx)`. Canonical keys come from `readStateKey(cwd, path)`. |
939
870
  | `ClassifiedError` | `{ kind, providerCode?, message? }` from `Provider.classifyError`. |
940
871
  | `AgentContextExceededError` / `AgentProviderError` / `AgentAbortedError` / `AgentToolNotAllowedError` / `AgentBudgetExceededError` | Typed errors. `toTypedError(err)` wraps native errors. |
941
872
  | `FileMapAdapter` | `{ get, save, delete }` — host-SDK seam for `createFileMapStore`. |
942
873
  | `TracingHooksOptions` | `createTracingHooks` config (`startSpan`, `namespace?`). |
943
874
  | `OpenAICompatParams` | `openaiCompat()` config (apiKey, baseURL, authHeader, extraHeaders, capabilities, cacheBreakpoints). |
944
875
  | `ArceeParams` / `LocalParams` | OpenAI-compatible wrapper configs for Arcee Platform and self-hosted local endpoints. |
945
- | `ValidationResult` | `{ valid, error?, coercedInput?, coercions?, droppedItems? }` — `validateToolArgs` output. `droppedItems` is keyed by field name and reports the original indexes of items dropped during recursive array validation. |
946
- | `AgentHookMap` | `{ [event]: handler \| handler[] }` — agent-lifetime hooks on `AgentOptions.hooks`. |
947
- | `RunHookMap` | Same shape, per-run scope (`agent.run({ hooks })`); handlers auto-detach. |
876
+ | `ValidationResult` | `{ valid, error?, coercedInput?, coercions?, droppedItems? }` — `validateToolArgs` output. `droppedItems` keyed by field name, reports original indexes of items dropped during recursive array validation. |
948
877
  | `toolOutputByteLength(content)` | Reproduces the loop's `outputBytes` formula. |
949
878
  | `toolResultToText(result)` | Flatten `string \| ToolResultContent[]` to a plain string. |
950
879
  | `tailTruncate(text, maxBytes, opts?)` | Tail-priority, UTF-8 byte-budgeted truncation (codepoint/surrogate-safe) — the shared discipline the `shell` tool uses. |
@@ -965,6 +894,7 @@ src/
965
894
  system-prompt.ts System-prompt section helpers (the `zidane/prompt` entry)
966
895
  mutation-validation.ts Host-side post-mutation validation hook helper
967
896
  tracing.ts createTracingHooks
897
+ cache-telemetry.ts installCacheBreakLogger, CacheBreakInfo/CacheDriftInfo
968
898
  stats.ts flattenTurns, statsByModel
969
899
  types.ts Shared types
970
900
  types-export.ts Public type surface for `zidane/types`
@@ -992,7 +922,11 @@ Narrow runtime subpaths for edge / Worker bundles — each re-exports root-publi
992
922
 
993
923
  ## Related
994
924
 
995
- - `docs/ARCHITECTURE.md` — execution-flow diagrams, hook firing order, gate precedence.
925
+ - `docs/ARCHITECTURE.md` — execution-flow diagrams, hook firing order, gate precedence, concurrency scheduler, tool ergonomics defaults.
926
+ - `docs/RUN_IN_BACKGROUND.md` — background task lifecycle, wire format, subagent task reassignment.
927
+ - `docs/EXECUTION_CONTEXT.md` — `ExecutionContext` capability model, remote/sandbox contexts.
928
+ - `docs/ARTIFACTS.md` — session artifact model, `artifact:recorded` consumers.
996
929
  - `docs/CHAT.md` — renderer-agnostic chat surface (auth, sessions, safe-mode, theme, edit-approval).
997
930
  - `docs/TUI.md` — OpenTUI terminal shell built on top.
998
931
  - `docs/INTERACTIONS.md` — `present_plan` / `ask_user` protocol.
932
+ - `docs/RESTATE.md` — durable execution (Restate) adapter, subagent journaling constraints.