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.
- package/README.md +18 -9
- package/dist/{acp-CZpX91YE.js → acp-Bw2b_kka.js} +3 -3
- package/dist/{acp-CZpX91YE.js.map → acp-Bw2b_kka.js.map} +1 -1
- package/dist/acp-cli.js +4 -4
- package/dist/acp.d.ts +1 -1
- package/dist/acp.js +1 -1
- package/dist/{agent-B1jsFaDq.js → agent-B_uQo4f1.js} +649 -48
- package/dist/agent-B_uQo4f1.js.map +1 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/{anthropic-Da39cE-d.js → anthropic-QbHqqTkV.js} +2 -2
- package/dist/{anthropic-Da39cE-d.js.map → anthropic-QbHqqTkV.js.map} +1 -1
- package/dist/{approvals-S100xf1d.js → approvals-B9aIQJRQ.js} +287 -45
- package/dist/approvals-B9aIQJRQ.js.map +1 -0
- package/dist/artifacts-CV2hUKnL.js +112 -0
- package/dist/artifacts-CV2hUKnL.js.map +1 -0
- package/dist/{auth-DfEWnkC7.js → auth-plw6npda.js} +19 -7
- package/dist/auth-plw6npda.js.map +1 -0
- package/dist/chat/pure.d.ts +3 -3
- package/dist/chat/pure.js +1 -1
- package/dist/chat.d.ts +6 -6
- package/dist/chat.js +5 -5
- package/dist/contexts/daytona.d.ts +1 -1
- package/dist/contexts/e2b.d.ts +1 -1
- package/dist/daemon-cli.js +1 -1
- package/dist/eval.d.ts +1 -1
- package/dist/eval.js +1 -1
- package/dist/extensions.d.ts +2 -2
- package/dist/extensions.js +2 -2
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +2 -2
- package/dist/{index-DFGECPfm.d.ts → index-Dc_zYpEu.d.ts} +351 -2
- package/dist/index-Dc_zYpEu.d.ts.map +1 -0
- package/dist/{index-DP7xYXr9.d.ts → index-Df2CH6bn.d.ts} +92 -7
- package/dist/index-Df2CH6bn.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +7 -6
- package/dist/{ipc-BLLscoIl.js → ipc-T_pPnHkp.js} +2 -2
- package/dist/{ipc-BLLscoIl.js.map → ipc-T_pPnHkp.js.map} +1 -1
- package/dist/ipc.d.ts +54 -6
- package/dist/ipc.d.ts.map +1 -1
- package/dist/ipc.js +3 -3
- package/dist/mcp.d.ts +1 -1
- package/dist/{oauth-C4GvY_p1.js → oauth-EIBUuSDK.js} +2 -2
- package/dist/{oauth-C4GvY_p1.js.map → oauth-EIBUuSDK.js.map} +1 -1
- package/dist/output/stream-json.d.ts +1 -1
- package/dist/output/terminal.d.ts +1 -1
- package/dist/plugins.d.ts +1 -1
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +2 -2
- package/dist/providers/anthropic.d.ts +1 -1
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/arcee.d.ts +1 -1
- package/dist/providers/baseten.d.ts +1 -1
- package/dist/providers/cerebras.d.ts +1 -1
- package/dist/providers/local.d.ts +1 -1
- package/dist/providers/openai-compat.d.ts +1 -1
- package/dist/providers/openai.d.ts +1 -1
- package/dist/providers/openai.js +1 -1
- package/dist/providers/openrouter.d.ts +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers/xai.js +2 -2
- package/dist/{providers-BYgyPb15.js → providers-CoK1rZ1p.js} +2 -2
- package/dist/{providers-BYgyPb15.js.map → providers-CoK1rZ1p.js.map} +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +3 -3
- package/dist/restate.d.ts +1 -1
- package/dist/restate.js +1 -1
- package/dist/{server-C8gULJ0d.js → server-DXM8ZbGi.js} +64 -6
- package/dist/server-DXM8ZbGi.js.map +1 -0
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/session/sqlite.d.ts.map +1 -1
- package/dist/session/sqlite.js +117 -1
- package/dist/session/sqlite.js.map +1 -1
- package/dist/{session-K3icbPH9.js → session-DPZnncaQ.js} +60 -1
- package/dist/session-DPZnncaQ.js.map +1 -0
- package/dist/session.d.ts +2 -2
- package/dist/session.js +3 -2
- package/dist/skills.d.ts +1 -1
- package/dist/{tool-formatters-C-s_-u0v.d.ts → tool-formatters-BgdOYAy9.d.ts} +2 -2
- package/dist/{tool-formatters-C-s_-u0v.d.ts.map → tool-formatters-BgdOYAy9.d.ts.map} +1 -1
- package/dist/tools/fetch-url.d.ts +1 -1
- package/dist/tools/web-search.d.ts +1 -1
- package/dist/tools.d.ts +2 -2
- package/dist/tools.js +2 -2
- package/dist/{transcript-anchors-D1ToF-EW.d.ts → transcript-anchors-CtnwuQJM.d.ts} +3 -3
- package/dist/transcript-anchors-CtnwuQJM.d.ts.map +1 -0
- package/dist/tui.d.ts +3 -3
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +576 -102
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-CZLsNgw-.js → turn-operations-DkJ1R0p8.js} +4 -3
- package/dist/turn-operations-DkJ1R0p8.js.map +1 -0
- package/dist/{turn-operations-Bp5-BAki.d.ts → turn-operations-khAvr7KJ.d.ts} +5 -3
- package/dist/turn-operations-khAvr7KJ.d.ts.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/{xai-DILdFGzN.js → xai-CazjStOK.js} +3 -3
- package/dist/{xai-DILdFGzN.js.map → xai-CazjStOK.js.map} +1 -1
- package/docs/ACP.md +6 -0
- package/docs/ARCHITECTURE.md +179 -356
- package/docs/ARTIFACTS.md +130 -0
- package/docs/CHAT.md +426 -1312
- package/docs/EXECUTION_CONTEXT.md +46 -19
- package/docs/EXTENSIONS.md +234 -584
- package/docs/HEADLESS.md +5 -0
- package/docs/INTERACTIONS.md +21 -22
- package/docs/IPC.md +318 -335
- package/docs/PLUGINS.md +2 -0
- package/docs/README.md +57 -0
- package/docs/RESTATE.md +199 -491
- package/docs/RUN_IN_BACKGROUND.md +107 -558
- package/docs/SKILL.md +171 -238
- package/docs/TUI.md +213 -591
- package/package.json +1 -1
- package/dist/agent-B1jsFaDq.js.map +0 -1
- package/dist/approvals-S100xf1d.js.map +0 -1
- package/dist/auth-DfEWnkC7.js.map +0 -1
- package/dist/index-DFGECPfm.d.ts.map +0 -1
- package/dist/index-DP7xYXr9.d.ts.map +0 -1
- package/dist/server-C8gULJ0d.js.map +0 -1
- package/dist/session-K3icbPH9.js.map +0 -1
- package/dist/transcript-anchors-D1ToF-EW.d.ts.map +0 -1
- package/dist/turn-operations-Bp5-BAki.d.ts.map +0 -1
- 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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
| Package | Pulls | Runtime |
|
|
9
|
+
| Package | Adds | Runtime |
|
|
12
10
|
|---|---|---|
|
|
13
|
-
| `zidane` |
|
|
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 |
|
|
16
|
-
|
|
17
|
-
A non-Bun host (Electron
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
76
|
+
| Module(s) | Purpose |
|
|
116
77
|
|---|---|
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
119
|
-
| `agents
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
124
|
-
| `
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
137
|
-
| `
|
|
138
|
-
| `
|
|
139
|
-
| `
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
148
|
-
| `
|
|
149
|
-
| `
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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`
|
|
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
|
-
|
|
134
|
+
## Environment variables
|
|
207
135
|
|
|
208
|
-
|
|
|
136
|
+
| Variable | Effect |
|
|
209
137
|
|---|---|
|
|
210
|
-
| `
|
|
211
|
-
| `
|
|
212
|
-
| `
|
|
213
|
-
| `
|
|
214
|
-
| `
|
|
215
|
-
| `
|
|
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
|
-
|
|
145
|
+
## Local provider
|
|
260
146
|
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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',
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
mediaKeepTurns: 8,
|
|
364
|
-
|
|
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,
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
`
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
528
|
-
type ChipColorMap = { default: ChipColor } & Partial<Record<string, ChipColor>>
|
|
286
|
+
const { brand, model } = useColors() // re-renders on theme switch
|
|
529
287
|
```
|
|
530
288
|
|
|
531
|
-
|
|
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
|
-
|
|
291
|
+
## Safe mode
|
|
583
292
|
|
|
584
|
-
`
|
|
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
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
`
|
|
309
|
+
`createTodoTools(opts)` returns the `todowrite`/`todoread` tool pair plus the live list a UI renders.
|
|
610
310
|
|
|
611
311
|
```ts
|
|
612
|
-
interface
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
-
|
|
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
|
-
`
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
785
|
-
-
|
|
786
|
-
-
|
|
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
|
-
|
|
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
|
-
-
|
|
797
|
-
-
|
|
798
|
-
- **
|
|
799
|
-
- **
|
|
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`
|
|
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
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
|
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
|
|
838
|
-
- **`buildContextSegments(input)`** (
|
|
839
|
-
- **Origins
|
|
840
|
-
- **`systemPath
|
|
841
|
-
- **`rulesBlock
|
|
842
|
-
- **Instruction files are host-supplied
|
|
843
|
-
- **Skill bodies are not
|
|
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
|
|
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 @@`
|
|
852
|
-
- **Renderer-faithful preview** — `previewEditPayload`. Routes each hunk through the same lenient resolver the tool body uses (curly-quote recovery, line-number-prefix stripping,
|
|
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
|
|
856
|
-
// payload
|
|
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
|
-
//
|
|
867
|
-
const lines: DiffLine[] = computeLineDiff(oldString, newString)
|
|
868
|
-
const
|
|
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
|
-
|
|
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
|
|
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)` |
|
|
956
|
-
| `maskToOutcomeKinds(mask, fallbackLength, deniedReason?)` |
|
|
957
|
-
| `buildEditOutcomesAnnotation(outcomes)` | Render
|
|
958
|
-
| `parseEditOutcomesFromResult(text)` | Re-parse the
|
|
959
|
-
| `mergeApprovalAndBodyOutcomes(approval, body)` | Fold
|
|
960
|
-
| `stripEditOutcomesAnnotation(text)` | Peel the first
|
|
961
|
-
| `rewriteMultiEditHeader(text, merged, path)` | Rewrite
|
|
962
|
-
| `summarizeOutcomes(outcomes)` | `{ applied, denied, skipped, failed, pending, total }` tally
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
1015
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1069
|
-
//
|
|
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
|
-
|
|
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'`
|
|
493
|
+
Three rendering modes for `'tool'` events, picked via `Settings.toolCallDisplay`:
|
|
1101
494
|
|
|
1102
|
-
| Mode |
|
|
495
|
+
| Mode | Renders | Use |
|
|
1103
496
|
|---|---|---|
|
|
1104
|
-
| `'hidden'` | nothing
|
|
1105
|
-
| `'formatted'` (default) | curated per-tool one-liner
|
|
1106
|
-
| `'full'` | `↳ <name>` header +
|
|
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
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
- **`
|
|
1145
|
-
-
|
|
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
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
579
|
+
A provider owns one trigger character + two operations:
|
|
1262
580
|
|
|
1263
581
|
```ts
|
|
1264
582
|
interface CompletionProvider<TItem> {
|
|
1265
|
-
id: string // 'skills' | 'files' | '
|
|
583
|
+
id: string // 'skills' | 'files' | 'commands' | 'chats' | 'artifacts'
|
|
1266
584
|
trigger: string // '/', '@', '#'
|
|
1267
|
-
label: string
|
|
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
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
1322
|
-
const optional = useDiscoveryOptional()
|
|
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
|
|
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
|
|
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
|
|
1333
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
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` / `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
674
|
+
Pure reducer + context so a badge updates live as bootstrap/login events fire:
|
|
1452
675
|
|
|
1453
676
|
```ts
|
|
1454
|
-
type McpAuthStatus
|
|
1455
|
-
|
|
1456
|
-
|
|
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
|
-
`
|
|
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`,
|
|
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
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
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/`.
|
|
706
|
+
- `resolvePersistDir({ userDir, sessionId })` → `<userDir>/sessions/<sessionId>/tool-results/`.
|
|
1521
707
|
- `buildPersistedStub({ toolName, originalBytes, persistedPath, output })` → the stub string.
|
|
1522
|
-
- `maybePersistToolResult(input)` →
|
|
1523
|
-
- `PERSISTED_STUB_PREFIX` (`'<persisted-output tool="'`)
|
|
1524
|
-
- `PERSISTENCE_PREVIEW_BYTES = 2048`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
739
|
+
## Session artifacts
|
|
1577
740
|
|
|
1578
|
-
|
|
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
|
-
|
|
1582
|
-
await store.save({ ...sessionData, metadata: { ...sessionData.metadata, title: cleanTitle(title) } })
|
|
1583
|
-
```
|
|
743
|
+
## Commands
|
|
1584
744
|
|
|
1585
|
-
|
|
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
|
-
|
|
747
|
+
`createBuiltinCommands(opts)` (`chat/builtin-commands`) returns the commands every surface ships regardless of extensions:
|
|
1588
748
|
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1597
|
-
const truncated = truncateTurnsAt(session.turns, turnId)
|
|
1598
|
-
const forked = await store.create({ projectRoot, turns: truncated, ... })
|
|
757
|
+
## Title generation
|
|
1599
758
|
|
|
1600
|
-
|
|
1601
|
-
const
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|