zidane 7.0.14 → 7.0.16

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 (124) hide show
  1. package/README.md +18 -9
  2. package/dist/{acp-CZpX91YE.js → acp-Bw2b_kka.js} +3 -3
  3. package/dist/{acp-CZpX91YE.js.map → acp-Bw2b_kka.js.map} +1 -1
  4. package/dist/acp-cli.js +4 -4
  5. package/dist/acp.d.ts +1 -1
  6. package/dist/acp.js +1 -1
  7. package/dist/{agent-B1jsFaDq.js → agent-B_uQo4f1.js} +649 -48
  8. package/dist/agent-B_uQo4f1.js.map +1 -0
  9. package/dist/agent.d.ts +1 -1
  10. package/dist/agent.js +1 -1
  11. package/dist/{anthropic-Da39cE-d.js → anthropic-QbHqqTkV.js} +2 -2
  12. package/dist/{anthropic-Da39cE-d.js.map → anthropic-QbHqqTkV.js.map} +1 -1
  13. package/dist/{approvals-S100xf1d.js → approvals-B9aIQJRQ.js} +287 -45
  14. package/dist/approvals-B9aIQJRQ.js.map +1 -0
  15. package/dist/artifacts-CV2hUKnL.js +112 -0
  16. package/dist/artifacts-CV2hUKnL.js.map +1 -0
  17. package/dist/{auth-DfEWnkC7.js → auth-plw6npda.js} +19 -7
  18. package/dist/auth-plw6npda.js.map +1 -0
  19. package/dist/chat/pure.d.ts +3 -3
  20. package/dist/chat/pure.js +1 -1
  21. package/dist/chat.d.ts +6 -6
  22. package/dist/chat.js +5 -5
  23. package/dist/contexts/daytona.d.ts +1 -1
  24. package/dist/contexts/e2b.d.ts +1 -1
  25. package/dist/daemon-cli.js +1 -1
  26. package/dist/eval.d.ts +1 -1
  27. package/dist/eval.js +1 -1
  28. package/dist/extensions.d.ts +2 -2
  29. package/dist/extensions.js +2 -2
  30. package/dist/headless.d.ts +1 -1
  31. package/dist/headless.js +2 -2
  32. package/dist/{index-DFGECPfm.d.ts → index-Dc_zYpEu.d.ts} +351 -2
  33. package/dist/index-Dc_zYpEu.d.ts.map +1 -0
  34. package/dist/{index-DP7xYXr9.d.ts → index-Df2CH6bn.d.ts} +92 -7
  35. package/dist/index-Df2CH6bn.d.ts.map +1 -0
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.js +7 -6
  38. package/dist/{ipc-BLLscoIl.js → ipc-T_pPnHkp.js} +2 -2
  39. package/dist/{ipc-BLLscoIl.js.map → ipc-T_pPnHkp.js.map} +1 -1
  40. package/dist/ipc.d.ts +54 -6
  41. package/dist/ipc.d.ts.map +1 -1
  42. package/dist/ipc.js +3 -3
  43. package/dist/mcp.d.ts +1 -1
  44. package/dist/{oauth-C4GvY_p1.js → oauth-EIBUuSDK.js} +2 -2
  45. package/dist/{oauth-C4GvY_p1.js.map → oauth-EIBUuSDK.js.map} +1 -1
  46. package/dist/output/stream-json.d.ts +1 -1
  47. package/dist/output/terminal.d.ts +1 -1
  48. package/dist/plugins.d.ts +1 -1
  49. package/dist/presets.d.ts +2 -2
  50. package/dist/presets.js +2 -2
  51. package/dist/providers/anthropic.d.ts +1 -1
  52. package/dist/providers/anthropic.js +1 -1
  53. package/dist/providers/arcee.d.ts +1 -1
  54. package/dist/providers/baseten.d.ts +1 -1
  55. package/dist/providers/cerebras.d.ts +1 -1
  56. package/dist/providers/local.d.ts +1 -1
  57. package/dist/providers/openai-compat.d.ts +1 -1
  58. package/dist/providers/openai.d.ts +1 -1
  59. package/dist/providers/openai.js +1 -1
  60. package/dist/providers/openrouter.d.ts +1 -1
  61. package/dist/providers/xai.d.ts +1 -1
  62. package/dist/providers/xai.js +2 -2
  63. package/dist/{providers-BYgyPb15.js → providers-CoK1rZ1p.js} +2 -2
  64. package/dist/{providers-BYgyPb15.js.map → providers-CoK1rZ1p.js.map} +1 -1
  65. package/dist/providers.d.ts +1 -1
  66. package/dist/providers.js +3 -3
  67. package/dist/restate.d.ts +1 -1
  68. package/dist/restate.js +1 -1
  69. package/dist/{server-C8gULJ0d.js → server-DXM8ZbGi.js} +64 -6
  70. package/dist/server-DXM8ZbGi.js.map +1 -0
  71. package/dist/session/sqlite.d.ts +1 -1
  72. package/dist/session/sqlite.d.ts.map +1 -1
  73. package/dist/session/sqlite.js +117 -1
  74. package/dist/session/sqlite.js.map +1 -1
  75. package/dist/{session-K3icbPH9.js → session-DPZnncaQ.js} +60 -1
  76. package/dist/session-DPZnncaQ.js.map +1 -0
  77. package/dist/session.d.ts +2 -2
  78. package/dist/session.js +3 -2
  79. package/dist/skills.d.ts +1 -1
  80. package/dist/{tool-formatters-C-s_-u0v.d.ts → tool-formatters-BgdOYAy9.d.ts} +2 -2
  81. package/dist/{tool-formatters-C-s_-u0v.d.ts.map → tool-formatters-BgdOYAy9.d.ts.map} +1 -1
  82. package/dist/tools/fetch-url.d.ts +1 -1
  83. package/dist/tools/web-search.d.ts +1 -1
  84. package/dist/tools.d.ts +2 -2
  85. package/dist/tools.js +2 -2
  86. package/dist/{transcript-anchors-D1ToF-EW.d.ts → transcript-anchors-CtnwuQJM.d.ts} +3 -3
  87. package/dist/transcript-anchors-CtnwuQJM.d.ts.map +1 -0
  88. package/dist/tui.d.ts +3 -3
  89. package/dist/tui.d.ts.map +1 -1
  90. package/dist/tui.js +576 -102
  91. package/dist/tui.js.map +1 -1
  92. package/dist/{turn-operations-CZLsNgw-.js → turn-operations-DkJ1R0p8.js} +4 -3
  93. package/dist/turn-operations-DkJ1R0p8.js.map +1 -0
  94. package/dist/{turn-operations-Bp5-BAki.d.ts → turn-operations-khAvr7KJ.d.ts} +5 -3
  95. package/dist/turn-operations-khAvr7KJ.d.ts.map +1 -0
  96. package/dist/types.d.ts +1 -1
  97. package/dist/{xai-DILdFGzN.js → xai-CazjStOK.js} +3 -3
  98. package/dist/{xai-DILdFGzN.js.map → xai-CazjStOK.js.map} +1 -1
  99. package/docs/ACP.md +6 -0
  100. package/docs/ARCHITECTURE.md +179 -356
  101. package/docs/ARTIFACTS.md +130 -0
  102. package/docs/CHAT.md +426 -1312
  103. package/docs/EXECUTION_CONTEXT.md +46 -19
  104. package/docs/EXTENSIONS.md +234 -584
  105. package/docs/HEADLESS.md +5 -0
  106. package/docs/INTERACTIONS.md +21 -22
  107. package/docs/IPC.md +318 -335
  108. package/docs/PLUGINS.md +2 -0
  109. package/docs/README.md +57 -0
  110. package/docs/RESTATE.md +199 -491
  111. package/docs/RUN_IN_BACKGROUND.md +107 -558
  112. package/docs/SKILL.md +171 -238
  113. package/docs/TUI.md +213 -591
  114. package/package.json +1 -1
  115. package/dist/agent-B1jsFaDq.js.map +0 -1
  116. package/dist/approvals-S100xf1d.js.map +0 -1
  117. package/dist/auth-DfEWnkC7.js.map +0 -1
  118. package/dist/index-DFGECPfm.d.ts.map +0 -1
  119. package/dist/index-DP7xYXr9.d.ts.map +0 -1
  120. package/dist/server-C8gULJ0d.js.map +0 -1
  121. package/dist/session-K3icbPH9.js.map +0 -1
  122. package/dist/transcript-anchors-D1ToF-EW.d.ts.map +0 -1
  123. package/dist/turn-operations-Bp5-BAki.d.ts.map +0 -1
  124. package/dist/turn-operations-CZLsNgw-.js.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,129 @@ 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:server_tool_use` | Provider-executed tool invocation closes (e.g. Anthropic `web_search_20250305`) | — | `id, name, input` |
332
+ | `stream:server_tool_result` | Matching provider-executed tool result closes | — | `toolUseId, toolName, content` |
333
+ | `stream:error` | Provider stream rejects before `stream:end` | — | `err, statusCode?, requestId?` — never fires on user aborts (those land on `agent:abort`) |
334
+ | `stream:retry` | Loop caught a retryable provider error, about to sleep and re-issue | — | `attempt, nextAttempt, maxAttempts, delayMs, err, statusCode?, requestId?` |
335
+ | `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` |
336
+ | `context:overflow` | Loop caught `context_exceeded`, ran emergency in-loop compaction, is re-issuing the stream | — | `attempt, err` |
337
+ | `compact:start` | `behavior.autoCompact` threshold crossed, about to run the summary call | — | `runId, sessionId?, turn, reason: 'auto', usedFraction, effectiveWindow` |
338
+ | `compact:progress` | Throttled, while the compaction summary streams | — | `+ estOutputTokens` |
339
+ | `compact:end` | Exactly once per `compact:start`, on success or failure | — | `+ status: 'success' \| 'error'` (success: replaced/dropped/restored counts + `effectiveTokens`; error: `error`) |
340
+ | `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` |
341
+ | `oauth:refresh` | OAuth token refreshed | — | `provider, providerId, source: 'params'\|'file', previousCredentials, credentials` |
342
+ | `content-ref:resolve` | Wire assembly sees a block carrying `ref`; resolves persisted content refs into provider-bound bytes | **yes** (`data`) | `block, data?, childId?, depth?` |
343
+ | `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
344
  | `tool:before` | Tool execution starts | — | `turnId, callId, name, displayName, input, runToolCounts, priorContent?` |
345
+ | `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
346
  | `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>` |
347
+ | `validation:reject` | Tool args failed validation (after auto-coerce) | — | `+ reason: string, schema` |
348
+ | `validation:coerce` | Tool args were auto-healed (only when fields changed) | — | `+ coercions: readonly string[], schema` |
366
349
  | `mcp:tool:gate` | Before each MCP call | **yes** (`block, reason, result?`) | `turnId, callId, server, tool, displayName, input` |
367
350
  | `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` |
351
+ | `mcp:tool:transform` | After MCP result | **yes** (`result`) | `+ result, outputBytes` |
352
+ | `mcp:tool:after` | MCP complete | — | `+ result, outputBytes` |
370
353
  | `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?` |
354
+ | `tool:transform` | After tool result | **yes** (`result`, `isError`) | `+ result, isError, metadata, outputBytes, coercions?` |
355
+ | `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` |
356
+ | `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 |
357
+ | `tool:after` | Tool complete | — | `+ result, outputBytes, runToolCounts, outcome?` |
373
358
  | `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` |
359
+ | `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 |
360
+ | `context:transform` | Before each LLM call | **yes** (`messages`) | `messages` |
361
+ | `system:transform` | Per-request system-prompt mutation (after `context:transform`) | **yes** (`system`) | `system, messages (readonly), turn, turnId, session?` |
383
362
  | `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
363
  | `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`) |
364
+ | `mcp:connect` | Server's client is live (bootstrap, or first `tools/call` for `lazyConnect`) | — | `name, transport, tools, lazy?` |
365
+ | `mcp:error` | Fatal MCP connection failure | — | `name, error` |
366
+ | `mcp:warn` | Diagnostic line from a server | — | `name, message` |
367
+ | `mcp:close` | MCP connection closed | — | `name` |
368
+ | `mcp:bootstrap:start` | Per server, before connect + listTools | — | `name, transport` |
369
+ | `mcp:bootstrap:end` | Per server, after connect attempt (always fires) | — | `name, transport, durationMs, ok` + (`toolCount, lazy?, cached?` \| `error`) |
370
+ | `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 |
371
+ | `mcp:tools:list` | Per server, after `listTools()` (or `cachedTools` resolution), before filtering | — | `name, transport, tools: McpToolSchema[]` |
372
+ | `mcp:auth:required` | Server needs OAuth login (no tokens / auto-promoted on 401+RFC9728) | — | `name, transport, reason: 'no-tokens'\|'auto-promoted'` |
373
+ | `mcp:auth:url`, `mcp:auth:success`, `mcp:auth:error` | Interactive OAuth flow lifecycle | — | `name` (+ `url` \| `error`) |
374
+ | `mcp:tools:filter` | Per server, after config-side allow/deny/predicate, before registration | **yes** (`tools[]`) | `server, transport, tools: Array<{name, description?, inputSchema?}>` |
375
+ | `background:start` | Background task spawned via `shell({ run_in_background: true })` | — | `taskId, pid, command, cwd, outputPath, startedAt` |
376
+ | `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 |
377
+ | `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 |
378
+ | `background:reconcile` | Merged push+pull background-exit list, before rendering `<task-notification>` blocks | **yes** (`exits`) | `exits: TaskExitInfo[]` — durable hosts journal it here |
379
+ | `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) |
380
+ | `execution:ready` | Execution handle bound (first run's lazy `spawn()`, or immediately for an injected handle) | — | `handle, spawned` — fires once per agent |
381
+ | `skills:resolve` | Once per agent, after discovery (lazy on first `warmup()`/`run()`/`activateSkill()`, eager when `eager: true`) | — | `skills` |
382
+ | `skills:catalog` | Once per agent, after catalog build (same timing as `skills:resolve`) | **yes** (`catalog`) | `catalog, skills` |
383
+ | `skills:activate` | Skill activated (via `skills_use` / `activateSkill` / session resume) | — | `skill, via: 'model'\|'explicit'\|'resume'` |
384
+ | `skills:deactivate` | Skill deactivated | — | `skill, reason: 'run-end'\|'explicit'\|'reset'\|'model'` — `'model'` when the model calls `skills_use({ mode: 'deactivate' })` |
385
+ | `pairing:repair` | Defensive tool_use/tool_result pairing pass repaired corrupted history | — | `mode, callId?, messageIndex, turnId?` |
386
+ | `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 |
387
+ | `usage` | Per-turn usage | — | `turn, turnId, usage, totalIn, totalOut` |
388
+ | `output` | Structured output extracted | — | `output, schema` |
389
+ | `budget:exceeded` | Per-turn tool-output bytes over `behavior.toolOutputBudget` (measured post-`tool:transform`) | — | `turn, turnId, bytes, budget` |
390
+ | `tool-budget:exceeded` | Per-tool call cap from `behavior.toolBudgets` tripped | — | `tool, count, max, turnId, mode: 'steer'\|'block'` |
391
+ | `repeat-guard:exceeded` | Repeat detector from `behavior.repeatGuard` tripped | — | `tool, count, threshold, turnId, action: 'block'\|'steer'\|'abort'` |
389
392
  | `agent:abort` | `agent.abort()` called | — | `{}` |
390
393
  | `agent:done` | Run finished (all exit paths) | — | `AgentStats` |
394
+ | `replay:done` | Durable-runtime adapter (e.g. `zidane/restate`) finished replaying/executing one handler invocation | — | `{}` — never fired by core; at most once per invocation |
395
+ | `session:start`, `session:end` | Run boundaries (always fire) | — | `sessionId, runId` + (`prompt` \| `status, turnRange`) |
396
+ | `session:turns` | Turns persisted | — | `sessionId, turns, count` |
397
+ | `session:meta` | Session metadata write | — | `sessionId, key, value` |
398
+ | `session:save` | Session store write | — | `sessionId` |
391
399
 
392
- ### Mutation contract
400
+ Every `tool:*` / `mcp:tool:*` row above also omits four optional identity fields for readability — see [Identity fields](#identity-fields-on-tool-hook-contexts).
393
401
 
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.
402
+ ### Mutation contract
395
403
 
396
404
  `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
405
 
398
406
  ### Identity fields on tool hook contexts
399
407
 
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):
408
+ Every `tool:*` and `mcp:tool:*` hook ctx additionally carries four optional identity fields (omitted from the table above to keep it readable):
401
409
 
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.
410
+ - `runId?: string` — the `SessionRun.id` this call belongs to. Disambiguates calls across parallel runs / subagent trees without subscribing to `child:tool:*`.
411
+ - `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`.
412
+ - `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
413
  - `depth?: number` — subagent depth, 0 = top-level. Mirrors `ToolContext.depth`.
406
414
 
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.
415
+ 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
416
 
409
417
  ### Child (sub-agent) bubble events
410
418
 
411
- `spawn`'s stream/tool/turn events bubble to the parent with `childId` + `depth`:
419
+ `spawn`'s stream/tool/turn/artifact events bubble to the parent with `childId` + `depth`:
412
420
 
413
421
  ```
414
422
  child:stream:text / child:stream:thinking / child:stream:end
415
- child:stream:server_tool_use / child:stream:server_tool_result
423
+ child:stream:server_tool_use / child:stream:server_tool_result / child:stream:error / child:stream:retry
416
424
  child:tool:gate / child:mcp:tool:gate ← mutable: block/reason/result propagate to the child
417
425
  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
426
+ child:tool:before / child:tool:dispatched / child:tool:after / child:tool:error / child:tool:cancelled
427
+ child:background:start / child:background:exit / child:background:reassign
420
428
  child:turn:after
429
+ child:artifact:recorded
421
430
  ```
422
431
 
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.
432
+ 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
433
 
425
434
  ### Hook recipes
426
435
 
@@ -453,7 +462,7 @@ agent.hooks.hook('context:transform', (ctx) => {
453
462
  })
454
463
  ```
455
464
 
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.
465
+ `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
466
 
458
467
  ## Agent State and Control
459
468
 
@@ -470,7 +479,7 @@ await agent.waitForIdle()
470
479
  await agent.destroy() // clean up execution + MCP
471
480
  ```
472
481
 
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.
482
+ `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
483
 
475
484
  Agents also implement `Symbol.asyncDispose` (aliased to `destroy`) so they work with TC39 explicit-resource-management:
476
485
 
@@ -482,68 +491,18 @@ await agent.run({ prompt: 'go' })
482
491
 
483
492
  Requires Node 20+ (or Bun). Idempotent, same semantics as calling `destroy()` directly.
484
493
 
485
- ## Background tasks
494
+ ## Background Tasks
486
495
 
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:
496
+ `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
497
 
489
498
  ```ts
490
499
  import { createAgent, resolveTasksDir } from 'zidane'
491
500
 
492
501
  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
- })
502
+ const agent = createAgent({ provider, session, behavior: { tasksDir } })
499
503
  ```
500
504
 
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.
505
+ 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
506
 
548
507
  ## Sessions
549
508
 
@@ -566,31 +525,21 @@ const loaded = await loadSession(store, session.id)
566
525
  | `createRemoteStore({ url })` | `zidane` | Server-side | Delegated |
567
526
  | `createFileMapStore(adapter)` | `zidane` | `meta.json` + `turns.jsonl` | Single session, auto-creates |
568
527
 
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).
528
+ 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
529
 
573
530
  ### SQLite schema migrations (v3 → v4)
574
531
 
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
- ```
532
+ `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
533
 
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.
534
+ **Rollback:** quit every zidane process holding the DB, then:
590
535
 
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.
536
+ ```sh
537
+ DB=~/.zidane/sessions.db # adjust to your path
538
+ cp "$DB.zidane-pre-v4.bak" "$DB"
539
+ rm -f "$DB-wal" "$DB-shm"
540
+ ```
592
541
 
593
- Delete the `.bak` once you've confirmed every session loads correctly; it can be reclaimed once you're satisfied.
542
+ 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
543
 
595
544
  ## Sub-agent Spawning
596
545
 
@@ -618,17 +567,15 @@ Three **independent** flags control what crosses the parent/child boundary:
618
567
 
619
568
  | Flag | Default | What it shares |
620
569
  |---|---|---|
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. |
570
+ | `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
571
  | `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.
572
+ | `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
573
 
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.
574
+ 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
575
 
629
576
  ### Subagent type registry
630
577
 
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.
578
+ 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
579
 
633
580
  ```ts
634
581
  createSpawnTool({
@@ -640,7 +587,7 @@ createSpawnTool({
640
587
  })
641
588
  ```
642
589
 
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.
590
+ 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
591
 
645
592
  ## Interaction Tool
646
593
 
@@ -657,6 +604,8 @@ const askUser = createInteractionTool({
657
604
  // tools: { ...basicTools, ask_user: askUser }
658
605
  ```
659
606
 
607
+ See `docs/INTERACTIONS.md` for the `present_plan` / `ask_user` protocol built on this.
608
+
660
609
  ## MCP Servers
661
610
 
662
611
  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 +621,7 @@ const agent = createAgent({
672
621
  })
673
622
  ```
674
623
 
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`).
624
+ 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
625
 
677
626
  ### Hiding bootstrap latency
678
627
 
@@ -681,14 +630,11 @@ await Promise.all([agent.warmup(), authenticate(), loadConfig()]) // pre-warm
681
630
  const agent = createAgent({ provider, mcpServers, eager: true }) // or kick off automatically
682
631
  ```
683
632
 
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:
633
+ `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
634
 
688
635
  ```ts
689
636
  agent.hooks.hook('mcp:bootstrap:end', (ctx) => {
690
- // ctx.name, ctx.transport, ctx.durationMs, ctx.ok
691
- // ok ? ctx.toolCount : ctx.error
637
+ // ctx.name, ctx.transport, ctx.durationMs, ctx.ok ? ctx.toolCount : ctx.error
692
638
  })
693
639
  ```
694
640
 
@@ -696,45 +642,36 @@ agent.hooks.hook('mcp:bootstrap:end', (ctx) => {
696
642
 
697
643
  ### Stdio env handling
698
644
 
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).
645
+ 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
646
 
701
647
  ### Where a stdio server runs (`scope`)
702
648
 
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`:
649
+ `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
650
 
705
651
  ```ts
706
652
  mcpServers: [
707
- // Touches the workspace → must share the agent's filesystem.
708
653
  { 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'] },
654
+ { name: 'github', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] }, // network-only → leave on host
711
655
  ]
712
656
  ```
713
657
 
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.
658
+ 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
659
 
721
660
  ### Per-server tool filtering
722
661
 
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`.
662
+ `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
663
 
725
664
  ### Server instructions
726
665
 
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.
666
+ 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
667
 
731
668
  ### Tool naming
732
669
 
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.
670
+ 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
671
 
735
- ## Progressive tool disclosure
672
+ ## Progressive Tool Disclosure
736
673
 
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.
674
+ `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
675
 
739
676
  ```ts
740
677
  createAgent({
@@ -748,11 +685,9 @@ createAgent({
748
685
  })
749
686
  ```
750
687
 
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.
688
+ 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
689
 
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.
690
+ `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
691
 
757
692
  ## Skills
758
693
 
@@ -784,7 +719,7 @@ metadata:
784
719
  Instructions here…
785
720
  ```
786
721
 
787
- ### Loading + enabling
722
+ ### Loading and enabling
788
723
 
789
724
  ```ts
790
725
  createAgent({
@@ -811,33 +746,27 @@ When the catalog is non-empty and `tool !== false`, three tools auto-inject:
811
746
 
812
747
  | Tool | Purpose |
813
748
  |---|---|
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. |
749
+ | `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
750
  | `skills_read` | Read a bundled resource (`references/`, `assets/`) of an active skill. |
816
751
  | `skills_run_script` | Run `scripts/*.sh` / `*.py` with args. Returns `{ exitCode, stdout, stderr }`. |
817
752
 
818
753
  Shell interpolation (`` !`cmd` ``) runs **per activation** — `` !`gh pr diff` `` reflects the current PR each time.
819
754
 
820
- Programmatic activation:
821
-
822
755
  ```ts
823
756
  await agent.activateSkill('pdf-processing') // skills:activate, via: 'explicit'
824
757
  agent.activeSkills // readonly ActiveSkill[]
825
758
  await agent.deactivateSkill('pdf-processing') // skills:deactivate, reason: 'explicit'
826
759
  ```
827
760
 
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: []`.
761
+ `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
762
 
830
- ### Enforcement + resume
763
+ ### Enforcement and resume
831
764
 
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.
765
+ 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
766
 
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.
767
+ `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
768
 
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`.
769
+ 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
770
 
842
771
  ### Installing skills (GitHub / npm / skills.sh)
843
772
 
@@ -849,18 +778,16 @@ await installSkillFromSpec({ source: 'https://skills.sh/anthropics/skills/fronte
849
778
  await installSkillFromSpec({ source: 'owner/repo#main:skills/foo', scope: 'user', preferSkillsCli: false })
850
779
  ```
851
780
 
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.
781
+ 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
782
 
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`.
783
+ 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
784
 
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).
785
+ In the TUI: **Settings → Skills → `ctrl+g`** (source → scope flow, same gesture as Extensions).
857
786
 
858
787
  ### Lenient load, strict authoring
859
788
 
860
789
  Third-party skills with minor deviations load anyway via `parseSkillFile` (warnings in `SkillConfig.diagnostics`). `defineSkill` / `writeSkillToDisk` run `validateSkillForWrite` — spec-strict.
861
790
 
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
791
  ## Structured Output
865
792
 
866
793
  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 +833,8 @@ createDockerContext({ image: 'node:22', cwd: '/workspace' })
906
833
  createSandboxContext(myProvider) // implement SandboxProvider (E2B, Rivet, …)
907
834
  ```
908
835
 
836
+ Capability model, remote/sandbox contexts, and the `ExecutionHandle` lifecycle: `docs/EXECUTION_CONTEXT.md`.
837
+
909
838
  ## Key Types
910
839
 
911
840
  | Type | Description |
@@ -913,7 +842,7 @@ createSandboxContext(myProvider) // implement SandboxProvider (E2B, Rivet, …)
913
842
  | `AgentOptions` | `createAgent()` config (+ `eager?` to pre-warm MCP). |
914
843
  | `AgentRunOptions` | `agent.run()` config. |
915
844
  | `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`. |
845
+ | `AgentStats` | Run result. `totalIn`/`totalOut`/`totalCacheRead`/`totalCacheCreation`/`cost` are cumulative (parent + children); `turns`/`turnUsage` parent-only. Plus `elapsed`, `output`, `children?`, `timeTillFirstTokenMs`. |
917
846
  | `Agent` | Instance (run, abort, steer, hooks, …). |
918
847
  | `Preset` | `Omit<Partial<AgentOptions>, 'provider' \| 'execution' \| 'session' \| 'mcpConnector'>`. |
919
848
  | `ToolDef` | `{ spec, execute }`. `execute` returns `string \| ToolResultContent[]`. |
@@ -931,20 +860,19 @@ createSandboxContext(myProvider) // implement SandboxProvider (E2B, Rivet, …)
931
860
  | `SkillConfig` / `SkillsConfig` | Parsed skill definition / config (`scan, write, exclude, enabled`). |
932
861
  | `InteractionToolOptions` | `createInteractionTool` config (`schema, onRequest`). |
933
862
  | `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)`. |
863
+ | `AgentHookMap` | `{ [event]: handler \| handler[] }` — agent-lifetime hooks on `AgentOptions.hooks`. |
864
+ | `RunHookMap` | Same shape, per-run scope (`agent.run({ hooks })`); handlers auto-detach. |
865
+ | `AgentBehavior.maxConcurrentTools` | Cap on in-flight tools per turn (default `10`, clamped at `1`). See [Per-tool concurrency](#per-tool-concurrency). |
866
+ | `ToolDef.isConcurrencySafe` | `boolean \| ((input) => boolean)`. Defaults to `false`. |
867
+ | `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). |
868
+ | `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
869
  | `ClassifiedError` | `{ kind, providerCode?, message? }` from `Provider.classifyError`. |
940
870
  | `AgentContextExceededError` / `AgentProviderError` / `AgentAbortedError` / `AgentToolNotAllowedError` / `AgentBudgetExceededError` | Typed errors. `toTypedError(err)` wraps native errors. |
941
871
  | `FileMapAdapter` | `{ get, save, delete }` — host-SDK seam for `createFileMapStore`. |
942
872
  | `TracingHooksOptions` | `createTracingHooks` config (`startSpan`, `namespace?`). |
943
873
  | `OpenAICompatParams` | `openaiCompat()` config (apiKey, baseURL, authHeader, extraHeaders, capabilities, cacheBreakpoints). |
944
874
  | `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. |
875
+ | `ValidationResult` | `{ valid, error?, coercedInput?, coercions?, droppedItems? }` — `validateToolArgs` output. `droppedItems` keyed by field name, reports original indexes of items dropped during recursive array validation. |
948
876
  | `toolOutputByteLength(content)` | Reproduces the loop's `outputBytes` formula. |
949
877
  | `toolResultToText(result)` | Flatten `string \| ToolResultContent[]` to a plain string. |
950
878
  | `tailTruncate(text, maxBytes, opts?)` | Tail-priority, UTF-8 byte-budgeted truncation (codepoint/surrogate-safe) — the shared discipline the `shell` tool uses. |
@@ -965,6 +893,7 @@ src/
965
893
  system-prompt.ts System-prompt section helpers (the `zidane/prompt` entry)
966
894
  mutation-validation.ts Host-side post-mutation validation hook helper
967
895
  tracing.ts createTracingHooks
896
+ cache-telemetry.ts installCacheBreakLogger, CacheBreakInfo/CacheDriftInfo
968
897
  stats.ts flattenTurns, statsByModel
969
898
  types.ts Shared types
970
899
  types-export.ts Public type surface for `zidane/types`
@@ -992,7 +921,11 @@ Narrow runtime subpaths for edge / Worker bundles — each re-exports root-publi
992
921
 
993
922
  ## Related
994
923
 
995
- - `docs/ARCHITECTURE.md` — execution-flow diagrams, hook firing order, gate precedence.
924
+ - `docs/ARCHITECTURE.md` — execution-flow diagrams, hook firing order, gate precedence, concurrency scheduler, tool ergonomics defaults.
925
+ - `docs/RUN_IN_BACKGROUND.md` — background task lifecycle, wire format, subagent task reassignment.
926
+ - `docs/EXECUTION_CONTEXT.md` — `ExecutionContext` capability model, remote/sandbox contexts.
927
+ - `docs/ARTIFACTS.md` — session artifact model, `artifact:recorded` consumers.
996
928
  - `docs/CHAT.md` — renderer-agnostic chat surface (auth, sessions, safe-mode, theme, edit-approval).
997
929
  - `docs/TUI.md` — OpenTUI terminal shell built on top.
998
930
  - `docs/INTERACTIONS.md` — `present_plan` / `ask_user` protocol.
931
+ - `docs/RESTATE.md` — durable execution (Restate) adapter, subagent journaling constraints.