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/CHAT.md CHANGED
@@ -1,811 +1,375 @@
1
1
  # Zidane Chat Engine
2
2
 
3
- Renderer-agnostic chat surface. Wraps the agent loop with auth + sessions + safe-mode + theme + settings + interactions + keybindings + MCP OAuth state and exposes them as React contexts. **No terminal dependencies** — `zidane/chat` powers `zidane/tui` and any future GUI / web shell on equal footing.
3
+ Renderer-agnostic chat surface: wraps the core agent loop with config resolution, sessions/store, safe mode, settings, theme, interactions, keybindings, MCP OAuth, todos, and slash commands, exposed as plain functions + React contexts. No terminal dependencies — `zidane/tui` and any GUI/web shell sit on top of it equally.
4
4
 
5
5
  ```ts
6
6
  import { resolveConfig } from 'zidane/chat'
7
7
  ```
8
8
 
9
- Three subpaths, three platform contracts:
10
-
11
- | Package | Pulls | Runtime |
9
+ | Package | Adds | Runtime |
12
10
  |---|---|---|
13
- | `zidane` | core agent loop, providers, tools, MCP, compact, loop persistence | Bun / Node |
11
+ | `zidane` | agent loop, providers, tools, MCP, compaction, loop persistence | Bun / Node |
14
12
  | `zidane/chat` | + React contexts, session reconstruction, theme, safe-mode, interactions, keybindings, MCP OAuth state | Bun / Node + React |
15
- | `zidane/tui` | + OpenTUI primitives, screens, components | **Bun + terminal** (`bun:sqlite`, native OpenTUI bindings) |
16
-
17
- A non-Bun host (Electron / Tauri / Next.js) imports `zidane/chat` only. The split is build-enforced — `dist/chat.js` contains zero OpenTUI imports and zero `bun:sqlite`.
13
+ | `zidane/tui` | + OpenTUI primitives, screens, components | Bun + terminal (`bun:sqlite`, native OpenTUI bindings) |
14
+
15
+ A non-Bun host (Electron, Tauri, Next.js) imports `zidane/chat` only — build-enforced: `dist/chat.js` carries zero OpenTUI imports and zero `bun:sqlite`.
16
+
17
+ ## Contents
18
+
19
+ - [GUI checklist](#gui-checklist)
20
+ - [Sub-modules](#sub-modules)
21
+ - [Required options](#required-options)
22
+ - [Environment variables](#environment-variables)
23
+ - [Local provider](#local-provider)
24
+ - [Storage-slot layout](#storage-slot-layout)
25
+ - [Agents](#agents)
26
+ - [System prompt](#system-prompt)
27
+ - [Settings](#settings)
28
+ - [Theme](#theme)
29
+ - [Safe mode](#safe-mode)
30
+ - [Todos](#todos)
31
+ - [Interactions](#interactions)
32
+ - [Keybindings](#keybindings)
33
+ - [Auto-compaction](#auto-compaction)
34
+ - [Context-usage breakdown](#context-usage-breakdown)
35
+ - [Subscription usage](#subscription-usage)
36
+ - [`SYSTEM.md` — overriding the built-in prompt](#systemmd--overriding-the-built-in-prompt)
37
+ - [Context segments](#context-segments)
38
+ - [Edit-diff rendering](#edit-diff-rendering)
39
+ - [Per-edit approval](#per-edit-approval)
40
+ - [Tool call display](#tool-call-display)
41
+ - [Session reconstruction](#session-reconstruction)
42
+ - [Streaming buffer](#streaming-buffer)
43
+ - [`StreamEvent`](#streamevent)
44
+ - [Completion](#completion)
45
+ - [Discovery context](#discovery-context)
46
+ - [Project skills + MCP servers](#project-skills--mcp-servers)
47
+ - [OAuth (two surfaces)](#oauth-two-surfaces)
48
+ - [Cross-provider model catalog](#cross-provider-model-catalog)
49
+ - [Effective context window](#effective-context-window)
50
+ - [Tool-result persistence](#tool-result-persistence)
51
+ - [Compaction primitives](#compaction-primitives)
52
+ - [Session export](#session-export)
53
+ - [Session artifacts](#session-artifacts)
54
+ - [Commands](#commands)
55
+ - [Title generation](#title-generation)
56
+ - [Turn operations](#turn-operations)
57
+ - [IPC contracts](#ipc-contracts)
58
+ - [Related](#related)
18
59
 
19
60
  ## GUI checklist
20
61
 
21
- What a non-TUI shell wires to reach feature parity with `zidane/tui`. All renderer-agnostic.
22
-
23
- **Provider stack** — mount in this order (`ThemedShell` reads `settings.theme` and wraps `<ThemeProvider>`; an embedder rendering modals also adds `<DiscoveryProvider>` above `<ModalRoot>`):
24
-
25
- ```tsx
26
- import {
27
- ConfigProvider, SettingsProvider, ThemeProvider, SafeModeProvider,
28
- InteractionsProvider, McpAuthProvider, DiscoveryProvider,
29
- DEFAULT_SETTINGS, resolveConfig, resolveTheme, useSettings,
30
- } from 'zidane/chat'
31
-
32
- <ConfigProvider config={resolveConfig({ store: myStoreFactory })}>
33
- <SettingsProvider initial={{ ...DEFAULT_SETTINGS, ...config.initialSettings }} onChange={...}>
34
- <ThemedShell>
35
- <SafeModeProvider>
36
- <InteractionsProvider>
37
- <McpAuthProvider>
38
- <DiscoveryProvider value={discoveryValue}>
39
- <YourScreens />
40
- </DiscoveryProvider>
41
- </McpAuthProvider>
42
- </InteractionsProvider>
43
- </SafeModeProvider>
44
- </ThemedShell>
45
- </SettingsProvider>
46
- </ConfigProvider>
47
- ```
48
-
49
- The TUI's `<App>` (`src/tui/app.tsx`) adds two OpenTUI-only providers underneath — `MdStyleProvider` + `ChipStyleProvider` — that pre-build `SyntaxStyle` instances. A GUI maps the same styles to CSS variables / Tailwind tokens directly off the `Theme` object instead.
50
-
51
- `ChatOptions.store` is required — chat ships no default so the bundle stays platform-neutral. Terminal hosts pass `createTuiStore` from `zidane/session/sqlite`; an Electron / Tauri host can pass `createFileMapStore` or any custom `SessionStore` (IndexedDB, remote, in-memory).
52
-
53
- **Agent build** — wire interaction tools, OAuth-aware MCP connector, project skills + MCPs, and the cwd-anchored system prompt:
54
-
55
- ```ts
56
- import {
57
- buildBuildSystem, buildSkillsConfig, buildMcpServers,
58
- createFileMcpCredentialStore, createInteractionTools, makeRequestInteraction,
59
- defaultSkillScanPaths, discoverProjectSkills, discoverProjectMcps,
60
- } from 'zidane/chat'
61
- import { connectMcpServers } from 'zidane/mcp'
62
- import { McpOAuthProvider } from 'zidane/mcp'
63
-
64
- const credentialStore = createFileMcpCredentialStore(paths.userDir)
65
- const skillsScan = defaultSkillScanPaths({ cwd: projectDir, prefix })
66
- const skillsConfig = buildSkillsConfig({ scan: skillsScan, enabled: settings.enabledSkills })
67
- const { servers: discoveredMcps } = discoverProjectMcps({ cwd: projectDir, prefix })
68
- const mcpServers = buildMcpServers({ discovered: discoveredMcps, enabled: settings.enabledMcps })
69
-
70
- const agent = createAgent({
71
- ...profile.preset,
72
- system: buildBuildSystem({ cwd: projectDir }),
73
- skills: skillsConfig,
74
- mcpServers,
75
- tools: {
76
- ...profile.preset.tools,
77
- ...createInteractionTools({ requestInteraction: makeRequestInteraction(interactionsActions) }),
78
- },
79
- provider: descriptor.factory(),
80
- session,
81
- mcpConnector: configs => connectMcpServers(configs, undefined, agent.hooks, {
82
- buildAuthProvider: cfg => new McpOAuthProvider({ name: cfg.name, store: credentialStore }),
83
- }),
84
- })
85
- ```
86
-
87
- **Required hooks** (wire on every `buildAgent` call — see `src/tui/app.tsx` for the canonical wiring):
88
-
89
- | Hook | Purpose | Consumer in chat layer |
90
- |---|---|---|
91
- | `tool:gate`, `child:tool:gate`, `mcp:tool:gate`, `child:mcp:tool:gate` | Approval gate | `useSafeModeActions().requestApproval(name, input, originator?)` + (for file-edit tools) `resolveApprovalForPayload`; partial → rebind `ctx.input` to the approved subset + stash outcomes in a pending-annotation map. See **Per-edit approval**. |
92
- | `tool:transform`, `child:tool:transform` | Per-edit annotation | Look up `callId` in the pending-annotation map and append `buildEditOutcomesAnnotation(outcomes)` to `ctx.result`. Clear the entry. |
93
- | `agent:done` | Stranded-annotation sweep | `pendingAnnotations.clear()` — covers completed / aborted / error paths so a never-fired `tool:transform` doesn't leak. |
94
- | `mcp:auth:required`, `mcp:auth:url`, `mcp:auth:success`, `mcp:auth:error`, `mcp:connect` | OAuth badge state | `useMcpAuthDispatch` → `reduceMcpAuth` |
95
- | `stream:thinking`, `stream:text`, `child:stream:thinking`, `child:stream:text` | Streaming deltas | `useStreamBuffer().queueStreamDelta` |
96
- | `tool:before`, `tool:after`, `mcp:tool:after`, `child:tool:before`, `child:tool:after` | Tool call/result events | `stream.appendImmediate` |
97
- | `spawn:before`, `spawn:complete`, `spawn:error`, `child:stream:end` | Subagent markers | `stream.appendImmediate` + `finalizeStreamingMarkdownForOwner` |
98
- | `turn:after` | Finalize trailing markdown, capture context size | `stream.flushAndUpdate` |
99
- | `compact:start`, `compact:end` | Loop-native auto-compaction lifecycle — banner, boundary card, footer context update | `stream.appendImmediate` + `setLastInputTokens` |
100
-
101
- **Auto-compaction** is loop-native (`behavior.autoCompact`): the SDK loop fires it mid-run, so the host just injects the per-run config via `chatAutoCompactBehavior(...)` and paints feedback from the `compact:start` / `compact:end` hooks. See **Auto-compaction** below.
102
-
103
- **Session activation** — call `pendingInteractionsFromTurns(session.turns)` on activation. If non-empty, enqueue each request with a resolver that collects responses into a `Map`, and once full calls `buildResumedToolResultsTurn(requests, responses, { turnId, runId })` + appends + triggers a prompt-less `agent.run()`. See `docs/INTERACTIONS.md` for the full protocol.
104
-
105
- **Tool-result persistence** — set `behavior.persistDir` (per session, under `paths.userDir`) and let `DEFAULT_PERSIST_EXCLUDE_TOOLS` filter out the always-inline tools. Toggle off by setting `behavior.persistThreshold = 0` (the `<persisted-output>` branch in the loop short-circuits).
106
-
107
- **Session export / details** — `writeSessionExport`, `renderSession`, `generateSessionTitle`, `compactConversation`, `deleteTurnSafely`, `truncateTurnsAt` cover every action a session-details surface needs.
62
+ A host embedding `zidane/chat` in its own shell needs, at minimum:
108
63
 
109
- Everything else (file completion, slash-commands, edit-diff rendering, chip pills, keybindings parsing) is detailed in the per-module sections below.
64
+ 1. **Resolve config** — `resolveConfig(opts)` → `ResolvedConfig` (providers, paths, agents, initial picks).
65
+ 2. **Create a session store** — implement `SessionStore` (or reuse `zidane/tui`'s sqlite one) and pass it to `ChatOptions.store`.
66
+ 3. **Mount the settings + theme + safe-mode + discovery + MCP-auth contexts** around the transcript tree, in that nesting order relative to any modal root (each section below says why).
67
+ 4. **Wire hooks → a `StreamEvent[]` buffer** via `useStreamBuffer()` — see **Streaming buffer**.
68
+ 5. **Render `'tool'` / `'tool-result'` events** through `TOOL_DISPLAY` + `isVisible` — see **Tool call display**.
69
+ 6. **Wire `tool:gate`** for safe mode + per-edit approval — see **Safe mode** and **Per-edit approval**.
70
+ 7. **Reconstruct history on load** via `eventsFromTurns` — see **Session reconstruction**.
110
71
 
111
72
  ## Sub-modules
112
73
 
113
- The table below indexes every named export; sections further down dive into the surfaces a GUI builder needs.
74
+ Every named export lives in one file under `src/chat/`; each row is `<module>` → one-line purpose. Modules with a dedicated section below only get a pointer here — the section is the source of truth.
114
75
 
115
- | Module | Surface |
76
+ | Module(s) | Purpose |
116
77
  |---|---|
117
- | `agents` | Multi-profile registry — `AgentProfile`, `AgentRegistry`, `AgentAccent`, `BUILD_AGENT`, `PLAN_AGENT`, `BUILTIN_AGENTS`, `DEFAULT_AGENT_ID`, `DEFAULT_PERSIST_EXCLUDE_TOOLS`, `resolveAgentId`, `singleAgentRegistry`, `accentColor`. See **Agents** below. |
118
- | `agent-prompt` | Composable system-prompt fragments — `IDENTITY_PREFIX`, `DOING_TASKS_DOCTRINE`, `CONVENTIONS_DOCTRINE`, `GIT_DOCTRINE`, `ACTIONS_WITH_CARE_DOCTRINE`, `TOKEN_DISCIPLINE_DOCTRINE`, `TOKEN_DISCIPLINE_DOCTRINE_READ_ONLY`, `COMMUNICATION_DOCTRINE`, `SUBAGENT_GUIDANCE`, `PLAN_MODE_DOCTRINE`, `PLAN_MODE_DOCTRINE_NO_PROMPTS`, `INTERACTION_GUIDANCE`, `INTERACTION_GUIDANCE_NO_PROMPTS`, `envSection`, `buildBuildSystem`, `buildPlanSystem`. Types: `BuildSystemOptions`, `EnvSectionOptions`. See **System prompt** below. |
119
- | `agents-md` | Personal-instructions discovery — `discoverAgentsMd({ cwd, home, prefix, scope })` walks `~/.agents/AGENTS.md`, `~/.{prefix}/AGENTS.md`, `~/.claude/CLAUDE.md`, `<repo>/AGENTS.md`, `<repo>/.agents/AGENTS.md`, `<repo>/.{prefix}/AGENTS.md`, `<repo>/CLAUDE.md` and loads **every** file that exists (broadest → narrowest, deduped by absolute path; empty / whitespace-only files skipped). The `scope` option (`'none'` / `'user'` / `'project'` / `'both'`, default `'both'`) restricts which scopes contribute — `'none'` short-circuits before any filesystem access. Renders a `userInstructions` block consumed by `buildBuildSystem` / `buildPlanSystem`. Types: `AgentsMdFile`, `AgentsMdResult`, `AgentsMdScope`. |
120
- | `auth` | `detectAuth(dataDir, providers)` reports which providers have usable credentials and which auth method each accepts. `ProviderAuth.methods[].source` is `'apikey' \| 'oauth'`. |
121
- | `auto-compact` | `chatAutoCompactBehavior({ enabled, threshold, rawContextWindow })` — build the per-run `behavior.autoCompact` for loop-native compaction; `shouldAutoCompact(input)` — the pure predicate the loop uses. See **Auto-compaction**. |
122
- | `boot-profiler` | `bootTick(label)` / `bootProfileEnabled()` — `ZIDANE_BOOT_PROFILE=1` opt-in wall-clock checkpoints for boot-path perf work. |
123
- | `browser` | `tryOpenBrowser(url)` — best-effort cross-platform launcher (`open` / `xdg-open` / `start`); used by both provider OAuth and MCP OAuth. |
124
- | `color-gradient` | `blendHsl`, `buildLinearRamp` — HSL-space ramp builders shared by the TUI's throbber + title overlay. |
125
- | `completion` | Extensible prompt autocompletion. `CompletionProvider<TItem>`, `useCompletion`, helpers (`findActiveTrigger`, `applyInsert`, `collectReferences`, `mergeReferences`). See **Completion**. |
126
- | `completion-skills`, `completion-files` | Built-in `/` (skills) + `@` (files) providers. Submit helpers: `uniqueSkillNamesFromReferences`, `uniqueFilesFromReferences`. |
127
- | `config` + `config-context` | `resolveConfig`, `resolveStoragePaths`, `useConfig`, `ChatOptions`, `ResolvedConfig`, `ResolvedPaths`, `ModelInfo`, `ProviderRegistry`. See **Required options**. |
128
- | `xdg` | XDG Base Directory resolver — `resolveStorageDirs({ prefix, storageDir?, home?, platform?, env? })` returns `{ configDir, dataDir, stateDir, cacheDir, mode }`. Honors `XDG_*_HOME` / `ZIDANE_*_DIR` env vars; preserves legacy `~/<prefix>/` for existing setups. Types: `StorageDirs`, `StorageMode`, `StorageSlot`. See **Storage-slot layout**. |
129
- | `credentials` | AI-provider credential store. `setProviderCredential`, `readProviderCredential`, `removeProviderCredential`, `readCredentials`, `writeCredentials`, `credentialsPath`, `applyApiKeyEnv` (called by `resolveConfig` before any factory runs). Owner-only file mode. |
130
- | `discovery-context` + `discovery-slot` | Live catalog plumbing: `DiscoveryProvider`, `useDiscovery`, `useDiscoveryOptional`, `createDiscoverySlot` (stale-while-revalidate primitive). Propagates fresh catalogs (files, skills, MCPs) into open modals. See **Discovery context** below. |
131
- | `edit-approval` | Pure helpers bridging the safe-mode gate to the host-side per-hunk flow — `resolveApprovalForPayload`, `maskToOutcomeKinds`, `buildEditOutcomesAnnotation`, `parseEditOutcomesFromResult`, `summarizeOutcomes`, `ResolvedApproval`. See **Per-edit approval**. |
132
- | `edit-diff` | Diff plumbing — `extractEditPayload`, `previewEditPayload`, `applyEditPayload`, `buildUnifiedDiff`, `buildContextualDiff`, `computeLineDiff`, `computeInlineDiff`, `splitLines`, `tokenize`, `filetypeFromPath`. See **Edit-diff rendering**. |
133
- | `enabled-toggle-set` | `useEnabledToggleSet({ catalog, keyOf, settingKey })` — generic state machine for `enabledSkills` / `enabledMcps` (undefined = all enabled, `[]` = off, `[names]` = allowlist). |
134
- | `files-discovery` | `listProjectFiles({ cwd, signal? })` → `FileEntry[]`, gitignored paths excluded. Includes directory entries (`kind: 'dir'`) derived from the file paths, so `@` mentions can reference dirs too. |
135
- | `format` | `fmtTokens`, `fmtCost`, `ageString`, `shortId`, `compactPath` — display-only helpers. |
136
- | `generate-title` | `generateSessionTitle({ session, provider, model, signal? })` + `cleanTitle` + `shouldAutoGenerateTitle(turns, metadata?)` (true once a session has ≥2 turns and no explicit `metadata.title` — the TUI's automatic-titling gate). Framed as a LABELING call, not a chat turn: delimited `<transcript>` first, instruction last, annotator-not-participant system prompt with worked examples. The prompt carries the opening user turn (topic anchor) plus the trailing `maxTurns`, since the tail alone titles whatever tangent the session was on. `cleanTitle` throws on answer-shaped replies (`I …`, `Sure …`, `Here's …`) and bracketed placeholders, so a model that answers instead of labeling reads as a failed generation and the gate retries on the next run. |
137
- | `hints` | Footer + prompt-hint plumbing — `Hint`, `hintsLength`, `clipHintsToWidth`, `truncateTrailing`. Plain-text math; rendering is the consumer's job. |
138
- | `interactions` | Plan + Q&A protocol (`present_plan`, `ask_user`). See **Interactions** and `docs/INTERACTIONS.md`. |
139
- | `keybindings` | Action catalog + JSONC overrides — `KEYBINDING_DEFS`, `KEYBINDING_DEF_BY_ACTION`, `DEFAULT_KEYBINDINGS`, `parseBindingSpec`, `matchesBinding`, `mergeKeybindings`, `formatBindingForDisplay`, `groupBindings`, `KEYBINDING_KEY_COL_WIDTH`, `readKeybindings`, `ensureKeybindingsFile`, `keybindingsPath`, `stripJsonComments`. See **Keybindings**. |
140
- | `markdown-segments` | `splitMarkdownCodeBlocks(text)` → `MarkdownSegment[]` (`'prose' \| 'code'`). Lets transcript renderers wrap fences with custom chrome (copy buttons, language pills) without re-parsing. |
141
- | `mcp-auth-state` | Pure reducer — `reduceMcpAuth(state, event)`, `getMcpAuthStatus`, `McpAuthStatus`, `McpAuthEvent`, `McpAuthStateMap`. See **MCP OAuth state**. |
142
- | `mcp-auth-context` | React provider — `McpAuthProvider`, `useMcpAuthState`, `useMcpAuthDispatch`. Two contexts split so dispatch consumers don't re-render on state churn. |
143
- | `mcp-credentials` | File-backed `McpCredentialStore` at `<dataDir>/mcp-credentials.json` (0o600). `createFileMcpCredentialStore`, `patchMcpCredential`, `mcpCredentialsPath`. |
144
- | `mcp-rows` | Renderer-agnostic flat row model shared by the standalone `McpsSettingsModal` and the inline MCP tab inside the unified `SettingsModal`. `buildVisibleMcpRows(catalog, expanded, toolsByServer)` → `VisibleMcpRow[]` (`'server' \| 'tool' \| 'tool-empty'`); `indexOfServerRow`, `parentServerName`. |
145
- | `mcp-tool-toggle` | Per-tool deny-list state machine over `Settings.disabledMcpTools[serverName]`. `useMcpToolToggleSet({ serverName, catalog })` for a single server; `useMcpToolToggleMap({ catalogByServer })` for the multi-server settings UI (stable hook count across React renders). `buildToolToggle` for renderer-agnostic factory use. |
146
- | `mcp-tools-cache` | Per-server tool catalog persistence at `<dataDir>/mcp-tools-cache.json`. `loadMcpToolsCache`, `saveMcpToolsCache`, `clearMcpToolsCache`, `mcpToolsCachePath`, `CachedMcpToolList`, `McpToolsCache`. `subscribeMcpToolsCache(agent.hooks, { dataDir, onChange? })` listens on `mcp:tools:list` and writes the file; `refreshMcpToolsCatalog({ servers, hooks, ... })` opens transient connections to repopulate without a full agent run. |
147
- | `mcps-discovery` | `discoverProjectMcps`, `parseMcpsFile`, `buildMcpServers`, `defaultMcpsConfigPaths`, `DiscoveredMcp`, `DiscoveryError`, `DiscoveryResult`. `buildMcpServers` now also accepts `disabledTools: Settings['disabledMcpTools']` and unions it into each config's `disabledTools` (the JSON-side deny-list still wins on collisions). See **Project MCP servers**. |
148
- | `model-catalog` | Cross-provider unified model picker assembly — `buildModelCatalog`, `filterModelCatalog`, `indexOfEntry`, `parseCustomModelQuery`, `CatalogEntry`. |
149
- | `model-chain` | Ordered model fallback chains (pure helpers behind the picker's multi-select + the host's run-option assembly) — `ModelChainEntry`, `toggleChainEntry`, `chainPositionOf`, `chainsEqual`, `filterChainFallbacks`, `restoreChainFallbacks`, `sanitizeModelChain`, `providerKeySetChanged`, `buildRunModel`. |
150
- | `oauth` | `runOAuthLogin(descriptor, { onUrl, onPrompt, onProgress, signal })` + `supportsOAuth(descriptor)` + `oauthUsesManualCodePaste(descriptor)` — AI-provider OAuth (distinct from MCP OAuth). |
151
- | `oauth-redirect` | `fetchOAuthRedirect(pastedUrl, { authUrl? })` — SSH escape hatch. Validates `pasted` is a loopback URL, fires a GET so the in-process callback server (pi-ai's or `startOAuthCallback`'s) receives the request through its normal handler. Used by the TUI to accept a redirect-URL paste when the browser couldn't reach the callback server directly. `normalizeRedirectPaste(pasted, authUrl)` widens what counts as a paste (bare code / query / path); `redirectUriFromAuthUrl(authUrl)` reads back the loopback callback the request was minted with, which the TUI also displays. |
152
- | `path-display` | `formatPathForCwd(projectRel, projectRoot, cwd)` — rewrites a project-root-relative path (as emitted by `listProjectFiles`) into the form the agent's CWD-resolving tools actually find. Wired into `createFilesCompletionProvider({ formatPath })` by the TUI shell. |
153
- | `project-root` | `findGitRoot(cwd)` — walks upward looking for `.git`, returns absolute path or `null`. Used for session scope tagging + export anchors. |
154
- | `prompt-segments` | `splitPromptSegments(text, refs)` → `PromptSegment[]`. GUI maps the same segments to inline-block chip pills. |
155
- | `project-user-paths` | `projectUserPaths({ subPath, cwd, home, prefix })` — shared search-order builder for project + user config discovery. Powers `defaultSkillScanPaths` (`'skills'`) and `defaultMcpsConfigPaths` (calls it twice, once per `mcps.json` / `mcp.json`); the same convention extends to any new discovery surface. |
156
- | `provider-usage` | Subscription usage per provider: `fetchProviderUsage`, `fetchAllProviderUsage`, `providerUsageSupported`, `usageSourceFor`, `mostConstrainedWindow`, `formatUsagePercent`, `clearProviderUsageCache`, `USAGE_CACHE_TTL_MS`. Types: `ProviderUsageSnapshot`, `ProviderUsageWindow`, `ProviderUsageCredits`, `ProviderUsageStatus`. Read-only and fail-soft (never refreshes tokens, never throws). See **Subscription usage**. |
157
- | `providers` | `ProviderDescriptor` registry + helpers — `BUILTIN_PROVIDERS` (`anthropicDescriptor`, `openaiDescriptor`, `openrouterDescriptor`, `cerebrasDescriptor`, `arceeDescriptor`, `localDescriptor`), `modelsForDescriptor`, `getModelInfo`, `getContextWindow`, `effectiveContextWindow`, `modelSupportsReasoning`, `OUTPUT_RESERVE_TOKENS`, `credKeyOf`, `piIdOf`. `cursorDescriptor` is exported but NOT in `BUILTIN_PROVIDERS` (OAuth works, inference not wired). See **Local provider**. |
158
- | `safe-mode` | Per-project safelist + matchers — `IMPLICITLY_SAFE_TOOLS`, `suggestSafelistEntries`, `addToSafelist`, `getSafelist`, `isOnSafelist`, `matchesSafelistEntry`, `readProjects`, `writeProjects`, `projectsFilePath`. |
159
- | `safe-mode-context` | `SafeModeProvider` owns a FIFO approval queue. `useSafeModeQueue()` returns the live array; `useSafeModeActions()` returns stable `{ requestApproval, resolveHead, denyAll }`. |
160
- | `session-export` | `renderSession`, `resolveSessionExportTarget`, `writeSessionExport`, `SessionExportFormat`, `SessionExportAnchor`, `SessionExportTarget`. |
161
- | `settings-context` | `SettingsProvider`, `useSettings`, `DEFAULT_SETTINGS`, `SETTINGS_TOGGLES`, `SETTINGS_CHOICES`, `SETTINGS_INPUTS`, `SETTINGS_CATEGORIES`, `BooleanSettingKey`, `SettingsCategory`, `SettingsCategoryDescriptor`. See **Settings**. |
162
- | `skills-discovery` | `discoverProjectSkills`, `buildSkillsConfig`, `defaultSkillScanPaths`. |
163
- | `store` | Session reconstruction + persisted UI state + transcript view rules — `eventsFromTurns`, `lastContextSizeFromTurns`, `listSessionMeta`, `deriveSessionTitle`, `titleFromTurns`, `selectableTurnIds`, `stripSpawnTokensLine`, `toolCallPreview`, `toolResultText`, `createStateStore`, `loadState`, `saveState`, `isVisible`, `marginTopFor`, `isEditErrorResult`, `isTurnHighlighted`, `sumRunCosts`, `usageByModel`, `turnSelectionOwnership`, `TuiState`, `StateStoreApi`, `ModelUsageSummary`. |
164
- | `streaming` | `useStreamBuffer`, `finalizeStreamingMarkdown`, `finalizeStreamingMarkdownForOwner`, `turnContextSize`. Per-owner finalize handles concurrent parent + child streams. |
165
- | `theme` + `theme-context` | `Theme`, `BUILTIN_THEMES`, `resolveTheme`, `resolveChipColor`, `DEFAULT_THEME`, themes (`CATPPUCCIN_*`, `VAPORWAVE_THEME`), hooks (`useTheme`, `useColors`, `useSelectStyle`, `useSurfaces`, `useSyntaxStyles`). See **Theme**. |
166
- | `todos` | `todowrite` / `todoread` tool factory — `createTodoTools` (returns a `Preset`), `isTodoTool`, `getTodosForRun`, `setTodosForRun`, `pruneTodosByRun`, `TODOWRITE_TOOL`, `TODOREAD_TOOL`, `TODOS_METADATA_KEY`, `TODO_WRITE_COUNTS_METADATA_KEY`, `TodoItem`, `TodoStatus`, `TodosBag`, `CreateTodoToolsOptions`. Persistent task checkpointing — top-level runs share a session-scoped slot (continuity across prompts); subagent runs get their own slot (isolation). On by default: a run-scoped `block-after` dedup (`maxIdenticalWrites`) hard-blocks consecutive identical payloads (loop breaker). Optional blanket per-run budget via `behavior.toolBudgets`; a soft in-body identical dedup also short-circuits to "No change". Compose with `composePresets` or spread. See **Todos** below. |
167
- | `tool-formatters` | Per-tool display verbs + curated input formatters — `TOOL_DISPLAY`, `displayNameFor`, `formatToolCall`, `ToolDisplayMeta`, `ToolFormatLine`. Powers the `'formatted'` `toolCallDisplay` view. See **Tool call display**. |
168
- | `transcript-anchors` | `computeTurnAnchors(items)` → `TranscriptItem[]` — assigns `turn-anchor-<turnId>` ids consumers wire into their renderer's scroll-into-view (TUI: `ScrollBoxRenderable.scrollChildIntoView`; GUI: DOM `element.scrollIntoView`). |
169
- | `turn-operations` | `deleteTurnSafely`, `truncateTurnsAt`, `turnAsText`, `countNeighbors`. Fork / delete with orphan tool-pair cleanup. |
170
- | `types` | Re-exports `Owner`, `Picked`, `Screen`, `SessionMeta`, `Settings`, `StreamEvent`, `EditPayload`, `EditHunk`, `EditOutcome`, `EditOutcomeKind`, `ToolCallDisplay`. |
78
+ | `config`, `config-context` | `resolveConfig`, `ResolvedConfig`/`ResolvedPaths`, `ChatOptions`. See **Required options**. |
79
+ | `xdg` | `resolveStorageDirs` — XDG-aware storage slot layout. See **Storage-slot layout**. |
80
+ | `agents`, `agent-prompt` | `SHARED_BEHAVIOR`, `BUILTIN_AGENTS`, system-prompt builders. See **Agents**, **System prompt**. |
81
+ | `settings-context`, `types` | `Settings`, `DEFAULT_SETTINGS`, `SETTINGS_TOGGLES`/`SETTINGS_CHOICES`/`SETTINGS_INPUTS`. See **Settings**. |
82
+ | `theme`, `theme-context` | `BUILTIN_THEMES`, `useColors`, `ThemeProvider`. See **Theme**. |
83
+ | `safe-mode`, `safe-mode-context` | `IMPLICITLY_SAFE_TOOLS`, `useSafeModeQueue`. See **Safe mode**. |
84
+ | `todos` | `createTodoTools`, hygiene defaults. See **Todos**. |
85
+ | `interactions` | `createInteractionTools`, resumed-orphan helpers. See **Interactions** (full contract in `docs/INTERACTIONS.md`). |
86
+ | `keybindings` | `KEYBINDING_DEFS`, `KeyAction`, `matchesBinding`, JSONC overrides. See **Keybindings**. |
87
+ | `auto-compact` | `chatAutoCompactBehavior`, re-exports `shouldAutoCompact` from `zidane/compact/policy`. See **Auto-compaction**. |
88
+ | `provider-usage`, `providers` | `fetchProviderUsage`, cross-provider subscription snapshots. See **Subscription usage**. |
89
+ | `agents-md` | `discoverAgentsMd` — walks `AGENTS.md`/`CLAUDE.md` up from cwd per `AgentsMdScope`; `renderAgentsMdBlock` wraps the merged text for the prompt. |
90
+ | `discovery-context`, `discovery-slot` | `DiscoveryProvider`, `createDiscoverySlot` (SWR catalogs). See **Discovery context**. |
91
+ | `mcps-discovery` | `discoverProjectMcps`, `parseMcpsFile`, `buildMcpServers`. See **Project skills + MCP servers**. |
92
+ | `mcp-credentials`, `mcp-rows`, `mcp-tool-toggle`, `mcp-tools-cache` | Per-server token storage, settings-modal row shaping, per-tool enable/disable, cached `listTools()` results keyed by server identity. |
93
+ | `oauth`, `oauth-redirect` | AI-provider + MCP OAuth. `fetchOAuthRedirect`/`normalizeRedirectPaste` handle SSH paste-back. See **OAuth**. |
94
+ | `mcp-auth-state`, `mcp-auth-context` | `McpAuthStatus` reducer + context. See **OAuth → MCP OAuth state**. |
95
+ | `model-catalog`, `model-chain` | `buildModelCatalog`, `filterModelCatalog`, fallback-chain persistence. See **Cross-provider model catalog**. |
96
+ | `edit-approval`, `edit-diff` | `resolveApprovalForPayload`, `extractEditPayload`, diff builders. See **Edit-diff rendering**, **Per-edit approval**. |
97
+ | `tool-formatters` | `TOOL_DISPLAY`, `displayNameFor`, `formatToolCall`. See **Tool call display**. |
98
+ | `store` | `eventsFromTurns`, `listSessionMeta`, `deriveSessionTitle`, transcript-view rules. See **Session reconstruction**. |
99
+ | `streaming` | `useStreamBuffer`, `finalizeStreamingMarkdown(ForOwner)`, `turnContextSize`. See **Streaming buffer**. |
100
+ | `completion`, `completion-skills`, `completion-files`, `completion-chats`, `completion-artifacts`, `completion-commands` | `useCompletion` + built-in providers (`/` skills & commands, `@` files, `#` chats & artifacts). See **Completion**. |
101
+ | `commands`, `builtin-commands` | `Command`/`CommandStep`, `executeCommand`, `createBuiltinCommands` (`/help`, `/reload`, `/artifacts`). See **Commands**. |
102
+ | `markdown-segments`, `prompt-segments` | Markdown → renderable segments; `splitPromptSegments` for submitted-prompt chip highlighting. |
103
+ | `session-export` | `renderSession`, `writeSessionExport`. See **Session export**. |
104
+ | `generate-title` | `generateSessionTitle`, `cleanTitle`. See **Title generation**. |
105
+ | `turn-operations` | `deleteTurnSafely`, `truncateTurnsAt`, `turnAsText`. See **Turn operations**. |
106
+ | `format` | Byte/duration/number formatting shared across footers and modals. |
107
+ | `credentials`, `auth` | `readCredentials`/`writeCredentials` (`credentials.json`), `detectAuth` (resume-picker eligibility). |
108
+ | `color-gradient` | `blendHsl`, `buildLinearRamp` — theme ramp generation. |
109
+ | `browser` | `tryOpenBrowser` — best-effort OS browser launch for OAuth URLs. |
110
+ | `boot-profiler` | `bootTick`, opt-in boot-time tracing (`ZIDANE_BOOT_PROFILE`). |
111
+ | `files-discovery`, `path-display`, `project-root`, `project-user-paths` | `@`-completion file catalog; path shortening for narrow UIs; git-root detection; `projectUserPaths` (shared project+user search-path builder). |
112
+ | `hints` | Contextual one-liner hints shown when the prompt is empty. |
113
+ | `transcript-anchors` | Scroll-anchor bookkeeping for the transcript view (keep-reading-position on new content). |
114
+ | `types` | Root type barrel: `Settings`, `StreamEvent`, `ChatOptions`, etc. |
115
+
116
+ Extension-facing modules under `src/chat/` (`artifacts-view`, `claude-import`, `external-editor`, `footer-chips`, `footer-hints`, `history-search`, `instruction-rows`, `orchestration`, `plugins-discovery`, `ruler-configs`, `run-receipt`, `session-cwd`, `shell-parse`, and daemon/host glue) back TUI-specific or daemon-specific features documented in `docs/TUI.md`, `docs/EXTENSIONS.md`, and `docs/IPC.md`.
171
117
 
172
118
  ## Required options
173
119
 
174
- `resolveConfig(options)` accepts a single object. Everything is optional except `store`:
175
-
176
120
  ```ts
177
- resolveConfig({
178
- // Required: session storage adapter.
179
- // Instance or factory `(paths) => SessionStore`.
180
- store: paths => createTuiStore(paths.db),
181
-
182
- // Storage location.
183
- prefix: '.zidane', // default `.zidane`
184
- storageDir: process.env.HOME, // default homedir; resolves to `${storageDir}/${prefix}`
185
-
186
- // Project-scoped session storage (off by default).
187
- // When `true` AND inside a git repo, sessions.db + state.json land at
188
- // `<git-root>/.{prefix}/`; credentials + safelist stay at user dir.
189
- projectDb: false,
190
- cwd: process.cwd(), // override the project-root walk root
191
-
192
- // Provider registry — defaults to BUILTIN_PROVIDERS (Anthropic + OpenAI + OpenRouter + Cerebras + Arcee + Local).
193
- providers: { ...BUILTIN_PROVIDERS, mine: myDescriptor },
194
-
195
- // Agent registry — defaults to BUILTIN_AGENTS (Build + Plan).
196
- agents: { ...BUILTIN_AGENTS, debug: myProfile },
197
- defaultAgent: 'build',
198
-
199
- // Legacy single-agent shorthand. Mutually exclusive with `agents`.
200
- preset: myPreset, // wrapped in a one-entry registry
201
- })
121
+ interface ChatOptions {
122
+ providers: ProviderRegistry // required — at least one configured provider
123
+ store: SessionStore // required — persistence backend
124
+ agents?: Record<string, AgentProfile> // defaults to BUILTIN_AGENTS
125
+ paths?: Partial<ResolvedPaths> // defaults from resolveStoragePaths()
126
+ autoUpdate?: AutoUpdateConfig
127
+ }
128
+
129
+ const config: ResolvedConfig = resolveConfig(opts)
202
130
  ```
203
131
 
204
- `resolveConfig` applies API keys into `process.env` and writes `ZIDANE_CREDENTIALS_PATH` before any factory runs — wizards on fresh installs render without throwing "no API key" errors.
132
+ `resolveConfig` never throws on a misconfigured provider — it's dropped from `availableProviders` and surfaced in `config.errors` so the host can render a banner instead of crashing boot.
205
133
 
206
- ### Env vars
134
+ ## Environment variables
207
135
 
208
- | Var | Effect |
136
+ | Variable | Effect |
209
137
  |---|---|
210
- | `ZIDANE_PREFIX` | Overrides `options.prefix`. |
211
- | `ZIDANE_STORAGE_DIR` | Pin all four storage slots to a single dir (legacy single-dir layout). When unset, the XDG resolver kicks in on Linux (or any platform with an `XDG_*_HOME` set / per-slot `ZIDANE_*_DIR` set). |
212
- | `ZIDANE_CONFIG_DIR` | Override the XDG config slot (default `$XDG_CONFIG_HOME/zidane`). Ignored when `ZIDANE_STORAGE_DIR` is set. |
213
- | `ZIDANE_DATA_DIR` | Override the XDG data slot (default `$XDG_DATA_HOME/zidane`). |
214
- | `ZIDANE_STATE_DIR` | Override the XDG state slot (default `$XDG_STATE_HOME/zidane`). |
215
- | `ZIDANE_CACHE_DIR` | Override the XDG cache slot (default `$XDG_CACHE_HOME/zidane`). |
216
- | `ZIDANE_PROJECT_DB` | `'0' / 'false' / 'off' / 'no'` → off; `'1' / 'true' / 'on' / 'yes'` → on. Sits between `options.projectDb` and the user-config file. |
217
- | `ZIDANE_CREDENTIALS_PATH` | Set by `resolveConfig` itself; the harness providers read it. |
218
- | `ZIDANE_DEBUG` | Enables stderr logging for swallowed errors (teardown, save, MCP bootstrap). |
219
- | `LOCAL_LLM_BASE_URL` | Local provider endpoint, e.g. `http://localhost:11434/v1` (Ollama). Required for `local`. Mirrored from the wizard's `baseURL` field. See **Local provider**. |
220
- | `LOCAL_LLM_API_KEY` | Optional bearer key for the local endpoint (most local servers are unauthenticated; defaults to a placeholder). |
221
- | `LOCAL_LLM_DEFAULT_MODEL` | Default model id the local server exposes (e.g. `llama3.1:8b`). Surfaced as the `local` provider's single picker entry. |
222
- | `LOCAL_LLM_CONTEXT_WINDOW` | Override the assumed context window for the local model (positive integer). Defaults to `8192`; invalid values fall back to the default. |
223
-
224
- ### Local provider
225
-
226
- `localDescriptor` (key `local`, label *Local LLM*) points zidane at any
227
- self-hosted OpenAI-compatible server — Ollama, vLLM, LM Studio, Lemonade, llama.cpp's server,
228
- text-generation-webui, etc. It's a thin wrapper over the shared `openaiCompat` provider, so it
229
- speaks the standard `POST /chat/completions` + SSE dialect.
230
-
231
- It has **no `envKey` and no fixed model catalogue** — both the endpoint and the model come from
232
- the user. Configuration goes through the wizard's `customFields` (persisted in the apikey
233
- credential and mirrored into env vars by `applyApiKeyEnv` at launch):
234
-
235
- | Field | Env var | Required | Notes |
236
- |---|---|---|---|
237
- | Base URL | `LOCAL_LLM_BASE_URL` | yes | e.g. Ollama `http://localhost:11434/v1`, vLLM `http://localhost:8000/v1`, LM Studio `http://localhost:1234/v1`. |
238
- | API key | `LOCAL_LLM_API_KEY` | no | Only for servers started with auth (vLLM `--api-key`, gateways). Blank → placeholder. |
239
- | Default model | `LOCAL_LLM_DEFAULT_MODEL` | no | The model id your server exposes. When set, it's surfaced as the provider's single model-picker entry. |
240
-
241
- **Configure via the TUI:** open the auth screen → pick *Local LLM* → the wizard
242
- walks the three fields → credentials are saved; the provider then appears in the model picker once a
243
- default model is set. (From Settings → Authentication, the *add or re-configure* action jumps
244
- straight into this wizard.)
245
-
246
- **Configure via env (headless / hosts):** set `LOCAL_LLM_BASE_URL` (+ optionally the others) before
247
- launch — `detectAuth` treats the provider as available once all required fields resolve.
248
-
249
- **Context window** is unknown over the OpenAI-compat API, so it defaults to a conservative `8192`
250
- (footer indicator + auto-compaction lean on this — under-estimating is the safe direction). Raise it
251
- with `LOCAL_LLM_CONTEXT_WINDOW` for larger-context models. Vision / cache / reasoning are off by
252
- default (most OSS local deployments strict-validate the request schema); pass a custom descriptor
253
- calling `local({ capabilities })` to flip them.
254
-
255
- ### Project-DB precedence
256
-
257
- Project mode resolves through: `options.projectDb` > env var > `<configDir>/config.json` `projectDb` flag > default (off). It activates only when the resolution lands on `true` **and** a git repo is found above `cwd`; otherwise everything stays at the user-scoped slots.
138
+ | `ZIDANE_HOME` | Override the user storage dir (default `~/.zidane`). |
139
+ | `ZIDANE_NO_UPDATE` / `NO_UPDATE_NOTIFIER` | Disable the background update check. |
140
+ | `ZIDANE_NO_USAGE_CHECK` / `CI` | Disable subscription-usage probes (also read by `usageChecksOptedOut()`). |
141
+ | `ZIDANE_TRUST_PROJECT_EXTENSIONS` | Legacy fallback for `Settings.loadProjectExtensions`. |
142
+ | `ZIDANE_DEBUG` | Verbose stderr, including missing MCP env-placeholder names. |
143
+ | `ZIDANE_BOOT_PROFILE` | Emit `bootTick` timing marks to stderr. |
258
144
 
259
- ### Storage-slot layout
145
+ ## Local provider
260
146
 
261
- `zidane` writes to four logical slots. The XDG resolver (`resolveStorageDirs`) picks between four resolution modes:
147
+ Zero-config local models (Ollama, LM Studio, etc.) are discovered by probing well-known local ports at startup; a reachable server is added to `availableProviders` as `local` with whatever models it reports, no credentials file needed. Discovery is best-effort and silent — an unreachable port is just absent from the list, not an error.
262
148
 
263
- | Mode | Trigger | Layout |
264
- |---|---|---|
265
- | `legacy-explicit` | `options.storageDir` set OR `ZIDANE_STORAGE_DIR` set | All four slots collapse to `<storageDir>/<prefix>`. |
266
- | `legacy-existing` | `~/<prefix>/` exists on disk | All four slots collapse to `~/<prefix>/`. Existing users keep their setup unchanged. |
267
- | `xdg` | Linux, OR any `XDG_*_HOME` / `ZIDANE_*_DIR` set | Slots fan out per spec (see below). |
268
- | `default` | Otherwise (mac/windows fresh) | All four slots collapse to `~/<prefix>/`. |
269
-
270
- In XDG mode the four slots map to:
271
-
272
- | Slot | Default | Holds |
273
- |---|---|---|
274
- | `configDir` | `$XDG_CONFIG_HOME/zidane` (`~/.config/zidane`) | `credentials.json`, `mcps.json`, `mcp-credentials.json`, `keybindings.json`, `config.json`, `projects.json` (safelist), `skills/`. |
275
- | `dataDir` | `$XDG_DATA_HOME/zidane` (`~/.local/share/zidane`) | `sessions.db`. |
276
- | `stateDir` | `$XDG_STATE_HOME/zidane` (`~/.local/state/zidane`) | `state.json`. |
277
- | `cacheDir` | `$XDG_CACHE_HOME/zidane` (`~/.cache/zidane`) | Persisted tool-result blobs, background-task logs, auto-update registry cache. |
278
-
279
- ### `ResolvedPaths`
149
+ ## Storage-slot layout
280
150
 
281
151
  ```ts
282
- interface ResolvedPaths {
283
- dir: string // effective root (configDir or projectDir)
284
- userDir: string // legacy alias for `configDir` — credentials + safelist live here
285
- projectDir: string | null // `<git-root>/<prefix>` when project mode is on
286
- db: string // `<dataDir>/sessions.db` (or projectDir/sessions.db in project mode)
287
- state: string // `<stateDir>/state.json` (or projectDir/state.json in project mode)
288
- configDir: string // see Storage-slot layout
289
- dataDir: string
290
- stateDir: string
291
- cacheDir: string
292
- storageMode: 'legacy-explicit' | 'legacy-existing' | 'xdg' | 'default'
293
- }
152
+ resolveStorageDirs({ prefix: 'zidane', cwd, home }): StorageDirs
153
+ // → { userDir, projectDir, mode: 'xdg' | 'legacy' }
294
154
  ```
295
155
 
296
- ### `ResolvedConfig`
297
-
298
- ```ts
299
- interface ResolvedConfig {
300
- prefix: string
301
- storageDir: string
302
- paths: ResolvedPaths
303
- providers: ProviderRegistry
304
- agents: AgentRegistry
305
- initialAgentId: string
306
- store: SessionStore
307
- stateStore: StateStoreApi
308
- modelsFor: (key: ProviderKey) => readonly ModelInfo[]
309
- initialState: TuiState
310
- initialSettings: Partial<Settings>
311
- resumeProvider: ProviderAuth | null
312
- initialPicked: Picked | null
313
- keybindings: KeyBindings
314
- }
315
- ```
316
-
317
- ### Persisted UI state (`state.json`)
318
-
319
- ```ts
320
- interface TuiState {
321
- lastProvider?: ProviderKey
322
- lastSessionId?: string
323
- lastModelByProvider?: Partial<Record<ProviderKey, string>>
324
- lastModelChain?: ModelChainEntry[] // entry 0 = primary, 1+ = fallbacks; restored only when entry 0 matches the resolved primary
325
- lastEffortByModel?: Record<string, ThinkingLevel>
326
- lastAgent?: string
327
- settings?: Partial<Settings>
328
- }
329
- ```
330
-
331
- `createStateStore(path)` returns `{ load, save }`; atomic write via the shared `writeFileAtomic` helper (`tmp + rename`, sibling temp suffix folds in `process.pid + Date.now()` so two writers in the same process can't collide) so a crash mid-write never leaves a half file. Corrupt state files are treated as absent so the user can still reach the auth screen. The same helper backs every persisted JSON in the chat layer — state, safelist (`projects.json`), credentials (`credentials.json`, mode `0o600`), MCP credentials (`mcp-credentials.json`, mode `0o600`), `keybindings.json`.
156
+ - **`userDir`** — `$XDG_DATA_HOME/zidane` when set, else `~/.zidane` (legacy, still the common case).
157
+ - **`projectDir`** — `<project root>/.zidane`, used for session exports and project-scoped skills/MCP config.
158
+ - Every path in `ResolvedPaths` derives from these two roots; hosts overriding `ChatOptions.paths` should override both together to keep sessions and credentials colocated.
332
159
 
333
160
  ## Agents
334
161
 
335
- A profile bundles a `Preset` with display metadata so a GUI can render a picker, persist the choice, and rebuild the agent on switch. Profiles are self-contained — the host's chat-level `preset` (if any) is NOT merged underneath.
336
-
337
- ```ts
338
- interface AgentProfile {
339
- id: string // persisted in `TuiState.lastAgent`
340
- label: string // displayed in the picker + status bar
341
- description: string // one-line picker hint
342
- preset: Preset // applied to `createAgent()`
343
- accent?: AgentAccent // 'brand' | 'accent' | 'warn' | 'model'
344
- }
345
- ```
346
-
347
- Built-ins (`BUILTIN_AGENTS`):
348
-
349
- - **`BUILD_AGENT`** (`build`) — full tool access: `shell`, `readFile`, `writeFile`, `listFiles`, `edit`, `multiEdit`, `glob`, `grep`, `webSearch`, `fetchUrl`, plus `spawn` (with `persist: true` so reloads can reconstruct the subagent tree) and `todowrite` / `todoread` layered in via `composePresets(..., createTodoTools())` (see **Todos**).
350
- - **`PLAN_AGENT`** (`plan`) — read-only: `readFile`, `listFiles`, `glob`, `grep`, `webSearch`, `fetchUrl`. System prompt is `PLAN_MODE_DOCTRINE`. No todos — a read-only exploration session has nothing to checkpoint.
351
-
352
- Both share `SHARED_BEHAVIOR`:
162
+ `BUILTIN_AGENTS = { build: BUILD_AGENT, plan: PLAN_AGENT }`. Both share one `AgentBehavior` baseline, `SHARED_BEHAVIOR`, layered with reliability defaults the SDK core leaves off:
353
163
 
354
164
  ```ts
355
- {
165
+ const SHARED_BEHAVIOR: AgentBehavior = {
356
166
  // context / token discipline
357
167
  compactStrategy: 'tail',
358
168
  compactThreshold: 128 * 1024,
359
- compactKeepTurns: 8,
360
- elideStaleReads: 'notice', // append-only staleness reminders — never rewrites
361
- // cached prefix bytes ('stub' re-bills the
362
- // conversation per newly-stale read)
363
- mediaKeepTurns: 8, // matched to compactKeepTurns so both frontiers
364
- // jump on the same chunk boundary — one prompt-
365
- // cache invalidation per window, not two
169
+ compactKeepTurns: 8, // floor, not a cap — see below
170
+ elideStaleReads: 'notice', // append-only staleness note; never rewrites
171
+ // cached prefix bytes ('stub' re-bills the
172
+ // conversation per newly-stale read)
173
+ mediaKeepTurns: 8, // matched to compactKeepTurns: one prompt-
174
+ // cache invalidation per window, not two
366
175
  toolDisclosure: 'lazy',
367
176
  toolOutputBudget: 64 * 1024,
368
177
  toolOutputBudgetExcludeTools: DEFAULT_BUDGET_EXCLUDE_TOOLS,
369
178
  persistThreshold: 8 * 1024,
370
179
  persistExcludeTools: DEFAULT_PERSIST_EXCLUDE_TOOLS,
371
180
 
181
+ // agent-build-time fallback; a chat host normally overrides this per run
182
+ // via `chatAutoCompactBehavior` (see Auto-compaction) — this only fires
183
+ // for SDK-direct consumers of BUILD_AGENT/PLAN_AGENT that never do.
184
+ autoCompact: { threshold: 0.85 },
185
+
372
186
  // loop prevention (ON in the chat profiles; OFF in the SDK core)
373
- requireReadBeforeEdit: true, // reject edit/multi_edit on a file not read this
374
- // session; write_file seeds read-state so the
375
- // write→edit flow still works
376
- dedupTools: { // replay-from-cache on identical discovery calls
377
- glob: discoveryDedupHash,
378
- grep: discoveryDedupHash,
379
- list_files: discoveryDedupHash,
380
- },
187
+ requireReadBeforeEdit: true,
188
+ dedupTools: { mode: 'chain', excludeTools: [...] },
381
189
  repeatGuard: { mode: 'both', windowSize: 40 },
382
- // chain AND sliding window: shell blocks @4 /
383
- // aborts @8; read_file blocks @6 / aborts @12
384
- thinkingDecay: { afterTurn: 10, floor: 1024 }, // step form: ONE budget change
385
- // per run = one cache invalidation (the budget
386
- // is part of Anthropic's cache key); no-op
387
- // unless a thinking budget is set
190
+ thinkingDecay: { afterTurn: 10, floor: 1024 },
388
191
  }
389
- ```
390
192
 
391
- `DEFAULT_PERSIST_EXCLUDE_TOOLS` = `['read_file', 'tool_search', 'skills_use', 'skills_read', 'present_plan', 'ask_user', 'spawn', 'todowrite', 'todoread']`. SDK consumers building custom profiles can extend this for any host-defined tool whose output should stay inline regardless of size. `DEFAULT_BUDGET_EXCLUDE_TOOLS` = `['tool_search', 'skills_use', 'skills_read']` (context-loading tools exempt from the per-turn output budget).
392
-
393
- The loop-prevention knobs are the one place the chat profiles diverge from the SDK core's conservative defaults. `requireReadBeforeEdit` is the only one that can *reject* a previously-succeeding call — but the refusal is a recoverable `tool_result` (`Edit error: <path> has not been read in this session. Call read_file first …`), not a hard error, so a model self-corrects in one turn. Content drift after a read is anchor-tolerant (the edit proceeds with a note when `old_string` still matches the current bytes verbatim; refusal covers anchors the drift broke and fuzzy-only matches, escalating on repeat to name the likely external writer) so churny workflows — a formatter or codegen touching files between edits — don't turn the guard into a standing "re-read everything" tax that pushes models toward shell-based edits. `repeatGuard` blocks the 4th identical `shell` call (6th for `read_file`) and aborts at the 8th (12th for `read_file`) via the agent's `AbortController`, preceded by a `repeat-guard:exceeded` event. The SDK core default is strict chain mode; **the chat profiles run `mode: 'both'` with a 40-call window**, because a chain resets on any intervening call and a spinning model always emits one. A real run burned 123 turns and 10M input tokens re-issuing one byte-identical `read_file` 59 times, alternating it with `grep`s on the same path: the longest consecutive streak was 5 against a threshold of 6, so nothing fired and the user had to cancel. The interleaved calls were `dedupTools` cache replays, which are free for the model and (by design) reset the chain, leaving the guard structurally unreachable. The window counts a payload among that tool's own recent calls, so dilution slows the trip instead of erasing it; the same run aborts at call 28 of 123. Consumers can still pick `mode: 'chain' | 'window'`, a custom `steeringMessage`, or `thresholdAction: 'steer'` to append guidance to the tool result instead of blocking at the soft threshold. `dedupTools` replay and `thinkingDecay` are free / inert until triggered.
394
-
395
- Other reliability levers stay off unless a profile opts in: `writeFileExistingPolicy: 'block-with-edit-recipe'` converts existing-file `write_file` rewrites into a recoverable `tool:gate` refusal; `readFileDefaults` sets omitted `read_file` slice defaults without changing explicit calls; `thinkingOverrunPolicy` queues a non-interrupting next-turn nudge when streamed thinking crosses a threshold; `textToolCallRescue` steers after a model writes JSON/XML/Liquid-style tool-call markup as plain text; `unknownToolRecovery` queues closest currently-advertised tool-name suggestions after an unknown native tool call; `progressNudges` emits bounded pivot nudges for repeated read-only turns, failed/no-op mutations, validation rejects, or tool errors; `maxTurnsWarningMessage` customizes the built-in turn-cap warning.
193
+ const DEFAULT_PERSIST_EXCLUDE_TOOLS = [
194
+ 'read_file', 'tool_search', 'skills_use', 'skills_read', 'read_chat_turns',
195
+ 'present_plan', 'ask_user', 'spawn', 'todowrite', 'todoread',
196
+ ]
197
+ const DEFAULT_BUDGET_EXCLUDE_TOOLS = ['tool_search', 'skills_use', 'skills_read', 'read_chat_turns']
198
+ ```
396
199
 
397
- `resolveAgentId(registry, requestedId, defaultId)` picks the next active profile with fallbacks: requested id > defaultId > first registry key > `null`. `singleAgentRegistry(preset)` wraps a legacy `preset` into a one-entry registry — used internally when the host passed `preset` without `agents`.
200
+ - **`repeatGuard: { mode: 'both', windowSize: 40 }`** — `'chain'` mode (consecutive identical calls) is trivially defeated by any intervening call, including a `dedupTools` cache replay; `'both'` also catches the same call recurring within a 40-call window regardless of what ran in between.
201
+ - **`compactKeepTurns` is a floor.** The loop never compacts below the last 8 turns even when the threshold triggers early on a token-heavy session, so a single giant tool result can't strand the model with only a summary and no live turns.
202
+ - **Tool-budget exclusions** cover tools whose output is either already bounded (`read_file`, `read_chat_turns`) or is the harness's own control-plane text (`tool_search`, `skills_use`/`skills_read`, `present_plan`/`ask_user`, `spawn`, `todowrite`/`todoread`) — truncating them would corrupt the contract they carry rather than save tokens.
203
+ - **Other reliability levers** hosts can opt into via `AgentBehavior` (off by default, not part of `SHARED_BEHAVIOR`): `writeFileExistingPolicy`, `readFileDefaults`, `thinkingOverrunPolicy`, `textToolCallRescue`, `unknownToolRecovery`, `progressNudges`, `maxTurnsWarningMessage`.
398
204
 
399
- `accentColor(profile.accent, COLOR)` resolves an `AgentAccent` token (`'brand' | 'accent' | 'warn' | 'model'`, defaulting to `'accent'`) to a hex string from the four-role color subset the caller passes. Renderer-agnostic — the TUI's footer + picker call it with `useColors()`, a GUI can hand it a CSS-variable lookup with the same shape.
205
+ `resolveAgentId(id, agents)` falls back to `DEFAULT_AGENT_ID` (`'build'`) for an unknown id instead of throwing — a stale `state.json` `lastAgent` pointing at a removed custom profile degrades to the default rather than blocking boot.
400
206
 
401
207
  ## System prompt
402
208
 
403
- Renderer-agnostic fragments designed to be byte-stable across sessions so the Anthropic ephemeral cache breakpoint keeps hitting. Every constant in `agent-prompt` is a self-contained section; the two composers (`buildBuildSystem`, `buildPlanSystem`) assemble them in cache-friendly order around the `SYSTEM_PROMPT_BOUNDARY` marker:
209
+ `buildBuildSystem` / `buildPlanSystem` (`chat/agent-prompt`) compose the two built-in profiles' prompts from named fragments. Plan is a strict subset of Build — no shell/spawn doctrine, read-only token discipline, and its own interaction guidance:
404
210
 
405
211
  ```ts
406
- buildBuildSystem({ cwd: projectDir, allowInteraction: true })
407
- // STATIC (cached):
408
- // IDENTITY_PREFIX
409
- // + DOING_TASKS_DOCTRINE + CONVENTIONS_DOCTRINE + ACTIONS_WITH_CARE_DOCTRINE
410
- // + GIT_DOCTRINE + TOKEN_DISCIPLINE_DOCTRINE + SUBAGENT_GUIDANCE + COMMUNICATION_DOCTRINE
411
- // + (allowInteraction ? INTERACTION_GUIDANCE : INTERACTION_GUIDANCE_NO_PROMPTS)
412
- // + opts.userInstructions
413
- // ── __ZIDANE_SYSTEM_PROMPT_BOUNDARY__ ──
414
- // DYNAMIC (not cached):
415
- // envSection({ cwd, date, platform })
416
-
417
- buildPlanSystem({ cwd: projectDir, allowInteraction: true })
418
- // STATIC (cached):
419
- // IDENTITY_PREFIX
420
- // + (allowInteraction ? PLAN_MODE_DOCTRINE : PLAN_MODE_DOCTRINE_NO_PROMPTS)
421
- // + TOKEN_DISCIPLINE_DOCTRINE_READ_ONLY + COMMUNICATION_DOCTRINE
422
- // + (allowInteraction ? INTERACTION_GUIDANCE : INTERACTION_GUIDANCE_NO_PROMPTS)
423
- // + opts.userInstructions
424
- // ── __ZIDANE_SYSTEM_PROMPT_BOUNDARY__ ──
425
- // DYNAMIC (not cached):
426
- // envSection(...)
427
- ```
212
+ // buildBuildSystem, systemOverride unset:
213
+ [IDENTITY_PREFIX, DOING_TASKS_DOCTRINE, CONVENTIONS_DOCTRINE, ACTIONS_WITH_CARE_DOCTRINE,
214
+ GIT_DOCTRINE, TOKEN_DISCIPLINE_DOCTRINE, SUBAGENT_GUIDANCE, COMMUNICATION_DOCTRINE,
215
+ allowInteraction === false ? INTERACTION_GUIDANCE_NO_PROMPTS : INTERACTION_GUIDANCE]
428
216
 
429
- The provider attaches `cache_control` to the static prefix only; per-turn `<env>` rewrites in the TUI re-bill the dynamic suffix but don't invalidate the cached doctrine above. When `cwd` is omitted (no `envSection` to render), the marker is dropped automatically — single-block cache, identical wire bytes to before. See `docs/ARCHITECTURE.md` § Prompt caching > System-prompt boundary for the underlying mechanism.
430
-
431
- Pass `allowInteraction: false` (mirror of `Settings.interactionMode === 'disabled'`) to swap in the no-prompts variants — the host should also drop `ask_user` / `present_plan` from the tool set at agent build time so the model can't call tools that aren't reflected in the prompt. The other two modes (`ask`, `skip`) both keep the tools and the interaction guidance; they differ only in who answers, which is a host-side policy (`shouldSkipInteractions`) rather than a prompt variant.
432
-
433
- **Per-turn env rewrite.** The TUI installs a `system:transform` hook that calls `replaceDynamicSection(ctx.system, envSection(...))` each turn — refreshes the cwd / project root / date below the boundary while leaving the cached static prefix byte-identical:
434
-
435
- ```ts
436
- import { replaceDynamicSection } from 'zidane'
437
-
438
- agent.hooks.hook('system:transform', (ctx) => {
439
- ctx.system = replaceDynamicSection(ctx.system, envSection({ cwd, projectRoot, allowInteraction }))
440
- })
217
+ // buildPlanSystem, systemOverride unset:
218
+ [IDENTITY_PREFIX,
219
+ noPrompts ? PLAN_MODE_DOCTRINE_NO_PROMPTS : PLAN_MODE_DOCTRINE,
220
+ TOKEN_DISCIPLINE_DOCTRINE_READ_ONLY, COMMUNICATION_DOCTRINE,
221
+ noPrompts ? INTERACTION_GUIDANCE_NO_PROMPTS : INTERACTION_GUIDANCE_PLAN]
441
222
  ```
442
223
 
443
- SDK consumers calling `createAgent({ ...BUILD_AGENT.preset })` directly get the cwd-less, marker-free variant and can wrap their own env section via `appendDynamicSection` / `replaceDynamicSection`.
444
-
445
- Custom profiles: import the doctrine fragments and spread them in any order — `buildBuildSystem` is just one composition. Use `joinSystemPrompt(static, dynamic)` to opt into the boundary in your own composer, or skip the marker entirely for whole-prompt caching.
224
+ Both join `[...doctrine, userInstructions]` as the **static** half and `[envSection]` as the **dynamic** half via `joinSystemPrompt(static, dynamic)` — the cache-boundary marker that lets a provider cache the doctrine block while `<env>` (cwd, platform, date) changes every turn. Use `joinSystemPrompt` directly to opt into the same boundary in a custom composer. `BuildSystemOptions.systemOverride` replaces the whole `doctrine` array (see **`SYSTEM.md`** below); `userInstructions` and `envSection` still compose around it either way.
446
225
 
447
226
  ## Settings
448
227
 
449
- `SettingsProvider` holds a `Settings` value and persists changes via `onChange` (called from `useEffect([settings])` to avoid double-firing under React 18 strict-mode dev).
450
-
451
- ```ts
452
- interface Settings {
453
- showThinking: boolean
454
- /**
455
- * Tool call rendering mode — `'hidden' | 'formatted' | 'full'`.
456
- * Default: `'formatted'`. Legacy `state.json` files migrate on first load.
457
- */
458
- toolCallDisplay: ToolCallDisplay
459
- showToolResults: boolean
460
- /** Show every line of `!` shell output instead of the 10-line tail (display-only; the persisted result keeps its 32 KiB trim). Default: off. */
461
- showFullShellOutput: boolean
462
- /** Route every tool call through the approval gate. Default: on. */
463
- safeMode: boolean
464
- /** Retain `ask_user` + `present_plan` tools and the matching prompt sections. Off strips both at session activation and swaps in the no-prompts doctrine variants. Default: on. */
465
- allowInteraction: boolean
466
- /** Collapse subagent runs to start/end markers. Default: on. */
467
- hideSubagentOutput: boolean
468
- /** Active theme id — `resolveTheme` falls back to DEFAULT on unknown ids. */
469
- theme: string
470
- /** Cross-project sessions list. Default: off. */
471
- showAllProjects: boolean
472
- /** Auto-resume the last session on launch (snapshotted at mount). Default: on. */
473
- resumeLastSession: boolean
474
- /** Persist >8 KiB tool outputs to disk as <persisted-output> stubs. Default: on. */
475
- persistToolResults: boolean
476
- /** Fire compaction when latest turn's input usage crosses `threshold × effectiveWindow`. Default: on. */
477
- autoCompact: boolean
478
- /** Fraction in (0, 1). Settings modal cycles 0.6 / 0.7 / 0.8 / 0.9. Default: 0.8. */
479
- autoCompactThreshold: number
480
- /** Render edit/multi_edit/write_file as unified diffs. Default: on. */
481
- showEditDiffs: boolean
482
- /** Renderer target fps. Settings modal cycles 30 / 60 / 120; `clampFps` guards bounds. Default: 60. */
483
- targetFps: number
484
- /** Drip-feed streamed text at a smooth cadence (typewriter) instead of in stream bursts. Default: on. */
485
- smoothStreaming: boolean
486
- /** Inline gradient throbber at the transcript tail while a run is streaming. Default: off. */
487
- showThrobber: boolean
488
- /** Skills allowlist. `undefined` = every discovered skill; `[]` = off. */
489
- enabledSkills?: readonly string[]
490
- /** MCPs allowlist. Same semantics. */
491
- enabledMcps?: readonly string[]
492
- }
228
+ `Settings` (`chat/types`) is a flat, JSON-serializable record — one interface, persisted whole in `state.json`. Selected fields:
493
229
 
494
- type ToolCallDisplay = 'hidden' | 'formatted' | 'full'
495
- ```
496
-
497
- `useSettings()` returns `{ settings, toggle, setSetting }`. `toggle` is restricted at the type level to `BooleanSettingKey` (`[K in keyof Settings]-?: Settings[K] extends boolean ? K : never`); `setSetting` is the general writer.
498
-
499
- Four tables drive any settings UI (TUI tabs today, GUI panels tomorrow) — they're the source of truth so new settings land automatically:
500
-
501
- - `SETTINGS_CATEGORIES: SettingsCategoryDescriptor[]` — display order + labels for the high-level buckets (`agent`, `ui`). Each toggle / choice / input declares which bucket it belongs to via its `category` field.
502
- - `SETTINGS_TOGGLES: SettingsToggle[]` — boolean rows. `key` is the narrowed `BooleanSettingKey`; `category: SettingsCategory` routes the row to the right tab.
503
- - `SETTINGS_CHOICES: SettingsChoice[]` — value-from-options rows. `category` field on every entry. Currently spans both buckets: `autoCompactThreshold`, `userInstructionsScope` (agent); `toolCallDisplay`, `editDiffDisplay`, `theme`, `uiMode`, `targetFps`, `ctrlCBehavior`, `terminalTitle` (ui). `terminalTitle: 'icon-session'` pairs with the `sessionIcon` input to render the tab title as `{icon} · {session}`.
504
- - `SETTINGS_INPUTS: SettingsInput[]` — free-text rows edited via a single-line input (`key` is the narrowed `StringSettingKey`). Currently `sessionIcon` and `attentionIcon` (ui) — the latter swaps in while the agent waits on user input (tool approvals, question forms) — both shown only while `terminalTitle` is `'icon-session'`. The modal renders the value (or `placeholder` when empty) and opens a footer editor on `enter`.
230
+ | Field | Type | Default | Note |
231
+ |---|---|---|---|
232
+ | `showThinking` | `boolean` | `true` | Render `'thinking'` events. |
233
+ | `toolCallDisplay` | `'hidden'\|'formatted'\|'full'` | `'formatted'` | See **Tool call display**. |
234
+ | `showToolResults` | `boolean` | `true` | |
235
+ | `groupToolCalls` | `boolean` | `false` | Collapse one exchange's tool calls into a summary row. |
236
+ | `showFullShellOutput` | `boolean` | `false` | Display-only; persisted shell output stays trimmed either way. |
237
+ | `showRunReceipt` | `boolean` | `true` | End-of-run summary line. |
238
+ | `safeMode` | `boolean` | `true` | See **Safe mode**. |
239
+ | `useDaemon` | `boolean` | `false` | Route through the shared daemon; see `docs/IPC.md`. |
240
+ | `orchestration` | `boolean` | `false` | Requires `useDaemon`; enforced by `enforceSettingsInvariants`. |
241
+ | `loadProjectExtensions` | `boolean` | `false` | Gate for `<project>/.zidane/extensions` (arbitrary local code). |
242
+ | `importForeignConfigs` | `boolean` | `false` | Import foreign stdio MCP entries. |
243
+ | `importRulerConfigs` | `boolean` | `true` | `.ruler/` is treated as an intentional committed config source. |
244
+ | `hideSubagentOutput` | `boolean` | `true` | |
245
+ | `subagentModels` | `boolean` | `true` | Gives `spawn` a `model` field when the provider has a catalog. |
246
+ | `theme` | `string` | `DEFAULT_THEME.id` | See **Theme**. |
247
+ | `showAllProjects` | `boolean` | `false` | Sessions list scope. |
248
+ | `resumeLastSession` | `boolean` | `true` | |
249
+ | `persistToolResults` | `boolean` | `true` | See **Tool-result persistence**. |
250
+ | `autoCompact` | `boolean` | `true` | See **Auto-compaction**. |
251
+ | `autoCompactThreshold` | `number` | `0.8` | Cycles 0.6/0.7/0.8/0.9 in the settings modal. |
252
+ | `resumeOnMaxTokens`, `maxOutputTokens`, `maxTurns`, `retryMaxAttempts`, `maxCostUsd`, `maxTotalTokens`, `maxWallMinutes` | mixed | off/`0` | Runaway ceilings; all off by default (model-driven, uncapped). |
253
+ | `showEditDiffs` | `boolean` | `true` | See **Edit-diff rendering**. |
254
+ | `editDiffDisplay` | `'full'\|'compact'` | `'full'` | Approval modal always shows the full diff regardless. |
255
+ | `showCompactionSummary` | `boolean` | `false` | Render the LLM summary text under the compaction card, not just metadata. |
256
+ | `compactionModel` | `string` | `''` | `''` = summarize on the run's main model. |
257
+ | `targetFps` | `number` | `60` | Clamped to `[1, 240]` by `clampFps`; modal cycles 30/60/120. |
258
+ | `mouseInteractions` | `boolean` | `true` | Off releases the mouse to the terminal emulator. |
259
+ | `interactionMode` | `'ask'\|'skip'\|'disabled'` | `'ask'` | Replaced the old boolean `Settings.allowInteraction`. `'disabled'` strips `present_plan`/`ask_user` on next session activation. Distinct from `BuildSystemOptions.allowInteraction` (a boolean the host derives from this for the prompt composer). |
260
+ | `smoothStreaming` | `boolean` | `true` | |
261
+ | `fileLogging` | `boolean` | `false` | The in-TUI Logs panel works regardless (in-memory). |
262
+ | `showTodoIndicator`, `showUsageIndicator`, `showThrobber` | `boolean` | `true`, `true`, `false` | See **Todos**, **Subscription usage**. |
263
+ | `checkForUpdates` | `boolean` | `true` | |
264
+ | `subscriptionUsage` | `boolean` | `true` | Gates *automatic* usage probes; see **Subscription usage**. |
265
+ | `uiMode` | `'full'\|'minimal'` | `'full'` | |
266
+ | `userInstructionsScope` | `'both'\|'user'\|'project'\|'none'` | `'both'` | Also governs `SYSTEM.md` discovery. |
267
+ | `ctrlCBehavior` | `'quit'\|…` | `'quit'` | |
268
+ | `terminalTitle` | `'off'\|'app'\|'session'\|'icon-session'` | `'off'` | Plus `sessionIcon`/`attentionIcon`/`activeIcon` string overrides (empty = ⚽/⚠️/⚡). |
269
+ | `attachmentsEnabled` | `boolean` | `true` | Paste-as-attachment pipeline. |
270
+ | `externalEditor` | `boolean` | `true` | Inert until the key is pressed; degrades to a notice with no `$EDITOR`. |
271
+ | `inlineAutocomplete` | `boolean` | `false` | |
272
+ | `enabledSkills`, `enabledMcps` | `string[]\|undefined` | `undefined` | `undefined` = every discovered entry enabled; once written, a concrete allowlist. |
273
+ | `disabledMcpTools` | `Record<string, string[]>\|undefined` | `undefined` | Per-server deny-list — opposite polarity from the allowlists above. |
274
+
275
+ Settings-modal rows are data-driven off `SETTINGS_CATEGORIES` (`'agent' | 'ui'`), `SETTINGS_TOGGLES` (boolean rows), `SETTINGS_CHOICES` (enum/number rows), and `SETTINGS_INPUTS` (free-text rows) — adding a row to one of those tables is enough for every renderer built off them to pick it up with no other code change. `enforceSettingsInvariants(settings)` clears cross-field-inconsistent combinations (e.g. `orchestration` without `useDaemon`) before a write lands.
505
276
 
506
277
  ## Theme
507
278
 
508
- `Theme` is plain JSON: hex strings + booleans. Renderer agnostic.
509
-
510
- ```ts
511
- interface Theme {
512
- id: string
513
- label: string
514
- colors: ThemeColors // brand, accent, model, warn, error, dim, mute, border, borderActive
515
- select: ThemeSelect // backgroundColor, focusedBackgroundColor, …
516
- surfaces: ThemeSurfaces // modal, chips, selection, diff
517
- syntax: SyntaxStyles // Record<token, { fg?, bg?, bold?, italic?, underline?, dim? }>
518
- }
279
+ `BUILTIN_THEMES: Record<string, Theme>`, keyed by id, with `DEFAULT_THEME` as the fallback. A `Theme` is a flat palette (`brand`, `model`, `surfaces.*`, including `surfaces.diff` and `surfaces.chips`) plus metadata (`id`, `name`, `dark`).
519
280
 
520
- interface ThemeSurfaces {
521
- modal: string
522
- chips: ChipColorMap // `{ default: ChipColor, [providerId]?: ChipColor }`
523
- selection: string // bg for the select-turn highlight
524
- diff: DiffSurfaces // addBg/removeBg/addFg/removeFg + optional *ContentBg
525
- }
281
+ ```tsx
282
+ <ThemeProvider theme={BUILTIN_THEMES[settings.theme] ?? DEFAULT_THEME}>
283
+ <App />
284
+ </ThemeProvider>
526
285
 
527
- interface ChipColor { bg: string; fg: string }
528
- type ChipColorMap = { default: ChipColor } & Partial<Record<string, ChipColor>>
286
+ const { brand, model } = useColors() // re-renders on theme switch
529
287
  ```
530
288
 
531
- `resolveChipColor(chips, providerId)` performs the kind-specific → `chips.default` fallback. Use it instead of indexing the map directly — host-registered completion providers automatically inherit the default chip tone until the host extends the map.
532
-
533
- `SyntaxStyles` keys cover two surfaces — markdown structure captures (`markup.heading`, `markup.bold`, `markup.italic`, `markup.raw.block`, `markup.quote`, …) and Tree-sitter language tokens (`keyword`, `string`, `function`, `type`, `comment`, …). One table drives both, so a code fence inside a markdown block shares the active theme.
534
-
535
- | Theme id | Label | Tonal target |
536
- |---|---|---|
537
- | `default` | Default | Blue-accent cascade on dark terminal |
538
- | `catppuccin-mocha` | Catppuccin Mocha | Mauve accent, darkest base |
539
- | `catppuccin-macchiato` | Catppuccin Macchiato | Softer dark |
540
- | `catppuccin-frappe` | Catppuccin Frappé | Warmer dark |
541
- | `catppuccin-latte` | Catppuccin Latte (light) | Light terminal only |
542
- | `vaporwave` | Vaporwave | Neon pink on near-black |
543
-
544
- `resolveTheme(id)` falls back to `DEFAULT_THEME` on unknown ids — a renamed theme in `state.json` never breaks rendering. Adding a theme is one entry in `BUILTIN_THEMES` (`src/chat/theme.ts`); the picker auto-populates from `Object.values(BUILTIN_THEMES)`.
545
-
546
- ## Safe-mode
547
-
548
- Per-tool approval gate with a per-project safelist. The agent emits a `tool:gate` hook; the chat layer's wired handler:
549
-
550
- 1. If `IMPLICITLY_SAFE_TOOLS` covers the call → allow. The list is `['read_file', 'list_files', 'glob', 'grep', 'ask_user', 'present_plan', 'todowrite', 'todoread', 'skills_use']` — pure reads, interaction prompts (the picker IS the gate), todo metadata, and skill activation (mutates a per-agent Map; no shell/disk/network — gating it would also break the model's recovery path when it deactivates a skill after an `AgentToolNotAllowedError`). `skills_read` (touches disk) and `skills_run_script` (executes scripts) are intentionally NOT on the list.
551
- 2. If the project safelist (`~/.zidane/projects.json`) matches → allow.
552
- 3. Else push an `ApprovalRequest` onto `SafeModeProvider`'s queue and await.
553
-
554
- ```ts
555
- type ApprovalDecision
556
- = | 'accept-once'
557
- | 'accept-session'
558
- | 'accept-safelist'
559
- | 'deny'
560
- | { kind: 'partial', mask: readonly boolean[] }
561
-
562
- type ApprovalOriginator
563
- = | { kind: 'parent' }
564
- | { kind: 'child', label: string } // label is the `child-N` tag
565
-
566
- type RequestApproval = (
567
- tool: string,
568
- input: Record<string, unknown>,
569
- originator?: ApprovalOriginator,
570
- ) => Promise<ApprovalDecision>
571
-
572
- interface ApprovalRequest {
573
- id: string
574
- tool: string
575
- input: Record<string, unknown>
576
- resolve: (decision: ApprovalDecision) => void
577
- /** Caller attribution. Absent ≡ `{ kind: 'parent' }`. */
578
- originator?: ApprovalOriginator
579
- }
580
- ```
289
+ Custom themes are plain `Theme` objects merged into the record the provider receives — no registration step beyond spreading `{ ...BUILTIN_THEMES, mine: myTheme }`.
581
290
 
582
- `ApprovalDecision` is a discriminated union — exhaustive switches must handle the object form, emitted by the per-edit modal for file-edit tools (`edit` / `multi_edit` / `write_file`). The `mask` is 1:1 with `EditPayload.hunks`; `true` = apply, `false` = deny. Bridge it via `resolveApprovalForPayload` (see **Per-edit approval**); non-edit gates collapse `partial` to `allow`.
291
+ ## Safe mode
583
292
 
584
- `ApprovalOriginator` lets the modal show ` · child-N` attribution when a subagent's gate bubbles up through the parent's hook bus. The TUI's `applyGate` reads `ctx.childId` (set by `BUBBLED_MUTABLE_EVENTS` in `src/tools/spawn.ts`) and threads it through:
293
+ `Settings.safeMode` (default on) routes every tool call through an approval queue before execution, except a fixed allowlist:
585
294
 
586
295
  ```ts
587
- const originator: ApprovalOriginator = ctx.childId
588
- ? { kind: 'child', label: ctx.childId }
589
- : { kind: 'parent' }
590
- const decision = await requestApproval(name, input, originator)
591
- ```
592
-
593
- ```tsx
594
- const { requestApproval, resolveHead, denyAll } = useSafeModeActions()
595
- const queue = useSafeModeQueue()
596
- const pending = queue[0] ?? null
597
-
598
- // Renderer maps the pending request to a picker; calling `resolveHead(decision)`
599
- // pops the head with one of the five decision shapes.
600
- // `denyAll()` drains the entire queue with `deny` — used on session teardown.
296
+ const IMPLICITLY_SAFE_TOOLS = [
297
+ 'read_file', 'grep', 'glob', 'list_files', 'todowrite', 'todoread',
298
+ 'tool_search', 'skills_use', 'skills_read', 'web_search',
299
+ ]
601
300
  ```
602
301
 
603
- `accept-safelist` calls `addToSafelist(dataDir, projectDir, suggestSafelistEntries(tool, input))` so subsequent calls with the same shape skip the gate. For shell, `suggestSafelistEntries` returns one `shell:<head>:*` per distinct command head (parsed like `isOnSafelist` matches), so a compound command (`cd dir && cat file`) persists the whole set — otherwise the `every-head` check would re-prompt on the uncovered head. The safelist lives at `<dataDir>/projects.json` (user dir, never the project dir). `partial` never writes a safelist entry — the safelist key is the tool name + an arg shape, with no hunk-level scope.
604
-
605
- **Parallel-call deny semantics**: each pending approval resolves on its own. A single `deny` does **not** cascade through the queue — the gate handler sets `ctx.block = true` + `ctx.reason = 'User denied this tool call'` for THAT call and returns, so the model receives `Blocked: User denied this tool call` as that one tool's result and the turn continues; other parallel approvals stay queued and prompt independently. The user's explicit "stop everything" gesture is the host-level `esc abort run` shortcut, which calls `cancelRunOnDenial()` → `denyAll()` + `agent.abort()` in one shot.
302
+ - **`useSafeModeQueue()`** exposes a FIFO of `ApprovalRequest`s; a host renders only the **head** and resolves it with `resolveHead(decision)` — never resolve other entries directly, since the underlying promise resolvers are owned by the provider.
303
+ - **`ApprovalDecision`** is `'accept' | 'accept-always' | 'deny' | { kind: 'partial', mask }` — the last is edit-family-only (see **Per-edit approval**).
304
+ - **`accept-always`** adds the tool name to a per-session allowlist so repeats in the same session skip the queue; it does not persist across sessions.
305
+ - Wire it at `tool:gate`: implicitly-safe tools and allowlisted names fall through immediately; everything else awaits the queue before the call proceeds.
606
306
 
607
307
  ## Todos
608
308
 
609
- `todowrite` and `todoread` give the model a place to plan multi-step work and stream progress as it executes. Monolithic-replacement semantics — every `todowrite` overwrites the active list in full. State lives at `session.metadata.todos: TodosBag` and is split by run kind:
309
+ `createTodoTools(opts)` returns the `todowrite`/`todoread` tool pair plus the live list a UI renders.
610
310
 
611
311
  ```ts
612
- interface TodosBag {
613
- /** Top-level slot — shared across runs whose `parentRunId` is unset. */
614
- session?: TodoItem[]
615
- /** Per-subagent-run slots, keyed by the child run's id. */
616
- byRun?: Record<string, TodoItem[]>
312
+ interface CreateTodoToolsOptions {
313
+ onChange?: (todos: readonly TodoItem[]) => void
314
+ maxItems?: number // default 100
617
315
  }
618
316
  ```
619
317
 
620
- The keying rule, given a `runId`:
621
-
622
- - **Top-level run** (no `parentRunId`) → `bag.session`. All top-level runs in the same session share this slot, so a list written in one prompt **survives across the next prompt**. The user aborts a run mid-task, sends a follow-up, and `todoread` returns the same list automatically — the model picks up where it left off without a host-side carry-over hook.
623
- - **Subagent run** (`parentRunId` set) → `bag.byRun[runId]`. Each child has its own slot; parallel children stay isolated from each other and from the parent. Matches Claude Code's TodoWrite v1 keying (`agentId ?? sessionId`).
624
-
625
- **Auto-clear on completion**: when every item is `completed`, the slot is wiped to `[]` on write — prevents stale "all done" lists from shadowing the next prompt's context. The model's reply summarizes the close-out (`Marked N items complete — list cleared.`); only the persisted slot is reset. Same rationale as Claude Code's `TodoWriteTool.ts:70`.
626
-
627
- **Archive sidecar**: every non-empty write also stashes the list under `bag.archive` (mirroring the active slot's routing — `archive.session` for top-level, `archive.byRun[runId]` for subagents). Empty writes — whether from auto-clear or an explicit `setTodosForRun(session, runId, [])` — **preserve** the archive. UI surfaces (the TUI modal in particular) fall back to it when the live slot is empty so users can still glance back at "what was just completed" until the model writes a new list. The archive is invisible to `todoread` — strictly model-facing reads always go to the live slot.
628
-
629
- Public API (re-exported from `zidane/chat`):
630
-
631
- - **Factory** — `createTodoTools(options?)` returns a `Preset` carrying `{ tools, behavior }`. By default `behavior` holds a **run-scoped `block-after` dedup** for `todowrite` (`behavior.dedupTools.todowrite`, `scope: 'run'`) that hard-blocks consecutive *identical* payloads (the real loop breaker), plus an optional per-run write budget when `maxWritesPerRun > 0`. A separate **soft** in-body identical dedup (`dedupIdentical`) resolves against the active slot and returns "No change…". Compose with `composePresets` or spread it into your agent setup like any other preset.
632
- - **Identities** — `TODOWRITE_TOOL`, `TODOREAD_TOOL`, `isTodoTool(name)`.
633
- - **Metadata accessors** — `getTodosForRun(session, runId)`, `setTodosForRun(session, runId, items)`, `getArchivedTodosForRun(session, runId)`, `pruneTodosByRun(session)`, `TODOS_METADATA_KEY`, `TODO_WRITE_COUNTS_METADATA_KEY`. All route through the keying rule above — top-level reads/writes the session slot, subagent reads/writes its own. The archive accessor reads `bag.archive` with the same routing.
634
- - **UI selectors** (renderer-agnostic, used by the TUI's todo indicator + modal; reusable by any GUI shell):
635
- - `useActiveTodos(session) → ActiveTodosState` — React hook. Recomputes on every parent re-render; selector is O(runs + todos) so memoization is unnecessary (and was previously incorrect — `Session.runs` is mutated in place by `completeRun` / `abortRun`).
636
- - `selectActiveTodos(session) → ActiveTodosState` — pure selector, identical contract, no React.
637
- - `pickActiveRunId(session) → runId | null` — "the run UI surfaces should reflect right now" (latest-running, falling back to most-recently-appended).
638
- - `TODO_STATUS_GLYPHS: Record<TodoStatus, string>` — single source of truth for status icons across surfaces.
639
- - **Prompt anchor helper** — `createActiveTodoAnchorHooks(options?)` returns an opt-in `system:transform` hook that appends a compact live-todo block. It reads only the live `todos` list, never `archive`, so an auto-cleared completed plan is not reintroduced. `renderActiveTodoAnchor(todos, options?)` is the pure renderer behind it.
640
- - **Types** — `TodoItem`, `TodoStatus` (`'pending' | 'in_progress' | 'completed' | 'cancelled'`), `TodosBag` (`{ session?, byRun?, archive? }`), `TodoTally`, `ActiveTodosState` (carries both `todos` and `archive` so UI surfaces can render the close-out batch after auto-clear), `CreateTodoToolsOptions`.
641
-
642
- `CreateTodoToolsOptions`:
643
-
644
- | Option | Default | Notes |
645
- |---|---|---|
646
- | `maxItems` | `100` | Per-call item cap. Excess items are truncated by the recursive validator and reported in the tool_result. |
647
- | `maxIdenticalWrites` | `5` | **On by default.** Hard stop for *consecutive identical* payloads — plumbs a run-scoped `behavior.dedupTools.todowrite` in `block-after` mode: the gate replays the first `N-1` identical writes, then refuses the `N`th (and every subsequent identical one) with `Blocked: …`. Safe-by-default because it only ever fires on a byte-identical repeat of the already-tracked list, so a real multi-step plan (different list each step) never trips it. `scope: 'run'` keeps a parent and a subagent from sharing the counter. Set to `0` to opt out. |
648
- | `identicalBlockReason` | built-in | `(count) => string`. Custom refusal text for the `maxIdenticalWrites` block. |
649
- | `maxWritesPerRun` | `0` | Blanket per-run write cap on **all** writes (distinct ones too). **Off by default** — `todowrite` is a state-transition tool, capping every call punishes the legitimate "finish the N-item plan" path (which needs ≥ N+1 calls). Prefer `maxIdenticalWrites` (identical-only). Set to a positive `N` to opt in; the factory then plumbs `behavior.toolBudgets.todowrite = { max: N, onExceed }`. |
650
- | `onMaxWrites` | `'steer'` | `'steer'` or `'block'`. Only takes effect when `maxWritesPerRun > 0`. |
651
- | `remindAfter` | `3` | Append a "you've checkpointed N times — slow down" nudge to the tool_result once the cumulative count reaches this. Count is scoped the same way the data is (session-shared for top-level runs, per-run for subagents) so it reflects use of the *active* slot, not noisy per-prompt resets. Set to `0` to opt out. |
652
- | `reminderText` | built-in | `(count, items) => string \| undefined`. Return `undefined` / `''` to suppress for this call. |
653
- | `dedupIdentical` | `true` | The **soft** in-body short-circuit: when the incoming payload is identical to the current slot (session-shared for top-level, per-run for subagents), the body returns "No change…" instead of rewriting. Orthogonal to the **hard** gate block (`maxIdenticalWrites`) — this one is advisory and the model can ignore it. Counter still advances on no-op re-writes so the reminder catches spam. Set `false` to skip the check entirely. |
654
- | `writeDescription` / `readDescription` | built-in | Override the JSON-schema-level descriptions. |
655
-
656
- `BUILD_AGENT` opts in via `composePresets`; `PLAN_AGENT` doesn't (read-only mode has nothing to checkpoint). Hosts building custom profiles use the same primitive:
657
-
658
- ```ts
659
- import { composePresets, definePreset } from 'zidane/presets'
660
- import { createTodoTools } from 'zidane/chat'
661
-
662
- const myPreset = composePresets(
663
- definePreset({
664
- name: 'mine',
665
- tools: { ...basicTools },
666
- behavior: { ...SHARED_BEHAVIOR },
667
- }),
668
- createTodoTools({ remindAfter: 3 }),
669
- )
670
- ```
671
-
672
- `composePresets` deep-merges `behavior.dedupTools` and `behavior.toolBudgets` per tool name, so layering order is forgiving: a downstream preset overriding `toolBudgets.todowrite` keeps the rest of the factory's wiring intact, and a host wiring its own `dedupTools.todowrite` entry (for telemetry or alternate caching) survives composition. For the simplest case ("just add the tools to an existing config"), plain spread is also valid since the factory returns a `Preset`:
673
-
674
- ```ts
675
- createAgent({ ...basicPreset, ...createTodoTools(), provider })
676
- ```
677
-
678
- Spread is shallow-merge, so two presets that both touch `dedupTools` / `toolBudgets` should go through `composePresets` instead.
679
-
680
- Hygiene is layered across the lifecycle:
681
-
682
- - **Input time** — schema validation drops malformed items; per-call ID dedup keeps last-wins semantics; empty initial writes don't create a slot.
683
- - **Auto-clear** — when every item is `completed`, the live slot is wiped on write. Prevents the "all done" list from sitting in the model's context and shadowing the next prompt. The close-out list lands in `bag.archive` so the TUI modal can keep showing it until the model writes a fresh batch.
684
- - **Per-call reminder** — rides on the tool's own `tool_result`, so the model sees the nudge in the result it just received (no `system:transform` plumbing). Counter is scoped to the active slot (session-shared / per-run).
685
- - **Consecutive-identical hard block (on by default)** — `maxIdenticalWrites` plumbs a run-scoped `behavior.dedupTools.todowrite` in `block-after` mode. After N byte-identical writes in a row the gate refuses with `Blocked: …` (and `isError`), so the model can't spin on the same list forever. Run-scoped (`scope: 'run'`) so a parent and a subagent never share the counter — the precise conflation that kept `todowrite` off `behavior.dedupTools` historically, now fixed by the per-run dedup key. Only ever fires on identical payloads, so a legitimate multi-step plan is untouched.
686
- - **Per-run write budget (opt-in)** — when the host sets `maxWritesPerRun > 0`, plumbed through `behavior.toolBudgets.todowrite`; same gate, same `tool-budget:exceeded` event. Counts every dispatched call including identical re-writes. Off by default — prefer the identical-only block above.
687
- - **Identical-payload soft dedup** — handled inside the tool body, against the current slot. The slot resolution naturally splits top-level from subagent, so an identical re-write in the active scope short-circuits to "No change" and a structurally identical write in a different scope (different subagent) writes correctly. Advisory; the gate block above is the hard backstop.
688
- - **Session-level reconciliation** — `pruneTodosByRun(session)` drops orphan subagent slots after a `setRuns()` fork or any out-of-band run-list mutation. The top-level `session` slot is unaffected (it's session-scoped, not per-run, so there's no orphan to GC). Not automatic on `save()`.
689
- - **Subagent isolation** — falls out of `parentRunId`-keyed routing in `getTodosForRun` / `setTodosForRun`; each child run owns its own slot.
690
-
691
- Renderer integration: the `TOOL_DISPLAY` registry ships entries for both tools (`Todos N items · …` line shape that matches the tool's result summary), and `DEFAULT_PERSIST_EXCLUDE_TOOLS` skips them from disk-persistence (the latest snapshot is the only state of interest).
692
-
693
- ### UI surface — opt-in indicator + always-on modal
694
-
695
- The chat layer ships two renderer-agnostic primitives any host can build a UI on. The TUI wires both (see **TUI.md**); a GUI shell would reuse `useActiveTodos` + `TODO_STATUS_GLYPHS` and draw its own chrome.
696
-
697
- - **Inline indicator.** Single-line "▸ <in-progress content>" badge to render near the prompt input. Show iff `selectActiveTodos(session).inProgress` is non-null. Gated on `Settings.showTodoIndicator` (default `true`). When the active run has no `in_progress` item the indicator returns null — never a placeholder, never visual noise.
698
- - **Todos modal.** Read-only viewer for the active run's slot. Renders the per-status tally header + a scrollable list of rows (`TODO_STATUS_GLYPHS[status]` + content). No interactive controls — the model is the only writer. The host wires it behind the `openTodos` keybinding action (default `ctrl+t` — see **Keybindings** below); the modal closes on `esc`. Resolves the active run via `pickActiveRunId` (latest running, fallback most-recently-appended) so a child subagent's slot surfaces when it's the live run.
699
- - **Active todo prompt anchor.** Optional. Add `createActiveTodoAnchorHooks()` to a chat/profile's hooks when a host wants the current live todos repeated in the dynamic system prompt. Leave it off for minimal prompts; it never changes `todowrite` storage or UI behavior.
700
-
701
- The hook recomputes on every parent re-render (cheap O(runs + todos) selector) so a `setTodosForRun` write — whether to the session slot or a subagent's — lands in the indicator and modal on the next paint without any memoization-cache invalidation gymnastics.
702
-
703
- For surfaces that don't sit on the streaming-buffer cascade — typically modal trees rendered above the chat shell — the host should drive an explicit re-render by subscribing to the agent's `tool:after` hook filtered to `TODOWRITE_TOOL` (the hook payload's `name` field). The TUI's `TodosModal` does this so it stays live while open; the indicator gets it for free because it sits inside `ChatScreen` and rides the existing event cascade.
704
-
705
- `Settings.showTodoIndicator` (boolean, default `true`) hides only the inline indicator. The modal stays accessible regardless — opening it is an explicit user gesture.
318
+ - Exactly one item may be `'in_progress'` at a time — `todowrite` rejects a second, forcing the model to finish or explicitly park the current one first.
319
+ - The list auto-clears once every item is `completed`, so a fresh `todowrite` call starts a clean board instead of accumulating stale done-items across unrelated tasks.
320
+ - `Settings.showTodoIndicator` (default on) shows a single muted `▸ <in-progress todo>` line above the prompt while a run has an in-progress item; `ctrl+t` opens the full list regardless of the toggle.
706
321
 
707
322
  ## Interactions
708
323
 
709
- `present_plan` and `ask_user` let the agent pause for explicit user input. **The call IS the persisted state** — both tools land in `session.turns` as regular `tool_call` blocks, and pending entries are derived from disk via `pendingInteractionsFromTurns(turns)`.
324
+ `createInteractionTools({ requestInteraction })` builds the `present_plan` / `ask_user` tools. Full protocol — request/response shapes, crash-resume semantics, daemon behavior — lives in `docs/INTERACTIONS.md`; this package is the implementation:
710
325
 
711
- Public API (re-exported from `zidane/chat`):
712
-
713
- - **Tools** — `createInteractionTools({ requestInteraction })`, `ASK_USER_TOOL`, `PRESENT_PLAN_TOOL`, `isInteractionTool`.
714
- - **Queue** — `InteractionsProvider`, `useInteractionsQueue`, `useInteractionsActions`, `makeRequestInteraction(actions)`, `PendingInteractionEntry`.
715
- - **Derivation** — `pendingInteractionsFromTurns(turns)`, `buildResumedToolResultsTurn(requests, responses, opts)`, `serializeInteractionResponse(response)`.
716
- - **Types** — `InteractionRequest`, `InteractionResponse`, `PlanRequest`, `QuestionRequest`, `PlanResponse`, `QuestionResponse`, `PlanPayload`, `QuestionPayload`, `Question`, `TextQuestion`, `SelectQuestion`, `ConfirmQuestion`, `QuestionType`, `QuestionChoice`, `PlanStep`, `PlanDecision`, `AnswerValue`, `InteractionsActions`.
717
-
718
- A GUI shell:
719
-
720
- 1. Mounts `<InteractionsProvider>`.
721
- 2. Injects the tools at agent build time:
722
- `createInteractionTools({ requestInteraction: makeRequestInteraction(useInteractionsActions()) })`.
723
- 3. Renders the head of the queue with its own UI (form / dialog / inline block).
724
- 4. On session activation, calls `pendingInteractionsFromTurns(turns)` — non-empty results enqueue resumed entries whose resolvers collect responses into a single `Map`, and once full call `buildResumedToolResultsTurn(...)` + `agent.run()`.
725
-
726
- See `docs/INTERACTIONS.md` for the full protocol (payload shapes, resumed-flow contract, smoke-test checklist).
326
+ - Mount `<InteractionsProvider>`, build tools with `createInteractionTools({ requestInteraction: makeRequestInteraction(useInteractionsActions()) })`, render the head of the queue.
327
+ - On session activation, call `pendingInteractionsFromTurns(turns)` to find orphaned requests from a crashed run, collect responses into a `Map`, and once full call `buildResumedToolResultsTurn` to synthesize the missing `tool_result`s before resuming.
328
+ - `Settings.interactionMode: 'disabled'` strips both tools from the toolset on the next session activation; `'skip'` keeps them registered but auto-resolves every request.
727
329
 
728
330
  ## Keybindings
729
331
 
730
- A user-overridable action catalog. Defaults declared in `KEYBINDING_DEFS`; user overrides live at `<userDir>/keybindings.json` (JSONC). `readKeybindings(userDir)` returns the merged map; `resolveConfig` calls it and exposes it as `ResolvedConfig.keybindings`.
332
+ `KEYBINDING_DEFS: Record<KeyAction, KeyBindingDef>` is the canonical action catalog — default chord(s) + description per action:
731
333
 
732
334
  ```ts
733
- type KeyAction
734
- = | 'openSettings' | 'openSessionDetails' | 'openModelPicker' | 'openEffortPicker'
735
- | 'openTodos' | 'openContextPanel' | 'openUsagePanel' | 'openKeybindings' | 'cycleAgent' | 'enterSelectTurnMode'
736
- | 'enterQueueSelection' | 'pushQueuedMessage' | 'dropQueuedMessage'
737
- | 'turnFork' | 'turnDelete' | 'turnCopy'
738
- | 'sessionDelete' | 'sessionCopyId' | 'sessionGenerateTitle'
739
- | 'sessionExportMarkdown' | 'sessionExportJson' | 'sessionCompact'
335
+ type KeyAction =
336
+ | 'quit' | 'openSettings' | 'openSessions' | 'newSession' | 'openArtifacts'
337
+ | 'openMainSession' | 'openModelPicker' | 'openAgentPicker' | 'openMcpPanel'
338
+ | 'openSkillsPanel' | 'openLogs' | 'toggleSafeMode' | 'toggleTodos'
339
+ | 'cancelRun' | 'cancelToolCall' | 'changeCwd' | 'attachClipboardImage'
340
+ | 'openExternalEditor' | 'turnEdit' | 'forkSession' | 'deleteSession'
341
+ | 'exportSession' | 'sessionCompact'
740
342
  ```
741
343
 
742
- Each `KeyBindingDef` carries `{ action, default, label, description }` — the `description` is rendered as a comment in the auto-generated `keybindings.json`. Defaults follow `ctrl+<letter>` for globals, single letters for modal-internal actions.
743
-
744
- `matchesBinding(event, spec)` is strict on modifiers: all declared must be pressed AND no extras. The parser is tolerant — case-insensitive, `+` / `-` separators, modifier aliases (`cmd` / `command` / `super` / `win` → `meta`; `option` / `opt` → `alt`), key aliases (`esc` ↔ `escape`, `enter` / `ret` ↔ `return`, `del` ↔ `delete`, `pgup` / `pgdn` ↔ `pageup` / `pagedown`).
745
-
746
- `ensureKeybindingsFile(userDir)` writes a fully-commented defaults file on first launch. Existing files are never overwritten — user customizations are sacred.
747
-
748
- A GUI can reuse `KEYBINDING_DEFS` for its own command palette / cheat-sheet surface, and `matchesBinding` for its keyboard router. TUI-specific keys (which terminal actions own which letters) are detailed in `docs/TUI.md`.
344
+ - **`matchesBinding(event, spec)`** — checks a parsed key event against a `KeyBindingSpec` (key + modifiers). `parseBindingSpec('ctrl+shift+p')` parses the JSONC-override string form.
345
+ - **`ensureKeybindingsFile(path)`** — writes a commented JSONC scaffold (every default, commented out) on first run so a user edits in place instead of authoring from scratch.
346
+ - Extensions can contribute their own bindings (`ResolvedExtensionKeybinding`), merged alongside the built-in table at resolve time.
347
+ - This module owns the *action catalog + resolution*, not the concrete global shortcut list or how a modal consumes a key — those live in `docs/TUI.md`.
749
348
 
750
349
  ## Auto-compaction
751
350
 
752
- Auto-compaction is **loop-native** — it lives in the SDK agent loop (`behavior.autoCompact`), not in the host. The loop watches each turn's effective input usage and, once it crosses the threshold, runs `compactConversation` + `summaryToTurn` + `buildPostCompactAttachments` mid-run, appends the marker to the session, and continues (see `docs/ARCHITECTURE.md` → loop-native compaction). It fires both mid tool-loop and at end-of-run, so a resumed session starts compacted.
753
-
754
- The host's only job is to translate its settings + the picked model's window into the per-run config and observe the lifecycle hooks:
755
-
756
- ```ts
757
- // Per run: honor the user's settings + inject the model window (the SDK core
758
- // has no model catalog). `chatAutoCompactBehavior` returns `false` when off.
759
- await agent.run({
760
- model, prompt,
761
- behavior: {
762
- autoCompact: chatAutoCompactBehavior({
763
- enabled: settings.autoCompact,
764
- threshold: settings.autoCompactThreshold,
765
- rawContextWindow: getContextWindow(descriptor, model), // null for custom models
766
- }),
767
- },
768
- })
769
-
770
- // UI feedback — the loop drives these; no host-side trigger / barrier needed.
771
- agent.hooks.hook('compact:start', ({ usedFraction }) => showBanner(usedFraction))
772
- agent.hooks.hook('compact:end', (ctx) => {
773
- if (ctx.status === 'success')
774
- repaintTranscript(session.turns) // marker is already appended + persisted
775
- })
776
- ```
777
-
778
- `shouldAutoCompact(input)` is still exported (`zidane/chat` / core `zidane`) as the pure predicate the loop uses internally — zero I/O, zero state, skip reasons `'disabled'` / `'unknown-window'` / `'invalid-threshold'` / `'invalid-input-tokens'` / `'under-threshold'` / `'already-compacting'` / `'cooldown'`. Hosts no longer call it directly; the loop owns the trigger, the hysteresis baseline, and a failure circuit breaker. The TUI/GUI still expose a manual "compact now" affordance on the session-details modal that calls `compactConversation` directly for an arbitrary (possibly inactive) session.
351
+ `Settings.autoCompact` (default on) + `Settings.autoCompactThreshold` (default `0.8`) drive `chatAutoCompactBehavior({ enabled, threshold, rawContextWindow, summarizer? })`, which a host passes as `behavior.autoCompact` to `agent.run()`. Loop-native: no host-side trigger, in-flight ref, or pre-run barrier — the loop evaluates `shouldAutoCompact` (`zidane/compact/policy`) mid-run and at end-of-run and cancels with the run's own abort signal. Paint feedback from the `compact:start` / `compact:end` hooks. `releaseReplacedTurns: true` is always set for chat hosts (long-lived, persist dir always configured) so compacted-away turns free memory — each one stays recoverable via its tool-result stub's persisted path.
779
352
 
780
353
  ## Context-usage breakdown
781
354
 
782
- A categorized view of what's consuming the context window — system prompt, rules (AGENTS.md/CLAUDE.md), skills catalog, MCP instructions, subagent definitions, tool definitions, MCP tools, conversation, plus deferred (lazy) tools, the autocompact buffer, and free space. Surfaced as the TUI `ctrl+shift+g` panel and the GUI context popover; both consume the same data.
355
+ `agent.getContextBreakdown(opts?)` returns a `ContextBreakdown` snapshot: token counts per component (system prompt, instruction files, active skills, tool schemas, conversation) plus the raw and effective context window. Feeds the footer's percentage indicator and the Settings → Instructions tab.
783
356
 
784
- - **`agent.getContextBreakdown(opts?)`** (on the core `Agent`, see `docs/ARCHITECTURE.md`) returns a `ContextBreakdown | null`. It snapshots the assembled prompt sections at run time (and a skills-only **pre-run** assembly before the first turn, so the panel works the moment the chat screen opens), then shapes them via `buildContextBreakdown`. The snapshot is cleared on `reset()` / `destroy()`, so it never leaks stale data across sessions.
785
- - **`buildContextBreakdown(snapshot)`** (`zidane/chat` + `zidane/chat/pure`, node-free) is the pure shaper. Live categories always reconcile to the real last-turn total (`conversation = used − Σ(other live)`), so the bar can never contradict the footer's `ctx` gauge. Free space and the autocompact buffer are exact; per-section counts are heuristic (`estimateTokens`) unless the provider supplies exact counts.
786
- - **Exact counts (optional).** When a provider implements `Provider.countTokens(payload)` (Anthropic `messages.countTokens`, OpenAI `responses.input_tokens`), `getContextBreakdown` differences a system-only and a system+tools probe to attribute the System prompt + Tool definitions buckets exactly; categories with no exact source render with a `~` prefix. `countTokens` returns `null` when unavailable (no endpoint / wrong auth) and the heuristic fills in — it can never break the breakdown.
787
- - **Autocompact zone.** Pass `opts.compactThreshold` (the `Settings.autoCompactThreshold` fraction) and the "Autocompact buffer" bucket becomes the whole tail from the threshold to the end of the window, so the bar shows exactly where compaction will fire.
788
- - **Rules attribution.** The agent can't isolate the rules block from the opaque host-composed system string, so the host passes `AgentRunOptions.contextSections = { rulesBlock, rulesFiles }` (the rendered AGENTS.md block + its source files) for a dedicated, per-file "Rules" row. Both hosts discover rules via `discoverAgentsMd`, but only the **GUI** currently threads `contextSections` into `agent.run()` — the TUI does not, so its breakdown has no "Rules" bucket. (The TUI's Settings → Instructions tab gets its per-file rows from the host side instead; see below.)
789
-
790
- `ContextBreakdown`, `ContextBreakdownOptions`, `ContextSnapshot`, `ContextCategory`, `ContextItem`, `ContextUsageSplit`, `ContextExactCounts`, `ContextMcpGroup`, and `buildContextBreakdown` are all exported from both `zidane/chat` and `zidane/chat/pure`.
357
+ - Sourced from the live run's assembled prompt when a run is in flight, falling back to a pre-run assembly otherwise — populated before the first message, `null` only after `destroy()`.
358
+ - No provider round-trip: it's the same strings the next request would send, counted locally.
359
+ - `activeSkills` lists which skills are currently live in the conversation (their bodies, not just catalog blurbs — see **Context segments**).
791
360
 
792
361
  ## Subscription usage
793
362
 
794
- How much of a *subscription* the user has burned, as opposed to the context window (above) or session cost. `chat/provider-usage` probes the providers' own usage endpoints and normalizes the answers into one shape. Surfaced as the TUI footer indicator (`▰ 5h 43%`) plus the `ctrl+p` panel; see [Subscription-usage panel in TUI.md](./TUI.md#subscription-usage-panel).
363
+ `fetchProviderUsage(descriptor, credential, opts?)` reads a provider's subscription usage (e.g. Claude Pro/Max session-window percentage, ChatGPT Plus/Pro caps) for the footer indicator and usage panel.
795
364
 
796
- - **`fetchProviderUsage(dataDir, descriptor, { refresh? })`** → `ProviderUsageSnapshot | null`. `null` means there is nothing to show: the provider has no usage surface, or no credential of the kind its probe needs. Otherwise the snapshot carries `status` (`'ok' | 'unauthorized' | 'unavailable'`), `plan`, `windows` (`{ id, label, usedPercent, resetsAt }`, percent 0-100, reset in epoch ms), `credits` (`{ remaining, total }`), `fetchedAt`, and a short `error` whenever `status !== 'ok'`.
797
- - **`fetchAllProviderUsage(dataDir, registry, opts?)`** → one snapshot per credentialed provider, registry order. `providerUsageSupported(descriptor)` and `usageSourceFor(descriptor)` answer whether a provider reports usage at all, and `mostConstrainedWindow(snapshot)` picks the window closest to its ceiling, which is what a one-line indicator should show.
798
- - **Credentials come from the file, then the environment for API keys.** `credentials.json` is checked first (it holds the credential `detectAuth` marks active); an API-key probe falls back to `process.env[descriptor.envKey]`, since `detectAuth` marks a provider available on a shell-set key alone and the launcher copies stored keys into env anyway. An entry whose `value` is empty (customFields-only) falls through to env too. OAuth probes stay file-only: those tokens have no env equivalent.
799
- - **Probes are keyed by credential-file key**, not provider key, so a host descriptor reusing a builtin credential slot (`credentialFileKey: 'anthropic'`) inherits its probe: `anthropic` → `api.anthropic.com/api/oauth/usage`, `openai-codex` → `chatgpt.com/backend-api/wham/usage` (with the stored `accountId` as `ChatGPT-Account-Id`), `xai-oauth` → the `grok.com` gRPC-web billing probe, `openrouter` → `openrouter.ai/api/v1/credits`.
800
- - **Fail-soft, never throwing.** Only the OpenRouter endpoint is official; the rest are unofficial and can change without notice. Transport errors, HTTP errors, gRPC out-of-band status (`grpc-status: 16` behind an HTTP 200) and unreadable payloads all normalize into a snapshot with a reason. The Grok payload is a protobuf with no schema available, recovered by a tolerant wire-format scan (`parseGrokBillingResponse`) rather than a vendored client, and it commonly refuses bearer tokens, so treat it as best-effort.
801
- - **Read-only: tokens are never refreshed here.** The run path owns OAuth refresh and refresh tokens are single-use, so a background usage read must not spend one. An expired access token short-circuits to `status: 'unauthorized'` with no request issued.
802
- - **Consent is two-tiered**, mirroring the update check. Hosts gate the *automatic* probes on `Settings.subscriptionUsage` (default on): the footer hook stops firing and the panel opens without loading, leaving `ctrl+r` inside it as an explicit fetch. `usageChecksOptedOut()` reads `ZIDANE_NO_USAGE_CHECK` / `CI` (same truthiness rule as `ZIDANE_NO_UPDATE`) and is enforced inside `fetchProviderUsage`, so it disables every probe including that refresh. Signing in to a provider is consent to talk to its inference endpoint, not to `chatgpt.com/backend-api` or `grok.com`.
803
- - **Unauthorized reasons stay in one register**: `not signed in`, `token expired` (local checks), `token rejected` (401/403), `token not accepted` (Grok's gRPC rejection). Other non-2xx keep `HTTP <status>`, where the code is the useful detail.
804
- - **Cached for `USAGE_CACHE_TTL_MS` (60s)** per credential key, with in-flight coalescing so a footer read and a panel open share one request. Snapshot identity (`providerKey` / `label`) is re-stamped per caller, since two descriptors can share a credential key. `refresh: true` bypasses both the cache and the coalescing map, so probes can overlap; each carries a monotonic ticket and only the newest may write the cache, which also means a probe spanning `clearProviderUsageCache()` cannot repopulate what it dropped. Keys are credential KEYS, not tokens, so a re-auth on the same provider would otherwise serve the previous account for a full TTL: the TUI calls `clearProviderUsageCache()` from its credential-refresh hook (`refreshAvailableProviders`).
365
+ - **Fail-soft, never throwing.** Only the OpenRouter endpoint is officially documented; the rest are unofficial and can change without notice. Transport errors, HTTP errors, and unreadable payloads all normalize into a snapshot with a `reason` string.
366
+ - **Read-only.** The run path owns OAuth refresh; a background usage read must not spend a single-use refresh token. An expired access token short-circuits to `status: 'unauthorized'`.
367
+ - **Two-tiered consent.** `Settings.subscriptionUsage` (default on) gates the *automatic* footer/panel probes — off leaves `ctrl+r` inside the panel as an explicit fetch. `ZIDANE_NO_USAGE_CHECK` / `CI` disable every probe, including that manual refresh, enforced inside `fetchProviderUsage` itself.
368
+ - **Cached for `USAGE_CACHE_TTL_MS` (60s)** per credential key, with in-flight coalescing so a footer read and a panel open share one request. `refresh: true` bypasses both; each refresh carries a monotonic ticket so only the newest write wins. `clearProviderUsageCache()` should be called from a credential-refresh hook so a re-auth on the same provider doesn't keep serving the previous account's cached snapshot.
805
369
 
806
370
  ## `SYSTEM.md` — overriding the built-in prompt
807
371
 
808
- `discoverSystemMd` (`chat/system-md`) finds a `SYSTEM.md` that **replaces** the built-in doctrine. Deliberately inverted semantics vs `AGENTS.md`:
372
+ `discoverSystemMd` finds a `SYSTEM.md` that **replaces** the built-in doctrine — inverted semantics from `AGENTS.md`:
809
373
 
810
374
  | | `AGENTS.md` | `SYSTEM.md` |
811
375
  |------------|-----------------------------|-----------------------------------|
@@ -813,168 +377,66 @@ How much of a *subscription* the user has burned, as opposed to the context wind
813
377
  | multiples | every match loads, in order | exactly one wins |
814
378
  | precedence | broad → narrow, all present | project beats user, rest shadowed |
815
379
 
816
- Discovery honors the same `userInstructionsScope` setting as `AGENTS.md`, with the same per-scope rules — `DiscoverSystemMdOptions.scope` takes the `AgentsMdScope` itself, not a boolean. `'none'` skips discovery before any filesystem probe (it exists to debug the base prompt "without any user doctrine bleeding in", and an override is the most invasive form of user doctrine there is). `'user'` drops the project candidate and `'project'` drops the user one, mirroring how `discoverAgentsMd` builds its list. That matters most for `'user'`: it is how someone says "do not trust this checkout", so a repo-root `SYSTEM.md` must not load there.
817
-
818
- > **A project-scope `SYSTEM.md` deletes safety doctrine.** `AGENTS.md` can only append, and is rendered under a header marking it as user-authored guidance. An override REPLACES the built-in prompt — `ACTIONS_WITH_CARE_DOCTRINE` and `GIT_DOCTRINE` included — so a file that merely exists at the root of a checkout can drop "destructive actions need explicit confirmation" and "never force-push without confirmation". Appending to doctrine and deleting doctrine are different risk profiles. Because discovery is automatic at session activation, the TUI paints a one-line transcript warning naming the file whenever a *project*-scope override is in force; the user-scope file stays quiet, since the user wrote it in their own home directory. Set `userInstructionsScope` to `'user'` or `'none'` to refuse repo-supplied overrides entirely.
819
-
820
- Search order, winner first: `<project root>/SYSTEM.md`, then `~/.{prefix}/SYSTEM.md`. Concatenating overrides would be incoherent — two files each claiming to *be* the system prompt can't both be it — so one wins and the losers come back in `SystemMdResult.shadowed` for the UI to mark inactive. An empty or whitespace-only file counts as absent, so a created-but-unwritten file can't hand the model a blank prompt.
821
-
822
- `BuildSystemOptions.systemOverride` is what applies it: `buildBuildSystem` / `buildPlanSystem` swap it in for identity + doctrine + guidance. Two things still compose around it:
823
-
824
- - **`userInstructions` is appended below it** — `AGENTS.md` stays an additive layer, so overriding the prompt doesn't silently disable your project instructions.
825
- - **The `<env>` block still lands below the cache boundary** — it carries cwd + project root, which the file tools' path resolution depends on. Dropping it would break `read_file` in ways that look like model error rather than configuration.
826
-
827
- `allowInteraction` is not consulted for the override text (the built-in fragments swap in no-prompts variants of themselves; your text is yours). One file overrides whichever profile is active — there is no plan-specific override.
828
-
829
- A host-supplied custom profile **cannot** be overridden: it keeps its own `preset.system`, so there is no built-in doctrine for the file to replace and the file is never read. Hosts must not report such a file as the prompt's source — the TUI threads a `supportsOverride` flag out of `composeHostContext` for exactly this, and refuses to create an override on those profiles rather than writing one that could never apply.
830
-
831
- `renderSystemMdSeed(staticPrompt)` builds the contents for a newly created override: an HTML-comment header explaining the semantics, followed by the doctrine that was in effect. Seeding with the real prompt makes creating the file a behavioral no-op until edited — an empty seed would strip ~1.2k tokens of doctrine the instant the file appeared, with no visible cause. Callers pass `splitSystemPrompt(system).static` so the per-turn `<env>` block never gets frozen into the file.
380
+ - Discovery honors `Settings.userInstructionsScope`, same per-scope rules as `AGENTS.md`. `'none'` skips discovery entirely; `'user'` drops the project candidate, `'project'` drops the user one.
381
+ - Search order, winner first: `<project root>/SYSTEM.md`, then `~/.{prefix}/SYSTEM.md`. Losers come back in `SystemMdResult.shadowed` so the UI can mark them inactive. An empty/whitespace-only file counts as absent.
382
+ - **A project-scope override deletes safety doctrine** — `ACTIONS_WITH_CARE_DOCTRINE` and `GIT_DOCTRINE` included — so a repo-supplied file can drop "destructive actions need confirmation" entirely. The TUI paints a transcript warning whenever a *project*-scope override is active (the user-scope file stays quiet — the user wrote it themselves). Set `userInstructionsScope` to `'user'` or `'none'` to refuse repo-supplied overrides.
383
+ - `BuildSystemOptions.systemOverride` applies it: `userInstructions` still appends below it, and the `<env>` block still lands below the cache boundary either way. `allowInteraction` swaps the built-in interaction fragment even when overridden; your override text is yours to write.
384
+ - A host-supplied **custom** profile (its own `preset.system`) cannot be overridden — there's no built-in doctrine to replace, and the file is never read. Hosts thread `composeHostContext`'s `supportsOverride` flag to avoid claiming otherwise.
385
+ - `renderSystemMdSeed(staticPrompt)` builds a newly-created override's contents: an HTML-comment header plus the doctrine that was in effect, so creating the file is a behavioral no-op until edited. Callers pass `splitSystemPrompt(system).static` so the per-turn `<env>` block never gets frozen into the file.
832
386
 
833
387
  ## Context segments
834
388
 
835
- Where the breakdown above answers "how many tokens is each part of my context", **context segments** answer "what IS each part, and where did it come from" — the data behind the TUI's Settings → Instructions tab.
389
+ Where the breakdown above answers "how many tokens", **context segments** answer "what IS each part, and where did it come from" — the data behind Settings → Instructions.
836
390
 
837
- - **`agent.getContextSegments(opts?)`** returns `ContextSegment[] | null` from the same assembly the breakdown uses (live run snapshot, falling back to the pre-run assembly), so it's populated before the first message and `null` only after `destroy()`. No provider round-trip and no tool serialization — just the assembled strings.
838
- - **`buildContextSegments(input)`** (`zidane/chat` + `zidane/chat/pure`, node-free) is the pure shaper. Each `ContextSegment` carries `{ id, label, origin, detail?, text, path?, tokens }`. The base "System prompt" segment is `baseSystem` minus `rulesBlock`, so instruction text is never reported twice; empty / whitespace-only pieces are dropped.
839
- - **Origins are honest about openability.** `'file'` is the only origin that carries a `path`, and therefore the only one a UI can hand to an editor. `'built-in'` is compiled in (base doctrine, subagent defs), `'generated'` is assembled at prompt time (the skills catalog), `'mcp'` is served by a connected server.
840
- - **`systemPath` promotes the base prompt to a file segment.** Pass it when a `SYSTEM.md` override produced the prompt and the "System prompt" segment flips from `'built-in'` to `'file'`, becoming openable. Host-supplied for the same reason as `rulesFiles`: the agent receives an already-composed system string with no provenance attached.
841
- - **`rulesBlock` is what makes the de-dup work.** The base-prompt segment subtracts it so instruction text isn't reported twice (once inline, once as its own file rows). `getContextSegments` falls back to the assembly's copy, but that only exists when the host threaded `AgentRunOptions.contextSections` into `run()` — which the TUI does not — so a host that renders its own instruction block should pass it explicitly.
842
- - **Instruction files are host-supplied.** Pass `opts.rulesFiles` — a host that already called `discoverAgentsMd` passes its `files` straight through, since `AgentsMdFile` is structurally compatible with `ContextSegmentFile`. The agent never reads those files itself (it receives the rendered block as an opaque substring), so without this it knows paths at best and never text.
843
- - **Skill bodies are not here.** Activating a skill puts its body in the *conversation* as a tool result, not the system prompt; only the catalog blurbs are default context. `ContextBreakdown.activeSkills` reports which skills are live.
844
-
845
- `ContextSegment`, `ContextSegmentFile`, `ContextSegmentOrigin`, `ContextSegmentsInput`, and `buildContextSegments` are exported from `zidane/chat` and `zidane/chat/pure` (types also from the root barrel).
391
+ - **`agent.getContextSegments(opts?)`** returns `ContextSegment[] | null` from the same assembly the breakdown uses.
392
+ - **`buildContextSegments(input)`** (also in `zidane/chat/pure`, node-free) is the pure shaper. Each segment carries `{ id, label, origin, detail?, text, path?, tokens }`; empty/whitespace-only pieces are dropped.
393
+ - **Origins**: `'file'` is the only origin with a `path` (openable in an editor); `'built-in'` is compiled in; `'generated'` is assembled at prompt time (skills catalog); `'mcp'` is server-provided.
394
+ - **`systemPath`** promotes the base-prompt segment from `'built-in'` to `'file'` when a `SYSTEM.md` override produced it.
395
+ - **`rulesBlock`** subtracts instruction text from the base-prompt segment so it isn't reported twice (once inline, once as its own file rows).
396
+ - **Instruction files are host-supplied** via `opts.rulesFiles` — the agent never reads them itself, so without this the segment knows paths at best, never text.
397
+ - **Skill bodies are not segments** — activating a skill puts its body in the conversation as a tool result, not the system prompt; only catalog blurbs are default context.
846
398
 
847
399
  ## Edit-diff rendering
848
400
 
849
- Renderer-agnostic helpers for turning `edit` / `multi_edit` / `write_file` tool calls into a unified diff. Two tiers:
401
+ Renderer-agnostic helpers for turning `edit`/`multi_edit`/`write_file` calls into a unified diff, two tiers:
850
402
 
851
- - **Header-only preview** — `extractEditPayload` + `buildUnifiedDiff`. Synthetic `@@ -1,N +1,M @@` chunk headers (no real file positions). Cheap, no priorContent walk; suitable for inline transcript diffs.
852
- - **Renderer-faithful preview** — `previewEditPayload`. Routes each hunk through the same lenient resolver the tool body uses (curly-quote recovery, line-number-prefix stripping, model-side `<n>`→`<name>` desanitize) and emits real file line numbers via `buildContextualDiff`. Returns per-hunk `HunkResolution` metadata so a GUI can badge unresolvable hunks instead of showing a misleading blank diff. The TUI's approval modal uses this so what the user previews matches what the tool would actually apply.
403
+ - **Header-only preview** — `extractEditPayload` + `buildUnifiedDiff`. Synthetic `@@ -1,N +1,M @@` headers, no priorContent walk. Cheap; fine for inline transcript diffs.
404
+ - **Renderer-faithful preview** — `previewEditPayload`. Routes each hunk through the same lenient resolver the tool body uses (curly-quote recovery, line-number-prefix stripping, `<n>`→`<name>` desanitize) and emits real file line numbers via `buildContextualDiff`. Returns per-hunk `HunkResolution` so a GUI can badge unresolvable hunks. The TUI's approval modal uses this tier so the preview matches what the tool would actually apply.
853
405
 
854
406
  ```ts
855
- const payload: EditPayload | undefined = extractEditPayload(name, input, priorContent)
856
- // payload.tool: 'edit' | 'multi_edit' | 'write_file'
857
- // payload.path: string
858
- // payload.hunks: readonly EditHunk[] ({ oldString, newString, replaceAll? })
859
- // payload.outcomes: readonly EditOutcome[] | undefined ← post-gate per-hunk state
860
-
861
- if (payload) {
862
- const unified = buildUnifiedDiff(payload) // synthetic headers, hunk-only
863
- const filetype = filetypeFromPath(payload.path) // 'typescript' | 'python' | ... | undefined
864
- }
407
+ const payload = extractEditPayload(name, input, priorContent)
408
+ // payload: { tool: 'edit'|'multi_edit'|'write_file', path, hunks, outcomes? }
865
409
 
866
- // Lower-level granularity:
867
- const lines: DiffLine[] = computeLineDiff(oldString, newString) // { op: 'context'|'add'|'remove', text }
868
- const inline: InlineDiff = computeInlineDiff(oldLine, newLine) // word-level segments for paired (-,+) rows
869
-
870
- // Whole-file diff with real line numbers + configurable context:
871
- const ctxDiff = buildContextualDiff(payload, priorContent, 3) // 3 = surrounding context lines
872
-
873
- // Model-faithful preview — mirrors the tool body's resolver:
874
- const preview: PreviewResult = previewEditPayload(payload, priorContent, 4)
875
- // preview.diffText : full unified diff of resolvable hunks
876
- // preview.resolution : HunkResolution[] (1:1 with payload.hunks)
877
- // preview.perHunkDiff : string[] (each hunk rendered against its predecessor's running state)
878
- // preview.resolvedPayload : EditPayload (oldString rewritten to haystack bytes, newString restyled)
410
+ const unified = buildUnifiedDiff(payload) // synthetic headers
411
+ const lines: DiffLine[] = computeLineDiff(oldString, newString) // { op: 'context'|'add'|'remove', text }
412
+ const ctxDiff = buildContextualDiff(payload, priorContent, 3) // real line numbers, 3 context lines
413
+ const preview = previewEditPayload(payload, priorContent, 4) // { diffText, resolution, perHunkDiff, resolvedPayload }
879
414
  ```
880
415
 
881
- ```ts
882
- type ResolvedVia
883
- = | 'exact'
884
- | 'quotes' // curly-quote recovery
885
- | 'desanitize' // model-side `<n>` → `<name>`
886
- | 'quotes+desanitize'
887
- | 'line-numbers' // stripped read_file `<N>\t…` / `<N>|…` prefixes
888
- | 'quotes+line-numbers'
889
-
890
- interface HunkResolution {
891
- /** True when the tool body would find this hunk and apply it without ambiguity. */
892
- resolved: boolean
893
- /** Path the resolver took — `'exact'` when no recovery was needed. */
894
- via?: ResolvedVia
895
- /** Match count in the running content for the hunk. */
896
- occurrences?: number
897
- /** True when the resolver found >1 match without `replace_all`. */
898
- ambiguous?: boolean
899
- }
900
- ```
901
-
902
- `applyEditPayload(payload, prior)` mirrors the tool-side semantics (`replaceAll === true` → `String.replaceAll`, otherwise first-occurrence) so a host can stage the post-apply file body without spawning the agent. It's the lower-level primitive `buildContextualDiff` is built on; hosts assembling a custom preview pipeline call them in tandem.
903
-
904
- Wire:
905
-
906
- - `tool:before` hook → read pre-write content for `write_file` (other edit tools carry it in the input) → call `extractEditPayload` → attach the result as `StreamEvent.edit` on the `'tool'` event. The host skips this default emit when the gate already painted a synthetic event for a partial / fully-denied call (otherwise the transcript would show one row with full hunks + a second with the reduced subset).
907
- - Renderer reads `event.edit` and renders accordingly. When `payload.outcomes` is populated, each hunk row carries an applied / denied / skipped / failed badge; pass the array through `summarizeOutcomes` for the header tally.
908
- - Suppress the paired success `tool-result` (`isEditErrorResult` is the gate). Success means "every hunk applied" — the result body is the legacy `Edited <path>: applied N edits (M replacements).` summary alone, no annotation block. Any presence of `<edit-outcomes>` (mixed / denied / failed) or a `[fully denied]` body keeps the result visible alongside the diff so the user reads the per-hunk reasons.
909
- - Historical replay (from `eventsFromTurns`) has no pre-write snapshot for `write_file`; the diff renders all-add, matching git's "new file" convention. Outcomes are reconstructed from the persisted `tool_result` body via `parseEditOutcomesFromResult` and re-attached to the `'tool'` event, so a reloaded transcript shows the same per-hunk badges live capture displayed.
910
-
911
- `theme.surfaces.diff` carries the row colors (`addBg`, `removeBg`, optional `*ContentBg`, `addFg`, `removeFg`); built-in themes pre-mix translucent reds/greens so terminals without alpha-blend get a legible effect, and CSS hosts can use the same hex values as `background-color`.
416
+ Wire: on `tool:before`, call `extractEditPayload` and attach the result as `StreamEvent.edit` on the `'tool'` event (skipped when the gate already painted a synthetic event for a partial/denied call). Suppress the paired success `'tool-result'` when `isEditErrorResult` says the body wasn't an error — the diff is the success signal. Historical replay (`eventsFromTurns`) reconstructs outcomes from the persisted result body via `parseEditOutcomesFromResult`. `theme.surfaces.diff` carries the row colors (`addBg`/`removeBg`/`addFg`/`removeFg`, optional `*ContentBg`).
912
417
 
913
418
  ## Per-edit approval
914
419
 
915
- Edit-family tools (`edit`, `multi_edit`, `write_file`) can accept or reject **individual hunks** instead of all-or-nothing. The user-side decision lives in `ApprovalDecision`'s `{ kind: 'partial', mask }` shape. Two layers cooperate:
916
-
917
- - **`multi_edit` body** is best-effort: a per-step failure (`old_string` not found, ambiguous, identical strings) is recorded against that step alone — siblings still run. The body emits an `<edit-outcomes>` block in its result whenever any step failed, keyed against the approved SUBSET it actually saw.
918
- - **The host** rebinds `ctx.input.edits` to the approved subset at `tool:gate` and (in `tool:transform`) merges the body's subset-keyed outcomes with its 1:1 approval-side decisions via `mergeApprovalAndBodyOutcomes`, then re-appends the canonical annotation block.
919
-
920
- Net effect: the canonical wire-format `<edit-outcomes>` block carries `applied` / `denied` / `skipped` / `failed` 1:1 with the model's ORIGINAL edits, for live + replay capture alike.
420
+ Edit-family tools can accept or reject **individual hunks** instead of all-or-nothing, via `ApprovalDecision`'s `{ kind: 'partial', mask }` shape. Two layers cooperate: `multi_edit`'s body is best-effort per-step (a failure on one step doesn't block siblings), and the host rebinds `ctx.input.edits` to the approved subset at `tool:gate`, then merges the body's subset-keyed outcomes with the approval-side 1:1 outcomes at `tool:transform` via `mergeApprovalAndBodyOutcomes`. Net effect: the canonical `<edit-outcomes>` block always carries `applied`/`denied`/`skipped`/`failed` 1:1 with the model's **original** edits.
921
421
 
922
422
  ```ts
923
423
  type EditOutcomeKind = 'applied' | 'denied' | 'skipped' | 'failed' | 'pending'
924
-
925
- interface EditOutcome {
926
- kind: EditOutcomeKind
927
- /** Short human-readable explanation surfaced in the transcript badge. */
928
- reason?: string
929
- }
930
-
931
- interface EditPayload {
932
- // …existing fields…
933
- /** 1:1 with `hunks`. Absent on a fresh tool call before the gate decides — renderer treats absence as "all applied". */
934
- outcomes?: readonly EditOutcome[]
935
- }
936
- ```
937
-
938
- ### Helpers
939
-
940
- ```ts
941
- import {
942
- resolveApprovalForPayload,
943
- maskToOutcomeKinds,
944
- buildEditOutcomesAnnotation,
945
- parseEditOutcomesFromResult,
946
- mergeApprovalAndBodyOutcomes,
947
- stripEditOutcomesAnnotation,
948
- summarizeOutcomes,
949
- type ResolvedApproval,
950
- } from 'zidane/chat'
424
+ interface EditOutcome { kind: EditOutcomeKind, reason?: string }
425
+ interface EditPayload { /* … */ outcomes?: readonly EditOutcome[] } // absent = "all applied"
951
426
  ```
952
427
 
953
428
  | Helper | Purpose |
954
429
  |---|---|
955
- | `resolveApprovalForPayload(decision, payload)` | Turn an `ApprovalDecision` into `{ outcomes, shouldBlock, syntheticEvent }`. Pure — does not mutate `input` / `payload`. |
956
- | `maskToOutcomeKinds(mask, fallbackLength, deniedReason?)` | Convert a boolean mask to a 1:1 `EditOutcome[]`. Missing entries default to `applied` (no-decision = keep). |
957
- | `buildEditOutcomesAnnotation(outcomes)` | Render an `EditOutcome[]` as the wire-format `<edit-outcomes>…</edit-outcomes>` block — body APPENDed to a tool result (joined with `\n\n`). Idempotent on missing reasons. |
958
- | `parseEditOutcomesFromResult(text)` | Re-parse the annotation block back into outcomes. Used by `eventsFromTurns` so replay shows the same badges as live capture. Returns `null` when the block is missing / malformed. |
959
- | `mergeApprovalAndBodyOutcomes(approval, body)` | Fold a body-side subset-keyed outcome list (e.g. from `multi_edit`'s best-effort body) into the approval-side 1:1 outcomes — each `applied` placeholder is replaced by the body's next outcome in subset order. Returns a fresh array. |
960
- | `stripEditOutcomesAnnotation(text)` | Peel the first `<edit-outcomes>` block (and one leading `\n\n` separator) out of a body. Used by `tool:transform` to remove a body-emitted block before re-appending the merged version — the parser is anchored on the FIRST block. |
961
- | `rewriteMultiEditHeader(text, merged, path)` | Rewrite a `multi_edit` body header so its `N of M edits` / `N attempted` counts reflect the merged outcomes (= model's original edit-list total), not the subset the body actually saw after gate rebinding. Preserves the body-side replacements count and pluralization. |
962
- | `summarizeOutcomes(outcomes)` | `{ applied, denied, skipped, failed, pending, total }` tally for the header badge. |
430
+ | `resolveApprovalForPayload(decision, payload)` | `ApprovalDecision` → `{ outcomes, shouldBlock, syntheticEvent }`. Pure. |
431
+ | `maskToOutcomeKinds(mask, fallbackLength, deniedReason?)` | Boolean mask → 1:1 `EditOutcome[]`; missing entries default to `applied`. |
432
+ | `buildEditOutcomesAnnotation(outcomes)` | Render the wire-format `<edit-outcomes>` block, appended to a result with `\n\n`. |
433
+ | `parseEditOutcomesFromResult(text)` | Re-parse the block for replay. `null` when missing/malformed. |
434
+ | `mergeApprovalAndBodyOutcomes(approval, body)` | Fold body-side subset outcomes into the approval-side 1:1 array. |
435
+ | `stripEditOutcomesAnnotation(text)` | Peel the first block out of a body before re-appending the merged version. |
436
+ | `rewriteMultiEditHeader(text, merged, path)` | Rewrite `multi_edit`'s `N of M edits` header to reflect the merged (original) total. |
437
+ | `summarizeOutcomes(outcomes)` | `{ applied, denied, skipped, failed, pending, total }` tally. |
963
438
 
964
- ```ts
965
- interface ResolvedApproval {
966
- /** Final state of every hunk after the decision (1:1 with payload.hunks). */
967
- outcomes: EditOutcome[]
968
- /** True when no hunk will be applied — gate should `block` the call. */
969
- shouldBlock: boolean
970
- /** Synthetic `EditPayload` for the transcript on a deny/partial. `null` on accept-all. */
971
- syntheticEvent: EditPayload | null
972
- }
973
- ```
974
-
975
- ### Wire format
976
-
977
- Canonical annotation block — emitted ONLY when at least one hunk is NOT applied. All-applied calls fall through to the legacy `Edited <path>: applied N edits (M replacements).` summary alone.
439
+ Wire format — emitted only when at least one hunk is not applied; an all-applied call falls through to the legacy one-line summary:
978
440
 
979
441
  ```
980
442
  Edited src/foo.ts: applied 2 edits (3 replacements).
@@ -986,73 +448,28 @@ Edited src/foo.ts: applied 2 edits (3 replacements).
986
448
  </edit-outcomes>
987
449
  ```
988
450
 
989
- - Opening + closing tags each on their own line.
990
- - One line per hunk: `#<1-based-index> <kind>[: <reason>]`.
991
- - `kind ∈ {applied | denied | skipped | failed}`.
992
- - Indexes are 1-based against the **model's original** `edits` array — `parseEditOutcomesFromResult` re-keys to a 0-based `EditOutcome[]`.
451
+ A fully-denied call skips the body path entirely: the persisted `tool_result` is `Blocked: User denied this tool call`, and the host emits a synthetic `'tool-result'` with body `[fully denied] <edit-outcomes>…</edit-outcomes>` for live display only.
993
452
 
994
- A fully-denied call (every hunk rejected) skips the substitute path entirely: the harness writes `Blocked: User denied this tool call` to the persisted tool_result, and the host emits a synthetic `tool-result` event with body `[fully denied] <edit-outcomes>…</edit-outcomes>` for live display only. `isEditErrorResult` keeps that synthetic body visible alongside the diff.
995
-
996
- ### Gate handler
997
-
998
- End-to-end pattern for a host wiring `tool:gate` against the helpers. The harness stays pure: the handler **rebinds** `ctx.input` (does not mutate the model's `tool_call.input` reference on the persisted assistant turn) and stashes outcomes for a paired `tool:transform` to append on the way out:
453
+ Gate-handler sketch — the harness stays pure; the handler rebinds `ctx.input` (never mutates the persisted `tool_call.input`) and stashes outcomes for a paired `tool:transform`:
999
454
 
1000
455
  ```ts
1001
- import {
1002
- buildEditOutcomesAnnotation,
1003
- extractEditPayload,
1004
- resolveApprovalForPayload,
1005
- toolCallPreview,
1006
- } from 'zidane/chat'
1007
-
1008
- const pendingAnnotations = new Map<string, readonly EditOutcome[]>()
1009
-
1010
456
  agent.hooks.hook('tool:gate', async (ctx) => {
1011
- if (ctx.block) return // upstream already refused
1012
-
1013
457
  const decision = await requestApproval(ctx.name, ctx.input, originator)
1014
- const editPayload = extractEditPayload(ctx.name, ctx.input)
1015
- if (!editPayload) {
1016
- if (decision === 'deny') {
1017
- ctx.block = true
1018
- ctx.reason = 'User denied this tool call'
1019
- }
1020
- return
1021
- }
1022
-
1023
- const resolved = resolveApprovalForPayload(decision, editPayload)
458
+ const payload = extractEditPayload(ctx.name, ctx.input)
459
+ if (!payload) { if (decision === 'deny') { ctx.block = true; ctx.reason = 'User denied this tool call' }; return }
1024
460
 
461
+ const resolved = resolveApprovalForPayload(decision, payload)
1025
462
  if (resolved.shouldBlock) {
1026
463
  if (resolved.syntheticEvent) {
1027
- stream.appendImmediate({
1028
- kind: 'tool',
1029
- text: toolCallPreview(ctx.name, ctx.input),
1030
- tool: ctx.name,
1031
- input: ctx.input,
1032
- edit: resolved.syntheticEvent,
1033
- ...(ctx.turnId ? { turnId: ctx.turnId } : {}),
1034
- })
1035
- stream.appendImmediate({
1036
- kind: 'tool-result',
1037
- text: `[fully denied] ${buildEditOutcomesAnnotation(resolved.outcomes)}`,
1038
- tool: ctx.name,
1039
- ...(ctx.turnId ? { turnId: ctx.turnId } : {}),
1040
- })
464
+ stream.appendImmediate({ kind: 'tool', tool: ctx.name, input: ctx.input, edit: resolved.syntheticEvent, text: toolCallPreview(ctx.name, ctx.input) })
465
+ stream.appendImmediate({ kind: 'tool-result', tool: ctx.name, text: `[fully denied] ${buildEditOutcomesAnnotation(resolved.outcomes)}` })
1041
466
  }
1042
- ctx.block = true
1043
- ctx.reason = 'User denied this tool call'
1044
- return
467
+ ctx.block = true; ctx.reason = 'User denied this tool call'; return
1045
468
  }
1046
-
1047
469
  if (resolved.syntheticEvent) {
1048
- // Rebind — fresh shallow clone whose `edits` is the approved subset.
1049
- // The model's original `tool_call.input` on the persisted turn stays
1050
- // intact because the rebind produces a new object.
1051
- const reducedEdits = reduceEditsByOutcomes(ctx.input.edits, resolved.outcomes)
1052
- ctx.input = { ...ctx.input, edits: reducedEdits }
470
+ ctx.input = { ...ctx.input, edits: reduceEditsByOutcomes(ctx.input.edits, resolved.outcomes) }
1053
471
  pendingAnnotations.set(ctx.callId, resolved.outcomes)
1054
472
  }
1055
- // accept-* (or partial that collapsed to all-applied) → fall through.
1056
473
  })
1057
474
 
1058
475
  agent.hooks.hook('tool:transform', (ctx) => {
@@ -1064,155 +481,78 @@ agent.hooks.hook('tool:transform', (ctx) => {
1064
481
  ? (ctx.result.length === 0 ? annotation : `${ctx.result}\n\n${annotation}`)
1065
482
  : [...ctx.result, { type: 'text', text: `\n${annotation}` }]
1066
483
  })
1067
-
1068
- // Same handler registered on `child:tool:transform` so subagent calls
1069
- // also get annotated — `BUBBLED_MUTABLE_EVENTS` shares the child ctx.
1070
- agent.hooks.hook('child:tool:transform', /* same body */)
484
+ // Register the same pair on `child:tool:transform` for subagent-issued edits.
485
+ // Wipe `pendingAnnotations` on `agent:done` — `tool:transform` doesn't fire on
486
+ // a `validation:reject` or throwing `tool:before`, which would otherwise strand entries.
1071
487
  ```
1072
488
 
1073
- Wipe `pendingAnnotations` on `agent:done` (covers completed / aborted / error paths) — `tool:transform` doesn't fire when `validation:reject` or a throwing `tool:before` synthesizes the tool_result, so entries can otherwise strand across runs. The TUI also clears the map defensively in its session-teardown path (`pendingAnnotationsRef.current.clear()`).
1074
-
1075
- ### `multi_edit` tool body shape
1076
-
1077
- Best-effort, per-hunk — the harness has no notion of approvals, but the body itself records failures per-step instead of bailing on the first. Applies every edit in input order against the file as left by the previous APPLIED step; a per-step rejection (`old_string` not found, ambiguous without `replace_all`, identical, malformed) is recorded against THAT step alone, and siblings still run. The file is written iff at least one step applied.
1078
-
1079
- Result shapes:
1080
-
1081
- - **All applied** → legacy `Edited <path>: applied N edits (M replacements).` summary, NO annotation block. Renderer suppression (`isEditErrorResult`) keeps the diff alone.
1082
- - **Mixed** → `Edited <path>: applied N of M edits (R replacements).` + per-failure lines (`edit #K failed: <reason>`) + `<edit-outcomes>` block keyed against the input the body saw.
1083
- - **All failed** → `multi_edit error: no edits applied to <path> (M attempted).` + per-failure lines + `<edit-outcomes>` block. The `multi_edit error:` prefix is preserved so existing visibility / log filters keep working.
1084
-
1085
- SDK consumers (CI agents, headless harnesses, pipelines parsing the result) get a self-describing result on success AND on partial failure — re-issuing just the failed steps is a parse-and-resubmit instead of "re-run the whole batch".
1086
-
1087
- When a host rebinds `ctx.input.edits` to the approved subset at the gate (above), the body sees a smaller batch and records outcomes against THAT subset. The host's `tool:transform` hook merges the body's subset-keyed outcomes with the approval-side decisions via `mergeApprovalAndBodyOutcomes` and rewrites the body's header (`rewriteMultiEditHeader`) so its `N of M` counts reflect the model's original total, not the subset. The merged outcomes also flow into the in-flight `'tool'` event via `updateToolEventOutcomes` so the live diff badges catch up — no session reload needed.
1088
-
1089
- ### Modal lifecycle
1090
-
1091
- The chat layer makes no assumption about how the host renders the approval surface. Two contracts a GUI must honor:
1092
-
1093
- - **Display the head of `useSafeModeQueue()` only.** The queue is FIFO; parallel tool calls prompt in arrival order. Resolve with `resolveHead(decision)` to pop the head — never resolve other entries directly, since the underlying `Promise` resolvers are owned by the provider.
1094
- - **Key the rendered surface on `request.id`.** Back-to-back approvals in the same queue tick must force-remount the modal so per-call UI state (per-hunk mask, focused row, zone, etc.) doesn't carry across. The TUI's `ChatScreen` does this with `<FileEditApprovalModal key={fileEditPending.id} … />` (`src/tui/screens.tsx`).
1095
-
1096
- For hosts that route some decisions through a separate modal stack (the TUI inlines `FileEditApprovalModal` in the transcript slot while non-file-edit gates render as an `ApprovalBlock` below the prompt), push an empty placeholder onto the global modal context for the inline surface so `useModalAwareFocus()` keeps releasing background focus — the focus contract is "any modal-aware surface = background blurred", and an inline modal is still a modal by that definition.
489
+ **Modal lifecycle:** display only the head of `useSafeModeQueue()` and resolve with `resolveHead(decision)`. Key the rendered surface on `request.id` so back-to-back approvals in the same tick force-remount (per-hunk mask / focused row must not carry across). Hosts routing some decisions through a separate modal stack should still push a placeholder onto the global modal context for an inline approval surface, so `useModalAwareFocus()` keeps releasing background focus.
1097
490
 
1098
491
  ## Tool call display
1099
492
 
1100
- Three rendering modes for `'tool'` `StreamEvent`s, picked via `Settings.toolCallDisplay`:
493
+ Three rendering modes for `'tool'` events, picked via `Settings.toolCallDisplay`:
1101
494
 
1102
- | Mode | What renders | When to use |
495
+ | Mode | Renders | Use |
1103
496
  |---|---|---|
1104
- | `'hidden'` | nothing (`isVisible` returns false for the kind) | transcript reads like a pure chat log |
1105
- | `'formatted'` (default) | curated per-tool one-liner from `tool-formatters` | balanced default — scannable signal, no JSON noise |
1106
- | `'full'` | `↳ <name>` header + entire `input` as syntax-highlighted JSON | debugging exactly what the model emitted |
1107
-
1108
- Every native tool ships a `ToolDisplayMeta` entry in `TOOL_DISPLAY`:
497
+ | `'hidden'` | nothing | transcript reads like a pure chat log |
498
+ | `'formatted'` (default) | curated per-tool one-liner | balanced default |
499
+ | `'full'` | `↳ <name>` header + full JSON input | debugging exact model output |
1109
500
 
1110
501
  ```ts
1111
- interface ToolDisplayMeta {
1112
- /** Sentence-case verb (e.g. `Read`, `Shell`, `Multi-edit`). */
1113
- displayName: string
1114
- /**
1115
- * Pull a one-line summary out of the raw model input. Returns null when
1116
- * the shape isn't what the tool expects; renderer falls back to the
1117
- * bare display name then.
1118
- */
1119
- format: (input: Record<string, unknown>) => ToolFormatLine | null
1120
- }
1121
-
1122
- interface ToolFormatLine {
1123
- target?: string // path / command / pattern / task
1124
- meta?: readonly string[] // suffix annotations joined with ` · `
1125
- }
502
+ interface ToolDisplayMeta { displayName: string, format: (input) => ToolFormatLine | null }
503
+ interface ToolFormatLine { target?: string, meta?: readonly string[] }
1126
504
  ```
1127
505
 
1128
- Examples (`displayName <target> · <meta>` shape):
1129
-
1130
506
  ```
1131
507
  Read src/foo.ts · L10–29
1132
508
  Shell git status
1133
509
  Grep /TODO/ · in src/ · *.ts · case-insensitive
1134
510
  Multi-edit src/foo.ts · 3 hunks
1135
- Write src/foo.ts · 2.4 KB
1136
- Spawn research the foo bar baz
1137
- Activate skill design-eng
1138
- Ask user 3 questions
1139
511
  Todos 5 items · 2 done · 1 in progress · 2 pending
1140
512
  ```
1141
513
 
1142
- Renderer-agnostic helpers:
1143
-
1144
- - **`displayNameFor(name)`** — curated entry's `displayName`, else a sentence-cased fallback (`mcp_github_create_issue` → `Github create issue` after the `mcp_` prefix is stripped).
1145
- - **`formatToolCall(name, input)`** — returns `ToolFormatLine | null`. Defensive: a misshapen input returns `null` (the renderer shows the bare display name).
1146
- - **`TOOL_DISPLAY`** — the registry itself. Host apps add their own entries by spreading: `{ ...TOOL_DISPLAY, my_custom_tool: { displayName: 'Foo', format: ... } }`.
1147
-
1148
- Render path expectations on the `'tool'` `StreamEvent`:
1149
-
1150
- - **`tool: string`** — canonical tool name (used as the registry key).
1151
- - **`input?: Record<string, unknown>`** — the raw arguments the model emitted. Populated by the `tool:before` + `child:tool:before` hooks (live) and by `eventsFromTurns` (historical replay). Required for both `'formatted'` and `'full'` to render usefully; legacy sessions without `input` fall back to a bare `↳ <name>` line.
514
+ - **`displayNameFor(name)`** — curated `displayName`, else a sentence-cased fallback (`mcp_` prefix stripped for MCP tools).
515
+ - **`formatToolCall(name, input)`** — `ToolFormatLine | null`; a misshapen input returns `null` and the renderer falls back to the bare name.
516
+ - **`TOOL_DISPLAY`** — the registry. Hosts add entries by spreading: `{ ...TOOL_DISPLAY, my_tool: { displayName, format } }`.
517
+ - Render path needs `tool: string` (registry key) and `input?: Record<string, unknown>` on the event — populated live by `tool:before`/`child:tool:before`, and by `eventsFromTurns` on replay. Legacy sessions without `input` fall back to `↳ <name>`.
1152
518
 
1153
519
  ## Session reconstruction
1154
520
 
1155
- `eventsFromTurns(turns, runs)` rebuilds the visible event stream from persisted turns. Pure function; no I/O.
1156
-
1157
- - Walks turns once, maintaining an **ancestry stack** of currently-open child runs derived from `SessionRun.parentRunId`. Diffing the stack against each turn's chain yields the correct `spawn-start` / `spawn-end` order for nested subagents (`parent → A → B → A → parent` emits `🌱A · 🌱B · 🌳B · 🌳A`).
1158
- - Tags every child-run event with `{ childId: 'child-N', depth }` where `child-N` is chronologically assigned by `startedAt`. Same labels the live spawn tool emits.
1159
- - Strips the redundant `Tokens: …` line from spawn tool-result bodies — only when guarded by the `[sub-agent <id>] …` header, so a body line that legitimately starts with `Tokens:` is preserved.
1160
- - Emits a `'compact-summary'` event for every `compact-summary` content block, with `compact` metadata (`replacedCount`, `model`, `compactedAt`, `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`).
1161
- - Detects leading `<task-notification>` text blocks (the wire format `renderTaskNotificationXml` emits) on user turns and synthesizes a `'task-notification'` event with structured `task` metadata (`taskId`, `status`, `exitCode`, `outputPath`, `command`, `durationMs`). The raw text block is suppressed from the `'user-prompt'` stream so the banner doesn't double-paint. Detection is anchored — a stray notification-shaped phrase mid-prompt won't trigger.
1162
-
1163
- `lastContextSizeFromTurns(turns, runs)` returns the most recent **depth-0** assistant turn's input token total (cache-aware: `input + cacheRead + cacheCreation`) — the next prompt feeds the parent, so a session that ended mid-spawn doesn't surface the child's window in the footer.
521
+ `eventsFromTurns(turns, runs)` rebuilds the visible event stream from persisted turns — pure, no I/O.
1164
522
 
1165
- `selectableTurnIds(events)` returns the ordered list of top-level turn ids the GUI can navigate (skips subagent turns + synthetic events).
523
+ - Maintains an ancestry stack from `SessionRun.parentRunId` so nested subagent spawn/end markers nest correctly, and tags every child-run event with `{ childId, depth }`.
524
+ - Emits a `'compact-summary'` event per `compact-summary` content block, and a `'task-notification'` event for persisted `<task-notification>` XML (the raw text block is dropped from the user-prompt stream so it doesn't double-paint).
525
+ - `lastContextSizeFromTurns(turns, runs)` returns the most recent depth-0 assistant turn's cache-aware input token total.
526
+ - `selectableTurnIds(events)` returns the ordered top-level turn ids a GUI can navigate.
527
+ - `listSessionMeta(store, filter?)` uses `Promise.allSettled` so one corrupt row doesn't take down the picker; `filter.projectRoot: null` asks for untagged legacy sessions.
528
+ - `deriveSessionTitle(turns, metadata)` prefers `metadata.title`, then `titleFromTurns` (first user message), then `'New session'`.
1166
529
 
1167
- `listSessionMeta(store, filter?)` uses `Promise.allSettled` so a single corrupt row doesn't take down the picker. `filter.projectRoot` scopes the list to one project (current TUI default); pass `null` to ask for untagged legacy sessions specifically.
1168
-
1169
- `deriveSessionTitle(turns, metadata)` prefers `metadata.title` (set by `generateSessionTitle`), then `titleFromTurns(turns)` (one-line first user message), then the literal `'New session'` (`DEFAULT_SESSION_TITLE`).
1170
-
1171
- ### Transcript view rules
1172
-
1173
- Pure helpers that encode the user-facing filter + spacing semantics so any renderer (TUI / GUI / SDK consumer) honors the same rules:
530
+ Transcript-view rules — pure helpers so every renderer honors the same filter/spacing semantics:
1174
531
 
1175
532
  ```ts
1176
533
  import { isVisible, marginTopFor, isEditErrorResult } from 'zidane/chat'
1177
-
1178
534
  const visible = events.filter(e => isVisible(e, settings))
1179
- // Render visible[] with a per-row top margin:
1180
535
  visible.map((e, i) => ({ event: e, marginTop: marginTopFor(e, visible[i - 1]) }))
1181
536
  ```
1182
537
 
1183
- - **`isVisible(event, settings)`** — applies `showThinking`, `toolCallDisplay`, `showToolResults`, `hideSubagentOutput`, and the `showEditDiffs` paired-result suppression in one place. Successful edit/multi_edit/write_file results are dropped (the diff itself is the success signal); error results bypass via `isEditErrorResult`.
1184
- - **`marginTopFor(event, previous)`** — returns the row count of breathing room above an event. Defaults per kind, with two context-aware overrides: consecutive `tool`/`tool-result` rows hug, and parent-level rows hug right after a subagent's `🌳` end marker.
1185
- - **`isEditErrorResult(text)`** — recognizes `Edit error:` / `multi_edit error:` / `Tool failed:` prefixes. Exposed for hosts that want the same "is this a failed edit?" check elsewhere.
538
+ `isVisible` applies `showThinking`/`toolCallDisplay`/`showToolResults`/`hideSubagentOutput`/`showEditDiffs` in one place. `marginTopFor` returns breathing-room row count, with tighter spacing for consecutive tool rows and right after a subagent's end marker. `isEditErrorResult` recognizes `Edit error:`/`multi_edit error:`/`Tool failed:` prefixes.
1186
539
 
1187
540
  ## Streaming buffer
1188
541
 
1189
- `useStreamBuffer()` is the React-side ring that the agent's `stream:text` / `stream:thinking` / `child:stream:*` hooks write into. Per-owner finalize (`finalizeStreamingMarkdownForOwner`) flips the trailing markdown event from `streaming: true` to `false` when a child completes, so reflowing stops at the right boundary.
1190
-
1191
- ```ts
1192
- type Owner = 'parent' | string // string = subagent's childId
1193
- ```
1194
-
1195
- Every queued delta carries an `Owner`; the buffer keeps per-owner trailing markdown events so a child's stream and the parent's stream can interleave without one leaking into the other's block.
542
+ `useStreamBuffer()` is the React-side ring that `stream:text`/`stream:thinking`/`child:stream:*` hooks write into. `Owner = 'parent' | <childId>` — every delta carries one, so a parent's and a child's streams interleave without leaking into each other's markdown block.
1196
543
 
1197
544
  ```tsx
1198
545
  const stream = useStreamBuffer(setEvents)
1199
-
1200
546
  agent.hooks.hook('stream:text', ctx => stream.queueStreamDelta('markdown', ctx.delta, { turnId: ctx.turnId }))
1201
- agent.hooks.hook('child:stream:text', ctx => stream.queueStreamDelta('markdown', ctx.delta, { childId: ctx.childId, depth: ctx.depth, turnId: ctx.turnId }))
547
+ agent.hooks.hook('child:stream:text', ctx => stream.queueStreamDelta('markdown', ctx.delta, { childId: ctx.childId, depth: ctx.depth }))
1202
548
  agent.hooks.hook('turn:after', () => stream.flushAndUpdate(finalizeStreamingMarkdown))
1203
- agent.hooks.hook('child:stream:end', ({ childId }) => {
1204
- stream.flushAndUpdate(prev => finalizeStreamingMarkdownForOwner(prev, childId))
1205
- })
549
+ agent.hooks.hook('child:stream:end', ({ childId }) => stream.flushAndUpdate(prev => finalizeStreamingMarkdownForOwner(prev, childId)))
1206
550
  ```
1207
551
 
1208
- `stream.appendImmediate(event)` is the path for synchronous events (`'tool'` / `'tool-result'` / `'info'` / `'error'` / `'spawn-start'` / `'spawn-end'`). `turnContextSize(usage)` computes the cache-aware total for the footer indicator.
1209
-
1210
- Stamp `callId: ctx.callId` on every emit constructed from a `tool:before` / `tool:after` / `tool:error` / `mcp:tool:after` / `child:tool:before` / `child:tool:after` hook (and on the gate-path's synthetic `'tool'` / `'tool-result'` emits). This is the canonical pairing key for out-of-process consumers. The replay path (`eventsFromTurns`) carries it through from each `tool_call.id` / `tool_result.callId` block verbatim, so live and reloaded transcripts share the same wiring.
552
+ `stream.appendImmediate(event)` is for synchronous events (`'tool'`, `'tool-result'`, `'info'`, `'error'`, `'spawn-start'`, `'spawn-end'`). `turnContextSize(usage)` computes the cache-aware total for a footer indicator. Stamp `callId: ctx.callId` on every emit built from a tool hook — the canonical pairing key for out-of-process consumers; `eventsFromTurns` carries it through verbatim on replay.
1211
553
 
1212
554
  ## `StreamEvent`
1213
555
 
1214
- The discriminated union the renderer consumes. `kind` values:
1215
-
1216
556
  ```
1217
557
  'thinking' | 'tool' | 'tool-result' | 'error'
1218
558
  | 'user-prompt' | 'info' | 'separator'
@@ -1220,20 +560,7 @@ The discriminated union the renderer consumes. `kind` values:
1220
560
  | 'compact-summary' | 'task-notification'
1221
561
  ```
1222
562
 
1223
- Notable fields:
1224
-
1225
- - `text: string` — main payload.
1226
- - `streaming?: boolean` — true while a markdown event is still receiving deltas.
1227
- - `childId?: string` / `depth?: number` — subagent ancestry.
1228
- - `tool?: string` — canonical tool name on `'tool'` / `'tool-result'` events (used for tool-aware filters).
1229
- - `callId?: string` — matches `tool_use.id` on the assistant turn's `tool_call` block. Canonical pairing key for `'tool'` ↔ `'tool-result'` (and tool-scoped `'error'`) events. Out-of-process consumers (GUI, SDK, logger, replayer) MUST pair by id — positional pairing within a tool-name bucket silently swaps results when two parallel calls share a tool name (common for `read_file`, `edit`, `multi_edit`). Absent on non-tool events and on session/provider-level error events that lack a callId in scope.
1230
- - `edit?: EditPayload` — populated on `'tool'` events for edit/multi_edit/write_file. See **Edit-diff rendering**.
1231
- - `refs?: { start, end, providerId }[]` — chip-highlight spans on `'user-prompt'` events.
1232
- - `turnId?: string` — the `SessionTurn.id` the event was emitted by; drives select-turn highlighting.
1233
- - `compact?: { replacedCount, model, compactedAt, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens }` — metadata for `'compact-summary'` boundary cards.
1234
- - `task?: { taskId, status: 'exited' | 'killed', exitCode, outputPath, command, durationMs }` — metadata for `'task-notification'` banners. Synthesized in `eventsFromTurns` from persisted `<task-notification>` XML in user-turn text blocks; the raw XML block is dropped from the user-prompt stream so the banner doesn't double-paint alongside it.
1235
-
1236
- `'user-prompt'` carries the **raw** user prompt without a `❯` prefix; renderers add their own chevron. `refs` offsets are aligned to the raw text.
563
+ Notable fields: `text`, `streaming?` (markdown still receiving deltas), `childId?`/`depth?` (subagent ancestry), `tool?` (canonical name on tool events), `callId?` (matches `tool_use.id` — **the** pairing key; positional pairing silently swaps results when two parallel calls share a tool name), `edit?: EditPayload`, `refs?: {start,end,providerId}[]` (chip spans on `'user-prompt'`), `turnId?`, `compact?` (compact-summary metadata), `task?` (task-notification metadata). `'user-prompt'` carries the raw prompt without a `❯` prefix; renderers add their own.
1237
564
 
1238
565
  ## Completion
1239
566
 
@@ -1242,66 +569,40 @@ Slash-command + reference-aware prompt autocompletion. Renderer-agnostic.
1242
569
  ```ts
1243
570
  import { createSkillsCompletionProvider, createFilesCompletionProvider, useCompletion } from 'zidane/chat'
1244
571
 
1245
- const skillsProvider = createSkillsCompletionProvider({
1246
- getCatalog: () => skillsCatalog, // live — re-read per call
1247
- getEnabled: () => settings.enabledSkills, // optional allowlist
1248
- })
1249
- const filesProvider = createFilesCompletionProvider({
1250
- getCatalog: () => filesCatalog,
1251
- })
1252
-
1253
- // In the host component:
1254
572
  const completion = useCompletion(
1255
573
  { text: bufferText, cursor: bufferCursor },
1256
- [skillsProvider, filesProvider],
574
+ [skillsProvider, filesProvider, commandsProvider, chatsProvider, artifactsProvider],
1257
575
  )
1258
576
  // → { active, items, selectedIndex, selectNext, selectPrev, commit, dismiss, references }
1259
577
  ```
1260
578
 
1261
- A **provider** owns one trigger character + two operations:
579
+ A provider owns one trigger character + two operations:
1262
580
 
1263
581
  ```ts
1264
582
  interface CompletionProvider<TItem> {
1265
- id: string // 'skills' | 'files' | 'tags' | ...
583
+ id: string // 'skills' | 'files' | 'commands' | 'chats' | 'artifacts'
1266
584
  trigger: string // '/', '@', '#'
1267
- label: string // display only
585
+ label: string
1268
586
  suggest(query, ctx, signal): CompletionItem<TItem>[] | Promise<…>
1269
587
  parseReferences(text, ctx): CompletionReference<TItem>[]
1270
588
  }
1271
589
  ```
1272
590
 
1273
- `suggest` returns items for the live query (after the trigger, up to the cursor). `parseReferences` re-scans the whole buffer on every keystroke for finalized references — these drive highlighting in the rendered prompt + submit-time side effects (`uniqueSkillNamesFromReferences(refs)` → `agent.activateSkill(name)`). No chip-tracking state; if the user edits the reference text away the highlight disappears automatically on the next reparse.
1274
-
1275
- Trigger rules (enforced by `findActiveTrigger`):
1276
-
1277
- - Trigger char at buffer start or after whitespace.
1278
- - Cursor on the same contiguous token (no whitespace between trigger and cursor).
1279
- - Query bounded by `maxQueryLength` (default 64).
1280
-
1281
591
  Built-in providers:
1282
592
 
1283
- - **`createSkillsCompletionProvider`** — trigger `/`. Filters by the user's allowlist. Matching is case-insensitive; name prefix outranks name substring outranks description substring, with an fzf-scored name fuzzy fallback so `reseach` still finds `research`. Submission helper: `uniqueSkillNamesFromReferences`.
1284
- - **`createFilesCompletionProvider`** — trigger `@`. Suggests files from the cwd, gitignored paths excluded. Matching is case-insensitive across name + path, with an fzf-scored basename fuzzy fallback so `lop.ts` still finds `loop.ts`; exact / prefix / substring hits always outrank fuzzy ones. Submission helper: `uniqueFilesFromReferences`. Optional `formatPath(entry)` hook rewrites the catalog's project-root-relative path into the form inserted into the prompt; pair with `formatPathForCwd` so the emitted reference resolves under the agent's CWD even when the TUI launches from a project subdirectory (default: identity).
1285
-
1286
- ```ts
1287
- import { createFilesCompletionProvider, formatPathForCwd } from 'zidane/chat'
1288
-
1289
- const filesProvider = createFilesCompletionProvider({
1290
- getCatalog: () => filesCatalog,
1291
- ensureCatalog: () => ensureFilesCatalog(),
1292
- formatPath: entry => formatPathForCwd(entry.path, projectDir, process.cwd()),
1293
- })
1294
- ```
1295
-
1296
- `formatPathForCwd(projectRel, projectRoot, cwd)` is pure and deterministic. When the target sits inside `cwd`, it returns a forward-slashed CWD-relative path (no `..` traversal); when it sits above, it returns the absolute path. A `..`-laden form would be valid but unreadable and surprises the user, whose mental model of `@` is "pick a file from the project view". The bug it closes: discovery walks from the git root, so `entry.path` is repo-root-relative; the agent's tools resolve relative to CWD, so inserting raw `entry.path` from a subdirectory resolved to `<cwd>/<rel>` and missed.
1297
-
1298
- References are highlighted in two surfaces — every provider's spans flow into `event.refs` (with `kind = providerId`) and the renderer paints each chip with the matching pair from `surfaces.chips[providerId] ?? surfaces.chips.default`. Built-in themes ship distinct `skills` + `files` tones.
593
+ | Provider | Trigger | Notes |
594
+ |---|---|---|
595
+ | `createSkillsCompletionProvider` | `/` | Filters by the enabled-skills allowlist; name-prefix > name-substring > description-substring, fzf fallback. Submit via `uniqueSkillNamesFromReferences`. |
596
+ | `createCommandsCompletionProvider` | `/` | Suggests the merged built-in + extension command list (`COMMANDS_TRIGGER`). Shares the `/` trigger with skills — both populate the same popup. |
597
+ | `createFilesCompletionProvider` | `@` | Suggests cwd files, gitignored paths excluded; fzf basename fallback. Submit via `uniqueFilesFromReferences`. Optional `formatPath` (pair with `formatPathForCwd`) rewrites a project-root-relative catalog path into one that resolves under the agent's actual cwd. |
598
+ | `createChatsCompletionProvider` | `#` | References another session by title (`CHATS_TRIGGER`). Submit via `uniqueChatIdsFromReferences`. |
599
+ | `createArtifactsCompletionProvider` | `#` | References a session artifact — **shares the `#` trigger with the chats provider**; both feed the same popup keyed by `ARTIFACTS_COMPLETION_PROVIDER_ID`. See **Session artifacts**. |
1299
600
 
1300
- For chip rendering on submitted prompts, use `splitPromptSegments(text, refs)` to get a `PromptSegment[]` of `'plain'` runs + `'chip'` atoms — map each segment to an inline-block pill.
601
+ `findActiveTrigger` enforces: trigger at buffer start or after whitespace, cursor on the same contiguous token, query bounded by `maxQueryLength` (default 64). References flow into `event.refs` (`kind = providerId`); the renderer paints each chip with `theme.surfaces.chips[providerId] ?? chips.default`. Use `splitPromptSegments(text, refs)` to get `'plain'`/`'chip'` segments for a submitted prompt.
1301
602
 
1302
603
  ## Discovery context
1303
604
 
1304
- Live catalogs (project files, skills, MCPs) flow through a React context so modals opened mid-session see fresh state — not a snapshot frozen at modal-open time. The modal layer keeps its open node in `useState`, and React skips re-rendering a subtree when the element reference is stable; passing `skillsCatalog` as a prop would bake the snapshot at open time. Context dodges that.
605
+ Live catalogs (project files, skills, MCPs) flow through a React context so a modal opened mid-session sees fresh state, not a snapshot frozen at open time.
1305
606
 
1306
607
  ```ts
1307
608
  interface DiscoveryContextValue {
@@ -1309,105 +610,48 @@ interface DiscoveryContextValue {
1309
610
  mcpsCatalog: readonly DiscoveredMcp[]
1310
611
  mcpsErrors: readonly DiscoveryError[]
1311
612
  filesCatalog: readonly FileEntry[]
1312
-
1313
- refreshSkills(): Promise<void> // bypass SWR throttle
613
+ refreshSkills(): Promise<void> // bypass SWR throttle
1314
614
  refreshMcps(): Promise<void>
1315
615
  refreshFiles(): Promise<void>
1316
-
1317
616
  ensureSkills(): Promise<readonly SkillConfig[]> // idempotent first-load
1318
617
  ensureFiles(): Promise<readonly FileEntry[]>
1319
618
  }
1320
619
 
1321
- const ctx = useDiscovery() // throws outside a provider
1322
- const optional = useDiscoveryOptional() // returns null instead
620
+ const ctx = useDiscovery() // throws outside a provider
621
+ const optional = useDiscoveryOptional() // returns null instead
1323
622
  ```
1324
623
 
1325
- `createDiscoverySlot({ walk, commit? })` is the stale-while-revalidate primitive backing each catalog — wire one slot per kind into your shell, then bridge them onto `<DiscoveryProvider value={...}>`. `DiscoveryShell` (`src/tui/discovery-shell.tsx`) is the canonical TUI composition. Mount the provider **above** `<ModalRoot>` so a refresh mid-modal propagates into the open modal. Standalone embedders rendering individual modals can skip it — `useDiscoveryOptional()` returns `null` and the modal falls back to its catalog props.
624
+ `createDiscoverySlot({ walk, commit? })` is the stale-while-revalidate primitive backing each catalog. Mount `<DiscoveryProvider>` **above** `<ModalRoot>` so a refresh mid-modal propagates in. Standalone embedders rendering a single modal can skip it — `useDiscoveryOptional()` returns `null` and the modal falls back to its catalog props.
1326
625
 
1327
626
  ## Project skills + MCP servers
1328
627
 
1329
- Both autoload from a fixed search order — project beats user, first-found wins on name collision:
628
+ Both autoload from a fixed search order, project beats user, first-found wins on name collision:
1330
629
 
1331
630
  ```
1332
- {cwd}/.agents/skills/<name>/SKILL.md (project, agnostic convention)
1333
- {cwd}/.{prefix}/skills/<name>/SKILL.md (project, zidane — `.zidane` by default)
1334
- ~/.agents/skills/<name>/SKILL.md (user, agnostic)
1335
- ~/.{prefix}/skills/<name>/SKILL.md (user, zidane)
1336
- ```
631
+ {cwd}/.agents/skills/<name>/SKILL.md {cwd}/.{prefix}/skills/<name>/SKILL.md
632
+ ~/.agents/skills/<name>/SKILL.md ~/.{prefix}/skills/<name>/SKILL.md
1337
633
 
1338
- ```
1339
- {cwd}/.agents/mcps.json {cwd}/.agents/mcp.json
1340
- {cwd}/.{prefix}/mcps.json {cwd}/.{prefix}/mcp.json
1341
- ~/.agents/mcps.json ~/.agents/mcp.json
1342
- ~/.{prefix}/mcps.json ~/.{prefix}/mcp.json
634
+ {cwd}/.agents/mcps.json {cwd}/.agents/mcp.json {cwd}/.{prefix}/mcps.json {cwd}/.{prefix}/mcp.json
635
+ ~/.agents/mcps.json ~/.agents/mcp.json ~/.{prefix}/mcps.json ~/.{prefix}/mcp.json
1343
636
  ```
1344
637
 
1345
- Both filenames are picked up from every search root. `mcps.json` is zidane's canonical name; `mcp.json` is the Cursor / common ecosystem alias and gets merged alongside (`mcps.json` wins on same-name collisions within a directory; non-overlapping entries from `mcp.json` are kept). `projectUserPaths({ subPath, cwd, home, prefix })` is the shared builder — extend any new discovery surface with the same convention.
638
+ `mcps.json` is zidane's canonical name; `mcp.json` is the Cursor/ecosystem alias, merged alongside (`mcps.json` wins same-name collisions). `projectUserPaths({ subPath, cwd, home, prefix })` is the shared builder for both.
1346
639
 
1347
- `mcps.json` / `mcp.json` accepts three shapes:
640
+ `mcps.json`/`mcp.json` accepts an array, a name-keyed map, or the Claude/Cursor `{ "mcpServers": { ... } }` wrapper — all three feed through `normalizeMcpServers`. Inline `"enabled": false` (Cursor runtime state) drops an entry before discovery. Placeholders `${VAR}`, `${VAR:-default}`, `${env:VAR}` expand across `command`/`args`/`env`/`headers`/`url`/`cwd` at connect time (not parse time, so config writers round-trip the raw placeholder); an unset variable with no default is left verbatim rather than silently emptied.
1348
641
 
1349
- ```json
1350
- [
1351
- { "name": "filesystem", "transport": "stdio", "command": "npx",
1352
- "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }
1353
- ]
1354
- ```
1355
-
1356
- ```json
1357
- {
1358
- "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] },
1359
- "github": { "url": "http://localhost:3002/mcp" }
1360
- }
1361
- ```
1362
-
1363
- ```json
1364
- {
1365
- "mcpServers": {
1366
- "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] },
1367
- "github": { "url": "http://localhost:3002/mcp" }
1368
- }
1369
- }
1370
- ```
1371
-
1372
- The first is canonical zidane; the second is a name-keyed map; the third is the Claude Desktop / Claude Code / Cursor wrapper, accepted so you can paste a `.mcp.json` / `~/.cursor/mcp.json` verbatim. All three feed through `normalizeMcpServers` so they end up shaped exactly like the agent's `mcpServers` option. Inline `"enabled": false` on an entry (Cursor runtime state) drops it before discovery.
1373
-
1374
- Environment-variable placeholders are expanded across `command`, `args`, `env` values, `headers` values, `url`, and `cwd` — the ecosystem convention for keeping tokens out of checked-in config files. Supported forms: `${VAR}`, `${VAR:-default}` (Claude Code), and `${env:VAR}` (VS Code / Cursor). Expansion happens at CONNECT time (`expandMcpConfigEnv` at the transport boundary), never at parse time — so config writers (the Claude import, settings upserts) always round-trip the raw placeholder and resolved secrets never land in a file. An unset **or empty** variable takes its `:-default` when present (shell semantics), otherwise an unset variable's placeholder is left verbatim (a literal `${…}` failing loudly at the server beats an empty token failing mysteriously; `ZIDANE_DEBUG=1` names the missing variable on stderr):
1375
-
1376
- ```json
1377
- {
1378
- "github": {
1379
- "command": "npx",
1380
- "args": ["-y", "mcp-remote", "https://api.githubcopilot.com/mcp/", "--header", "Authorization: Bearer ${GITHUB_API_TOKEN}"]
1381
- }
1382
- }
1383
- ```
1384
-
1385
- `parseMcpsFile(text)` is tolerant to NBSP / BOM / zero-width chars outside string literals (common copy-paste artifacts).
1386
-
1387
- `discoverProjectMcps({ cwd, prefix })` returns `{ servers, errors }` — per-file parse failures land in `errors: DiscoveryError[]` so a GUI can render a "non-fatal parse error" preamble instead of silently dropping the entry. `buildMcpServers({ discovered, enabled })` applies the `enabledMcps` filter to produce the array consumed by `createAgent({ mcpServers })`.
1388
-
1389
- `settings.enabledSkills` / `settings.enabledMcps` are `undefined` until first toggle (every discovered entry is enabled). Once written, the persisted shape is a concrete array — newly-added skills don't silently flip on, the user opts them in.
1390
-
1391
- Toggling an enable/disable flag does NOT tear down a stream in flight — change takes effect on the next session activation.
642
+ `parseMcpsFile(text)` tolerates NBSP/BOM/zero-width chars outside string literals. `discoverProjectMcps({ cwd, prefix })` returns `{ servers, errors }` — per-file parse failures land in `errors` instead of silently dropping the entry. `buildMcpServers({ discovered, enabled })` applies `Settings.enabledMcps` to produce the array for `createAgent({ mcpServers })`. Toggling an enable/disable flag does not tear down a stream in flight — it applies on the next session activation.
1392
643
 
1393
644
  ## OAuth (two surfaces)
1394
645
 
1395
- Two distinct OAuth flows, both ending in stored credentials and a working session:
1396
-
1397
646
  ### AI-provider OAuth
1398
647
 
1399
- Used by Anthropic (Claude Pro/Max) and OpenAI Codex. Wired through `descriptor.oauthProvider` (an `OAuthProviderInterface` — zidane's own type, importable from `'zidane'`; pi-ai removed its version of the interface in 0.80.8).
648
+ Anthropic (Claude Pro/Max) and OpenAI Codex, via `descriptor.oauthProvider` (`OAuthProviderInterface`, importable from `'zidane'`).
1400
649
 
1401
650
  ```ts
1402
- import { oauthUsesManualCodePaste, runOAuthLogin, setProviderCredential, supportsOAuth } from 'zidane/chat'
1403
-
1404
651
  if (supportsOAuth(descriptor)) {
1405
652
  const credentials = await runOAuthLogin(descriptor, {
1406
653
  onUrl: (url, instructions) => { /* render to user */ },
1407
- // Required for providers with `usesCallbackServer: false` (Anthropic Claude
1408
- // Pro/Max). pi-ai invokes `onPrompt` with `{ message, placeholder?, allowEmpty? }`
1409
- // when it needs the user to paste a code from the browser.
1410
- onPrompt: async prompt => askUser(prompt.message, prompt.placeholder),
654
+ onPrompt: async prompt => askUser(prompt.message, prompt.placeholder), // manual-code providers
1411
655
  onProgress: msg => { /* show progress */ },
1412
656
  signal: abortController.signal,
1413
657
  })
@@ -1415,268 +659,138 @@ if (supportsOAuth(descriptor)) {
1415
659
  }
1416
660
  ```
1417
661
 
1418
- `runOAuthLogin` calls `tryOpenBrowser(url)` automatically; the user can also click the URL surfaced via `onUrl`. `oauthUsesManualCodePaste(descriptor)` is a convenience that returns `true` when the underlying pi-ai provider has `usesCallbackServer: false` — UIs can use it to swap their copy from "waiting for browser callback" to "paste the code from your browser below" before the prompt actually arrives.
1419
-
1420
- **SSH paste-back.** When the user runs zidane over SSH (or behind a firewall that blocks loopback), the browser-side redirect to `http://127.0.0.1:<port>/callback?...` can't reach the remote callback server. `fetchOAuthRedirect(pastedUrl)` is the escape hatch: hand it the URL the browser ended up at and it fires a GET from inside the agent process. The request hits pi-ai's still-running server through the same handler a real browser would have, `waitForCode` resolves, and `runOAuthLogin` continues uninterrupted — no `onPrompt` call needed. The TUI wires this into a paste input next to the auth URL on every OAuth surface (AI providers + MCP).
1421
-
1422
- Pass the authorization URL along (`fetchOAuthRedirect(pasted, { authUrl })`) and partial pastes work too: `normalizeRedirectPaste(pasted, authUrl)` completes a bare `code`, a `?code=…&state=…` query, or a `/callback?…` path against the `redirect_uri` the request was minted with (`redirectUriFromAuthUrl`), reusing the `state` from the authorization URL when the paste has none. Absolute URLs are passed through untouched so a mistaken non-loopback paste still hits the SSRF guard instead of being rewritten into a request against our own callback server.
662
+ `runOAuthLogin` calls `tryOpenBrowser(url)` automatically. `oauthUsesManualCodePaste(descriptor)` flags providers with `usesCallbackServer: false` so a UI can swap its copy to "paste the code" up front. **SSH paste-back:** when the loopback callback is unreachable (SSH, firewalled), `fetchOAuthRedirect(pastedUrl)` fires the callback GET from inside the agent process instead. `normalizeRedirectPaste(pasted, authUrl)` completes a bare code or partial paste against the `redirect_uri` minted for the request; an absolute non-loopback paste passes through untouched into the SSRF guard rather than being rewritten.
1423
663
 
1424
664
  ### MCP-server OAuth
1425
665
 
1426
- Streamable-HTTP and SSE MCP servers can require OAuth 2.1 (Linear, Sentry, the Anthropic-hosted catalog). Wired through the SDK's `OAuthClientProvider` interface — `zidane/chat` provides the persistence half (`McpCredentialStore` + `createFileMcpCredentialStore`), `zidane/mcp` provides the SDK-protocol half (`McpOAuthProvider`).
1427
-
1428
- Three promotion paths to OAuth:
1429
-
1430
- 1. **Explicit** — `auth: 'oauth'` on the server config.
1431
- 2. **Cursor alias** — `authMethod: 'mcpOAuth'` (the runtime-state field Cursor writes) is normalized to `auth: 'oauth'` at parse time, so a pasted `~/.cursor/mcp.json` Just Works.
1432
- 3. **Auto-detect** — a server returning `UnauthorizedError` (i.e. 401 + RFC 9728 metadata) is auto-promoted, **but only when** the user has NOT supplied a static `Authorization` header.
1433
-
1434
- Bootstrap behavior with `auth: 'oauth'`:
666
+ Streamable-HTTP/SSE MCP servers needing OAuth 2.1 (Linear, Sentry, the Anthropic-hosted catalog). `zidane/chat` provides persistence (`McpCredentialStore`, `createFileMcpCredentialStore`); `zidane/mcp` provides the SDK-protocol half (`McpOAuthProvider`).
1435
667
 
1436
- - **Tokens stored** → `McpOAuthProvider` is built with `redirectUri: undefined` (non-interactive). The SDK uses stored tokens, refreshes on expiry. Normal connect.
1437
- - **No tokens** → `client.connect()` throws `UnauthorizedError`. The bootstrap converts this into a `mcp:auth:required` event and skips the server (other servers continue). The GUI surfaces a `needs login` badge.
668
+ Promotion to OAuth: explicit `auth: 'oauth'`, the Cursor `authMethod: 'mcpOAuth'` alias (normalized at parse time), or auto-detect on an `UnauthorizedError` (401 + RFC 9728 metadata) when no static `Authorization` header is set.
1438
669
 
1439
- Interactive login lives in `loginMcpServer` (from `zidane/mcp`):
1440
-
1441
- 1. Spawn a loopback callback server (`startOAuthCallback`) on a random `127.0.0.1` port.
1442
- 2. Build a fresh `McpOAuthProvider` with `redirectUri` pointing at the loopback.
1443
- 3. `client.connect()` triggers the SDK to call `redirectToAuthorization(url)` — opens the user's browser via `tryOpenBrowser`, fires the `mcp:auth:url` hook so the modal can render the URL.
1444
- 4. SDK throws `UnauthorizedError`. We wait on the callback for the `?code=...` redirect.
1445
- 5. `transport.finishAuth(code)` exchanges the code for tokens; `provider.saveTokens` persists.
1446
- 6. Reconnect with the new tokens; `client.listTools()` returns the catalog.
1447
- 7. The host rebuilds the active agent so the now-authenticated server connects on the next run.
670
+ With stored tokens, `client.connect()` uses/refreshes them non-interactively. With none, `connect()` throws `UnauthorizedError`, the bootstrap emits `mcp:auth:required` and skips the server (others continue). Interactive login (`loginMcpServer` from `zidane/mcp`): spawn a loopback callback server → build a fresh provider with that `redirectUri` → `connect()` triggers `redirectToAuthorization` (browser opens, `mcp:auth:url` fires) → wait for the callback code → `transport.finishAuth(code)` → `provider.saveTokens` → reconnect → rebuild the active agent so the now-authenticated server is used on the next run.
1448
671
 
1449
672
  ### MCP OAuth state
1450
673
 
1451
- Pure reducer + React context so the GUI's badge updates live as the bootstrap + login flows emit events.
674
+ Pure reducer + context so a badge updates live as bootstrap/login events fire:
1452
675
 
1453
676
  ```ts
1454
- type McpAuthStatus
1455
- = | { kind: 'idle' }
1456
- | { kind: 'needs-auth', reason: 'no-tokens' | 'auto-promoted' }
1457
- | { kind: 'authorizing', url?: string }
1458
- | { kind: 'authed' }
1459
- | { kind: 'error', error: string }
677
+ type McpAuthStatus =
678
+ | { kind: 'idle' } | { kind: 'needs-auth', reason: 'no-tokens' | 'auto-promoted' }
679
+ | { kind: 'authorizing', url?: string } | { kind: 'authed' } | { kind: 'error', error: string }
1460
680
  ```
1461
681
 
1462
- `McpAuthEvent` mirrors the agent hooks (`auth-required`, `auth-url`, `auth-success`, `auth-error`, `connected`) plus UI-only verbs (`login:start`, `login:cancel`, `logout`, `reset`).
1463
-
1464
- ```tsx
1465
- const state = useMcpAuthState()
1466
- const dispatch = useMcpAuthDispatch()
1467
-
1468
- agent.hooks.hook('mcp:auth:required', ({ name, reason }) => dispatch({ type: 'auth-required', name, reason }))
1469
- agent.hooks.hook('mcp:auth:url', ({ name, url }) => dispatch({ type: 'auth-url', name, url }))
1470
- agent.hooks.hook('mcp:auth:success', ({ name }) => dispatch({ type: 'auth-success', name }))
1471
- agent.hooks.hook('mcp:auth:error', ({ name, error }) => dispatch({ type: 'auth-error', name, error: error.message }))
1472
- agent.hooks.hook('mcp:connect', ({ name }) => {
1473
- if (mcpCredentialStore.load(name)?.tokens) dispatch({ type: 'auth-success', name })
1474
- else dispatch({ type: 'connected', name })
1475
- })
1476
- ```
1477
-
1478
- Two contexts split (`useMcpAuthState` reads; `useMcpAuthDispatch` writes) so dispatch consumers don't re-render on state churn. Mount `<McpAuthProvider>` **above** the modal layer so live updates propagate into open modals — the snapshot-at-open-time problem otherwise leaves badges stale.
1479
-
1480
- `getMcpAuthStatus(state, name)` treats absent entries as `idle` so the map stays sparse.
682
+ `useMcpAuthState()` / `useMcpAuthDispatch()` are split so dispatch consumers don't re-render on state churn. Mount `<McpAuthProvider>` above the modal layer. `getMcpAuthStatus(state, name)` treats an absent entry as `idle`.
1481
683
 
1482
684
  ## Cross-provider model catalog
1483
685
 
1484
- `buildModelCatalog`, `filterModelCatalog`, and `indexOfEntry` assemble a unified picker that spans every authed provider.
1485
-
1486
- ```ts
1487
- const catalog: CatalogEntry[] = buildModelCatalog(availableProviders, modelsFor, currentPick)
1488
- const filtered = filterModelCatalog(catalog, query)
1489
- const cursor = indexOfEntry(filtered, currentPick)
1490
- ```
1491
-
1492
- Each `CatalogEntry` carries the provider key, model id, display name, capability flags (`reasoning`, `input`), context window, and a search corpus.
1493
-
1494
- ### Models the catalog doesn't serve
1495
-
1496
- Persisted picks outlive catalogs: a provider retires an id, or the user names one the local list never had. Restoring such a pick is deliberate (an id typed by hand is legitimately off-catalog, and the bundled list often lags a provider's real lineup), but it must stay visible and selectable.
1497
-
1498
- - **`include`** — `buildModelCatalog({ providers, modelsFor, current, include })` guarantees a row for each `{ providerKey, modelId }` pair, synthesizing a stub `ModelInfo` flagged `offCatalog: true` when the provider doesn't serve it. Stubs lead their provider's section and join the search corpus. A provider that serves nothing still appears when `include` names it. Pass the active pick plus every fallback-chain entry: without a row, a stale chain entry can't be toggled off and re-persists on every close.
1499
- - **`parseCustomModelQuery(query, providers, fallbackProviderKey)`** — reads a query that matched nothing as "use this id anyway". A leading provider key or label is honored (`xai grok-9`, `OpenAI Codex gpt-9`, longest prefix wins) and stripped; otherwise the id is attributed to `fallbackProviderKey`, since a bare model id is meaningless without a provider. Returns `null` when nothing usable remains.
686
+ `buildModelCatalog`, `filterModelCatalog`, `indexOfEntry` assemble a picker spanning every authed provider; each `CatalogEntry` carries provider key, model id, display name, capability flags (`reasoning`, `input`), context window, and a search corpus.
1500
687
 
1501
- Renderers should print the `offCatalog` flag rather than the stub's zeroed metadata — the TUI picker shows `not in catalog` where it would otherwise show `ctx …`.
1502
-
1503
- For an effort picker on top, gate visibility on `modelSupportsReasoning(descriptor, modelId)` and offer the `ThinkingLevel` values: `'off' | 'minimal' | 'low' | 'medium' | 'high' | 'adaptive'` (adaptive is Anthropic-only). Persist the user's choice keyed by **model id** (not provider) under `TuiState.lastEffortByModel` so switching the same model across providers preserves intent.
688
+ - **`include`** guarantees a row for a persisted pick the current catalog doesn't serve (retired id, hand-typed id) — synthesizes a stub `ModelInfo` flagged `offCatalog: true`. Pass the active pick plus every fallback-chain entry, or a stale chain entry can't be toggled off.
689
+ - **`parseCustomModelQuery(query, providers, fallbackProviderKey)`** — reads an unmatched query as "use this id anyway"; a leading provider key/label is honored and stripped, otherwise the id is attributed to the fallback provider. `null` when nothing usable remains.
690
+ - Render `offCatalog` explicitly (e.g. `not in catalog`) rather than the stub's zeroed metadata.
691
+ - Effort picker: gate on `modelSupportsReasoning(descriptor, modelId)`; `ThinkingLevel` is `'off'|'minimal'|'low'|'medium'|'high'|'adaptive'` (adaptive is Anthropic-only). Persist by **model id**, not provider, so switching providers on the same model preserves intent.
1504
692
 
1505
693
  ## Effective context window
1506
694
 
1507
695
  ```ts
1508
- import { getContextWindow, effectiveContextWindow, OUTPUT_RESERVE_TOKENS } from 'zidane/chat'
1509
-
1510
- const raw = getContextWindow(descriptor, model) // 200_000 (Claude Sonnet, e.g.)
1511
- const eff = effectiveContextWindow(raw) // 180_000 = raw - OUTPUT_RESERVE_TOKENS
696
+ const raw = getContextWindow(descriptor, model) // e.g. 200_000
697
+ const eff = effectiveContextWindow(raw) // raw - OUTPUT_RESERVE_TOKENS
1512
698
  ```
1513
699
 
1514
- `OUTPUT_RESERVE_TOKENS = 20_000` matches Claude Code's `COMPACT_MAX_OUTPUT_TOKENS` — the reserved output budget every turn keeps in reserve. The footer indicator and the auto-compact trigger both key off `eff` so the two surfaces agree on "how full are we, really". `effectiveContextWindow(null) === null` — callers can pipe `getContextWindow` through unchanged.
700
+ `OUTPUT_RESERVE_TOKENS = 20_000` — matches Claude Code's `COMPACT_MAX_OUTPUT_TOKENS`, the output budget every turn keeps in reserve. The footer indicator and the auto-compact trigger both key off `eff` so the two surfaces agree. `effectiveContextWindow(null) === null`.
1515
701
 
1516
702
  ## Tool-result persistence
1517
703
 
1518
- The agent loop can replace oversize `tool_result` outputs with a `<persisted-output tool="…" bytes="…" path="…">` stub carrying a 2 KiB head preview. Imports from `zidane`:
704
+ The agent loop can replace an oversize `tool_result` with a `<persisted-output tool="…" bytes="…" path="…">` stub carrying a 2 KiB head preview.
1519
705
 
1520
- - `resolvePersistDir({ userDir, sessionId })` → `<userDir>/sessions/<sessionId>/tool-results/`. Always pin to the user dir.
706
+ - `resolvePersistDir({ userDir, sessionId })` → `<userDir>/sessions/<sessionId>/tool-results/`.
1521
707
  - `buildPersistedStub({ toolName, originalBytes, persistedPath, output })` → the stub string.
1522
- - `maybePersistToolResult(input)` → returns a tagged outcome: `persisted`, `skip`, or a bounded `error` stub when persistence was required but the blob write failed.
1523
- - `PERSISTED_STUB_PREFIX` (`'<persisted-output tool="'`) — recognize a stub for tail compaction / future stale-output elision.
1524
- - `PERSISTENCE_PREVIEW_BYTES = 2048` — bytes of head content included in the inline preview.
1525
- - `cleanupPersistedSession(persistDir)` — wipe on session delete.
1526
-
1527
- Toggle off via `behavior.persistThreshold = 0` (the loop's persistence branch short-circuits). `behavior.persistExcludeTools` (defaults to `DEFAULT_PERSIST_EXCLUDE_TOOLS`) lists tools that are never persisted regardless of size.
708
+ - `maybePersistToolResult(input)` → `persisted` / `skip` / a bounded `error` stub when the write failed.
709
+ - `PERSISTED_STUB_PREFIX` (`'<persisted-output tool="'`) recognizes a stub for tail-compaction or stale-output elision.
710
+ - `PERSISTENCE_PREVIEW_BYTES = 2048`. `cleanupPersistedSession(persistDir)` wipes on session delete.
1528
711
 
1529
- Persisted blobs already on disk stay readable via `read_file` on the stub's `path` attribute even after the toggle flips off — only **future** tool results are affected.
712
+ Toggle off via `behavior.persistThreshold = 0`; `behavior.persistExcludeTools` (default `DEFAULT_PERSIST_EXCLUDE_TOOLS`) exempts specific tools regardless of size. Blobs already on disk stay readable via the stub's `path` even after the toggle flips off.
1530
713
 
1531
714
  ## Compaction primitives
1532
715
 
1533
- `zidane` re-exports the full `zidane/compact` surface. A GUI's `/compact` action calls:
716
+ `zidane` re-exports the full `zidane/compact` surface:
1534
717
 
1535
718
  ```ts
1536
- import { compactConversation, summaryToTurn, buildPostCompactAttachments, selectFilesFromSession } from 'zidane'
1537
-
1538
719
  const result = await compactConversation({ provider, model, turns: session.turns, scope: 'tail', signal })
1539
720
  // → { summary, summarizedTurnIds, droppedDueToPtl, model, usage }
1540
721
 
1541
- const summaryTurn = summaryToTurn({
1542
- summary: result.summary,
1543
- replacesTurnIds: result.summarizedTurnIds,
1544
- model: result.model,
1545
- usage: result.usage,
1546
- })
1547
-
1548
- const restoration = await buildPostCompactAttachments({
1549
- recentFiles: selectFilesFromSession(session),
1550
- execution: agent.execution,
1551
- handle: agent.handle,
1552
- })
1553
-
722
+ const summaryTurn = summaryToTurn({ summary: result.summary, replacesTurnIds: result.summarizedTurnIds, model: result.model, usage: result.usage })
723
+ const restoration = await buildPostCompactAttachments({ recentFiles: selectFilesFromSession(session), execution: agent.execution, handle: agent.handle })
1554
724
  await session.appendTurns([summaryTurn, ...restoration.turns])
1555
725
  ```
1556
726
 
1557
- The marker turn carries a `compact-summary` content block; the agent loop scans for it during resume + before each provider call and elides every turn whose id appears in `replacesTurnIds` from the wire-level message list, replacing them with a single user-message containing the summary text. **Browsable history on disk stays intact** — the user can scroll above the boundary to see the original turns.
1558
-
1559
- Errors: `CompactInvalidInputError`, `CompactPromptTooLongError`. Helpers: `truncateHeadForPtlRetry`, `stripImagesFromTurns`, `sliceForCompaction`, `anchorPreviewFor`, `ANCHOR_PREVIEW_MAX_CHARS`. Prompt builders: `buildCompactPrompt`, `buildFromCompactPrompt`, `buildFullCompactPrompt`, `buildTailCompactPrompt`, `buildUpToCompactPrompt`, `BASE_INSTRUCTIONS`, `NO_TOOLS_PREAMBLE`, `TRAILER`. Restoration: `selectRecentFiles`, `selectFilesFromReadState`, `BYTES_PER_TOKEN`, `estimateTokens`, `utf8ByteLength`.
727
+ The marker turn carries a `compact-summary` content block; the loop scans for it during resume + before each provider call and elides every turn in `replacesTurnIds` from the wire-level message list. Browsable history on disk stays intact — the user can scroll above the boundary. Errors: `CompactInvalidInputError`, `CompactPromptTooLongError`. Also exported: `truncateHeadForPtlRetry`, `stripImagesFromTurns`, `sliceForCompaction`, `anchorPreviewFor`, prompt builders (`buildCompactPrompt`, `buildTailCompactPrompt`, etc.), and restoration helpers (`selectRecentFiles`, `selectFilesFromReadState`, `estimateTokens`).
1560
728
 
1561
729
  ## Session export
1562
730
 
1563
731
  ```ts
1564
- import { renderSession, writeSessionExport, resolveSessionExportTarget } from 'zidane/chat'
1565
-
1566
- // Render only (clipboard / network / S3):
1567
- const text = renderSession(sessionData, { format: 'markdown' }) // or 'json'
1568
-
1569
- // Render + write under `.{prefix}/sessions/`:
732
+ const text = renderSession(sessionData, { format: 'markdown' }) // or 'json' — render only
1570
733
  const target = await writeSessionExport({ session: sessionData, format: 'markdown', cwd, prefix })
1571
734
  // → { filepath, dir, anchor: 'project' | 'home' }
1572
735
  ```
1573
736
 
1574
- Anchor walks upward from `cwd` looking for `.git`; the first hit anchors to that repo's root (`project`). Outside a repo, the destination anchors to `home` (`~/.{prefix}/sessions/`). Use `resolveSessionExportTarget` to compute the path without writing.
737
+ Anchor walks upward from `cwd` looking for `.git`; the first hit anchors to that repo (`project`), otherwise `~/.{prefix}/sessions/` (`home`). `resolveSessionExportTarget` computes the path without writing.
1575
738
 
1576
- ## Title generation
739
+ ## Session artifacts
1577
740
 
1578
- ```ts
1579
- import { generateSessionTitle, cleanTitle } from 'zidane/chat'
741
+ `SessionStore` carries three **optional** methods — `upsertArtifact`, `listArtifacts`, `deleteArtifact` — and `SessionMetaRow` an optional `artifactCount`; a store that omits them degrades to empty artifact listings rather than throwing. `Session.addArtifact(input, origin)` / `Session.listArtifacts(filter?)` are the per-session convenience wrappers built on top. The `/artifacts` built-in command (`createBuiltinCommands` in `src/chat/builtin-commands.ts`, alongside `/help` and `/reload`) lists what a session or project produced, and the `#` completion trigger is shared between the chats provider (`completion-chats.ts`) and the artifacts provider (`completion-artifacts.ts`) — see **Completion**. Full schema, kinds, and the extension-facing `artifact:add` surface are owned by `docs/ARTIFACTS.md`.
1580
742
 
1581
- const title = await generateSessionTitle({ session: sessionData, provider, model, signal })
1582
- await store.save({ ...sessionData, metadata: { ...sessionData.metadata, title: cleanTitle(title) } })
1583
- ```
743
+ ## Commands
1584
744
 
1585
- Use `cleanTitle` to strip quotes / trailing punctuation / non-printable noise the model occasionally emits.
745
+ `chat/commands` is the shared slash-command runtime both built-ins and extension-contributed commands run through: `Command`, `CommandStep`/`CommandStepContext`, `parseCommandInvocation`, `executeCommand`, and `commandBlocksToContent`/`commandBlockToEvent` (render a `CommandOutputBlock` into persisted turn content or a live `StreamEvent`).
1586
746
 
1587
- ## Turn operations
747
+ `createBuiltinCommands(opts)` (`chat/builtin-commands`) returns the commands every surface ships regardless of extensions:
1588
748
 
1589
- ```ts
1590
- import { deleteTurnSafely, truncateTurnsAt, turnAsText, countNeighbors } from 'zidane/chat'
749
+ | Command | Requires | Does |
750
+ |---|---|---|
751
+ | `/help` | — | Lists `opts.getCommands()` (built-ins + extensions), read live so it stays accurate after `/reload`. |
752
+ | `/reload` | `opts.reloadExtensions` | Re-discovers, re-runs `setup`, tears down the old extension instances. Omitted from the list on hosts without one. |
753
+ | `/artifacts [project\|session]` | `opts.artifacts` | Lists what the session (or every session in the project) produced. A host with a modal (the TUI) opens it instead of emitting markdown. Omitted on hosts without a session store. |
1591
754
 
1592
- // Delete + clean up orphan tool blocks in neighbors:
1593
- const next = deleteTurnSafely(session.turns, turnId)
1594
- await store.save({ ...sessionData, turns: next, updatedAt: Date.now() })
755
+ `createCommandsCompletionProvider` (trigger `/`, shared with skills) suggests from the live merged command list. Authoring a new command — the full `Command` shape, `CommandInteraction`, `generateData`/`spawnAgents` — is documented in `docs/EXTENSIONS.md`; this module is the runtime that executes whatever a `Command` describes.
1595
756
 
1596
- // Fork: truncate at and including turnId:
1597
- const truncated = truncateTurnsAt(session.turns, turnId)
1598
- const forked = await store.create({ projectRoot, turns: truncated, ... })
757
+ ## Title generation
1599
758
 
1600
- // Render a turn's text for copy / preview:
1601
- const text = turnAsText(turn)
759
+ ```ts
760
+ const title = await generateSessionTitle({ session: sessionData, provider, model, signal })
761
+ await store.save({ ...sessionData, metadata: { ...sessionData.metadata, title: cleanTitle(title) } })
1602
762
  ```
1603
763
 
1604
- `deleteTurnSafely` is conservative about orphan `tool_use` / `tool_result` pairs — removing the assistant turn drops the matching `tool_result` from the next user turn, and vice versa, so the persisted history stays protocol-valid.
1605
-
1606
- ## Patterns
1607
-
1608
- **Read the active theme from a component** — `const { brand, model } = useColors()`. Component renders against the current palette and re-renders on theme switch automatically.
1609
-
1610
- **Add a settings row** — append to `SETTINGS_TOGGLES` (boolean) or `SETTINGS_CHOICES` (string/number-valued) and set `category: 'agent' | 'ui'`. Renderers built off the live tables pick it up without code changes.
1611
-
1612
- **Add a completion provider** — implement `CompletionProvider<TItem>`, pass the provider list to `useCompletion(state, providers)`. The popup picks it up automatically. Extend `theme.surfaces.chips[providerId]` for a per-kind pill color; otherwise the provider inherits `chips.default`.
1613
-
1614
- **Compose a custom agent profile** — assemble the system prompt from `agent-prompt` fragments (or write your own), bundle a `Preset` via `definePreset`, register under your own id in the `agents` map. Hosts mixing built-ins with their own typically spread `agents: { ...BUILTIN_AGENTS, debug: myDebugProfile }`.
1615
-
1616
- **Wire auto-compaction** — it's loop-native: pass `behavior: { autoCompact: chatAutoCompactBehavior({ enabled, threshold, rawContextWindow }) }` to `agent.run()` and paint feedback from the `compact:start` / `compact:end` hooks. No host-side trigger, in-flight ref, or pre-run barrier — the loop runs it mid-run + at end-of-run and cancels with the run's own signal.
1617
-
1618
- **Wire interactions** — mount `<InteractionsProvider>`, build tools via `createInteractionTools({ requestInteraction: makeRequestInteraction(useInteractionsActions()) })`, render the head of the queue. On session activation call `pendingInteractionsFromTurns` and collect responses into a single `Map`; once full call `buildResumedToolResultsTurn`. See `docs/INTERACTIONS.md`.
764
+ `cleanTitle` strips quotes, trailing punctuation, and non-printable noise the model occasionally emits.
1619
765
 
1620
- **Render an edit diff** — call `extractEditPayload` in `tool:before` (passing pre-write content for `write_file`), persist on the `'tool'` `StreamEvent.edit` field, render via `buildUnifiedDiff(payload)` + `filetypeFromPath(payload.path)` for syntax highlighting. Theme via `theme.surfaces.diff`. For a renderer-faithful preview (matches the tool body's lenient resolver, real file line numbers, per-hunk resolvability metadata), use `previewEditPayload(payload, priorContent, contextLines?)` and pass `result.diffText` to the diff renderable; consult `result.resolution[i].resolved` to decide whether to badge the hunk as unresolvable.
1621
-
1622
- **Wire per-edit approval** — in the `tool:gate` handler, call `extractEditPayload(name, input)`. If it returns a payload, route the decision through `resolveApprovalForPayload(decision, payload)`: on `shouldBlock`, emit `resolved.syntheticEvent` as a `'tool'` event (so the transcript shows the intended diff with denied badges), emit a paired `'tool-result'` event with body `[fully denied] ${buildEditOutcomesAnnotation(resolved.outcomes)}` for live display, and set `ctx.block = true`; on partial-accept, **rebind** `ctx.input` to a fresh shallow-clone whose `edits` is the approved subset and stash `resolved.outcomes` in a per-callId pending-annotation map. A paired `tool:transform` handler reads back the map and appends `buildEditOutcomesAnnotation(outcomes)` to `ctx.result`, so the wire / persisted history carries the per-hunk decisions for replay. Register the same handler on `child:tool:transform` for subagent-issued calls. Wipe the map on `agent:done` to catch paths where `tool:transform` never fires (validation reject, throwing `tool:before`). See **Per-edit approval** for the full gate handler skeleton.
1623
-
1624
- **Drive a model picker** — `buildModelCatalog(providers, modelsFor, currentPick)` + `filterModelCatalog(catalog, query)` + `indexOfEntry`. Gate an effort sub-picker on `modelSupportsReasoning(descriptor, modelId)`.
1625
-
1626
- **Detect resume credentials** — `detectAuth(dataDir, providers).filter(p => p.available)` returns providers the user can log into without re-running the wizard.
1627
-
1628
- **Render a transcript from a loaded session** — `eventsFromTurns(session.turns, session.runs)` + your own renderer iterating the events. The TUI ships `<Transcript events settings />` as one reference implementation.
1629
-
1630
- **Track context-window usage** — read `lastContextSizeFromTurns(turns, runs)` for the persisted last value; subscribe to `turn:after` to update live (`turnContextSize(usage)` for the cache-aware number). Display as a percentage of `effectiveContextWindow(getContextWindow(descriptor, model))`.
1631
-
1632
- **Render chip pills on submitted prompts** — call `splitPromptSegments(text, refs)` and map each `'chip'` segment to your platform's pill (HTML `<span>`, React component, etc.). Pull colors from `theme.surfaces.chips` via `resolveChipColor`.
766
+ ## Turn operations
1633
767
 
1634
- **Persist + restore the user's last picks** — `stateStore.save({ ...stateStore.load(), lastProvider, lastSessionId, lastModelByProvider, lastModelChain, lastEffortByModel, lastAgent, settings })`. Resume is automatic via `resolveConfig`'s `resumeProvider` + `initialPicked` (which restores the fallback chain too, via `restoreChainFallbacks`).
768
+ ```ts
769
+ const next = deleteTurnSafely(session.turns, turnId) // + clean up orphan tool blocks in neighbors
770
+ const truncated = truncateTurnsAt(session.turns, turnId) // fork: truncate at and including turnId
771
+ const text = turnAsText(turn) // render for copy/preview
772
+ ```
1635
773
 
1636
- See `src/tui/app.tsx` for the canonical wiring across every pattern above.
774
+ `deleteTurnSafely` is conservative about orphan `tool_use`/`tool_result` pairs — removing an assistant turn also drops the matching `tool_result` from the next user turn (and vice versa), so persisted history stays protocol-valid.
1637
775
 
1638
- ## IPC contracts (what the shared daemon relies on)
776
+ ## IPC contracts
1639
777
 
1640
- `zidane/ipc`'s chat host is a third consumer of this surface (alongside the
1641
- TUI and the Electron GUI main process). It leans on canonical pieces whose
1642
- shapes are therefore **wire contracts** — changing them changes the daemon
1643
- protocol:
778
+ `zidane/ipc`'s chat host is a third consumer of this surface (alongside the TUI and the Electron GUI main process), so several shapes here are **wire contracts** — changing them changes the daemon protocol:
1644
779
 
1645
- - **`StreamEvent` is the wire vocabulary.** Daemon `event` signals carry
1646
- canonical `StreamEvent`s verbatim; every window renders them with the
1647
- same components used for replay. New event kinds are automatically
1648
- transportable, but renamed/retyped fields are protocol breaks (bump
1649
- `IPC_PROTOCOL_VERSION`).
1650
- - **`eventsFromTurns` is the convergence primitive.** Windows replace their
1651
- transcript with its output at run end / reconnect / `sessions.touch`, so
1652
- live hook forwarding must stay event-for-event consistent with replay
1653
- (the host mirrors this file's mapping deliberately — spawn banners,
1654
- compact-summary cards, gate-blocked pairs).
1655
- - **Session `status` is the cross-process run marker.** The loop persists
1656
- `'running'` at run start and settles it at run end; both the daemon host
1657
- and the in-process TUI refuse to run a session whose store status says
1658
- another process is mid-run (5-minute freshness bound). Anything that
1659
- writes session documents must preserve this field's semantics.
1660
- - **Settings have one writer at a time.** With a daemon controller
1661
- connected, `state.json` settings writes go through the daemon
1662
- (`settings.set`, value-diffed so only runtime-shaping keys tear agents
1663
- down); otherwise the local store writes as before. Extending `Settings`
1664
- is additive-safe; repurposing keys is not.
1665
- - **HITL queue shapes travel.** `ApprovalRequest` (minus its live
1666
- `resolve`), `InteractionRequest`/`InteractionResponse`, and the
1667
- resumed-orphan helpers (`pendingInteractionsFromTurns`,
1668
- `buildResumedToolResultsTurn`) back the daemon's approval/interaction
1669
- hubs — the same crash-resume semantics documented in
1670
- `docs/INTERACTIONS.md`, held cross-process.
780
+ - **`StreamEvent`** is the wire vocabulary; daemon `event` signals carry it verbatim. New kinds are automatically transportable; renamed/retyped fields are protocol breaks (bump `IPC_PROTOCOL_VERSION`).
781
+ - **`eventsFromTurns`** is the convergence primitive — windows replace their transcript with its output at run end / reconnect, so live hook forwarding must stay event-for-event consistent with replay.
782
+ - **Session `status`** is the cross-process run marker (both the daemon host and an in-process TUI refuse to run a session another process is mid-run on, 5-minute freshness bound).
783
+ - **`Settings`** has one writer at a time — with a daemon connected, writes go through `settings.set`; extending `Settings` is additive-safe, repurposing keys is not.
784
+ - **HITL queue shapes** (`ApprovalRequest` minus its live `resolve`, `InteractionRequest`/`InteractionResponse`, `pendingInteractionsFromTurns`, `buildResumedToolResultsTurn`) back the daemon's approval/interaction hubs — same crash-resume semantics as `docs/INTERACTIONS.md`, held cross-process.
1671
785
 
1672
- See `docs/IPC.md` for the full signal union, method map, sync matrix, and
1673
- containment guarantees; `docs/TUI.md` → **Execution runtimes** for how the
1674
- TUI hosts both engines behind one UI.
786
+ See `docs/IPC.md` for the full signal union, method map, and sync matrix.
1675
787
 
1676
788
  ## Related
1677
789
 
1678
- - `docs/TUI.md` — terminal shell built on top of this surface.
1679
- - `docs/SKILL.md` — agent core (providers, presets, hooks, skills, MCP, sessions).
790
+ - `docs/TUI.md` — terminal shell built on top of this surface; global keybindings, modals, screens.
791
+ - `docs/SKILL.md` — agent core: providers, presets, hooks, skills, MCP.
1680
792
  - `docs/ARCHITECTURE.md` — agent loop, hook firing order, gate precedence.
1681
- - `docs/INTERACTIONS.md` — `present_plan` / `ask_user` protocol, resumed-flow contract.
793
+ - `docs/INTERACTIONS.md` — `present_plan`/`ask_user` protocol, resumed-flow contract.
1682
794
  - `docs/IPC.md` — shared chat host, daemon protocol, multi-window sync.
795
+ - `docs/ARTIFACTS.md` — session artifact schema, kinds, extension `artifact:add` surface.
796
+ - `docs/EXTENSIONS.md` — extension authoring, including custom `Command`s.