zidane 7.0.15 → 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.
@@ -1,6 +1,6 @@
1
1
  # Zidane Extensions
2
2
 
3
- User-authored TypeScript / JavaScript modules that plug into a running zidane host without touching the upstream codebase. Drop `index.ts` into `.zidane/extensions/<name>/`, ship a small `setup(ctx)` function, and contribute tools, hooks, MCP servers, skills, themes, agent profiles, keybindings, runtime transforms, storage/session state, context-panel sections, and more.
3
+ User-authored TypeScript / JavaScript modules that plug into a running zidane host without touching the upstream codebase. Drop `index.ts` into `.zidane/extensions/<name>/`, ship a `setup(ctx)` function, and contribute tools, hooks, MCP servers, skills, themes, agent profiles, keybindings, runtime transforms, storage/session state, context-panel sections, and more.
4
4
 
5
5
  ```ts
6
6
  // .zidane/extensions/hello/index.ts
@@ -16,39 +16,34 @@ export default defineExtension({
16
16
  })
17
17
  ```
18
18
 
19
- Restart the TUI — that's it. The extension shows up in `Settings → Extensions` for on/off toggling and its keybindings (if any) in `Settings → Keybindings`.
19
+ Restart the TUI. The extension shows up in `Settings → Extensions` for on/off toggling and its keybindings (if any) in `Settings → Keybindings`.
20
20
 
21
- ## Why
22
-
23
- The existing surfaces (`mcps.json`, `SKILL.md`, `keybindings.json`) cover static configuration. Extensions cover the parts you can only express as code: a custom tool, an observability hook, a streaming UI panel, a slash-command provider hitting an internal API. Anyone can vibe-code one and try it without forking zidane.
21
+ Contents: [Discovery](#discovery) · [defineExtension API](#the-defineextension-api) · [The ctx surface](#the-ctx-surface) · [How it boots](#how-it-boots) · [Enabling / disabling](#enabling--disabling) · [Installing from GitHub / npm](#installing-from-github--npm) · [Collisions](#collisions--precedence) · [Hooks: parent vs subagent](#hooks-parent-vs-subagent) · [Authoring patterns](#authoring-patterns) · [Settings UI](#settings-ui) · [Limitations](#limitations--gotchas-v1) · [SDK surface](#sdk-surface) · [Preset vs extensions](#preset-vs-extensions) · [File layout](#file-layout-reference)
24
22
 
25
23
  ## Discovery
26
24
 
27
- Search order — first-found per `name` wins (same convention as skills + `mcps.json`):
25
+ First-found per `name` wins (same convention as skills + `mcps.json`):
28
26
 
29
27
  1. `{project}/.agents/extensions/<name>/`
30
28
  2. `{project}/.zidane/extensions/<name>/`
31
29
  3. `~/.agents/extensions/<name>/`
32
30
  4. `~/.<prefix>/extensions/<name>/` (e.g. `~/.zidane/extensions/<name>/`)
33
31
 
34
- `{project}` is the git root walked up from `cwd` (or `cwd` itself when not in a repo). Each `<name>/` directory must contain one of (probed in order): `index.ts`, `index.tsx`, `index.mjs`, `index.js`.
35
-
36
- Bun loads `.ts` / `.tsx` natively — both under `bun run` and in `bun build --compile` binaries. No transpiler dependency.
32
+ `{project}` is the git root walked up from `cwd` (or `cwd` itself outside a repo). Each `<name>/` directory must contain one of (probed in order): `index.ts`, `index.tsx`, `index.mjs`, `index.js`. Bun loads `.ts`/`.tsx` natively, in `bun run` and in `bun build --compile` binaries.
37
33
 
38
- Optional manifest metadata can live in either `<name>/extension.json` or the `zidane` field of `<name>/package.json`:
34
+ Optional manifest metadata lives in `<name>/extension.json` or the `zidane` field of `<name>/package.json`:
39
35
 
40
36
  ```json
41
37
  {
42
38
  "apiVersion": 1,
43
39
  "name": "hello",
44
40
  "displayName": "Hello Tools",
45
- "description": "Logs every tool call",
46
41
  "capabilities": ["hooks", "storage"],
47
42
  "permissions": ["storage:write"]
48
43
  }
49
44
  ```
50
45
 
51
- The loader rejects unsupported `apiVersion` values and rejects manifest/module name mismatches so the persisted extension identity stays stable.
46
+ The loader rejects unsupported `apiVersion` values and manifest/module name mismatches, so the persisted extension identity stays stable.
52
47
 
53
48
  ## The `defineExtension` API
54
49
 
@@ -58,26 +53,25 @@ import type { ExtensionDefinition } from 'zidane'
58
53
  interface ExtensionDefinition {
59
54
  name: string // stable, kebab-case-ish
60
55
  apiVersion?: 1 // omitted means v1
61
- displayName?: string // optional UI label
56
+ displayName?: string
62
57
  permissions?: readonly ExtensionPermission[] // descriptive, not a sandbox
63
58
  capabilities?: readonly ExtensionCapability[] // shown before setup runs
64
- version?: string // surfaced in Settings → Extensions
65
- description?: string // ditto
59
+ version?: string
60
+ description?: string
66
61
  setup: (ctx: ExtensionContext) => void | Promise<void>
67
62
  teardown?: () => void | Promise<void> // reserved (no hot-reload yet)
68
63
  }
69
64
  ```
70
65
 
71
- `setup(ctx)` runs once per registry build — boot, `/reload`, or a cwd change. All registration goes through `ctx.*` — registration calls after `setup` resolves are dropped with a `logger.warn` for the author (runtime affordances like `ctx.tui.*` and `submitPrompt` stay live for the extension's whole lifetime). The framework awaits async `setup` before considering the extension "loaded", so a slow registration blocks only that extension (others run in parallel).
66
+ `setup(ctx)` runs once per registry build — boot, `/reload`, or a cwd change. Registration calls after `setup` resolves are dropped with a `logger.warn` (runtime affordances like `ctx.tui.*` and `submitPrompt` stay live for the extension's whole lifetime). Async `setup` is awaited before the extension is "loaded"; extensions load in parallel, so one slow one only blocks itself.
72
67
 
73
- Throws inside `setup` are caught and surfaced on the extension's row in `Settings → Extensions` as an error message. The rest of boot continues — one bad extension can't brick the TUI.
68
+ Throws inside `setup` are caught and surfaced on the extension's row in `Settings → Extensions`. The rest of boot continues.
74
69
 
75
70
  ### Zero-import alternative
76
71
 
77
- Compiled `zidane-tui` binaries don't expose their bundled `node_modules/zidane` to dynamic imports outside the bundle. For binaries, drop the `import` entirely — the loader accepts a plain object too:
72
+ Compiled `zidane-tui` binaries don't expose their bundled `node_modules/zidane` to dynamic imports outside the bundle. For binaries, drop the `import` — the loader accepts a plain object:
78
73
 
79
74
  ```ts
80
- // .zidane/extensions/hello/index.ts — no runtime imports
81
75
  export default {
82
76
  name: 'hello',
83
77
  setup(ctx) {
@@ -86,16 +80,14 @@ export default {
86
80
  }
87
81
  ```
88
82
 
89
- For type-checking your `ctx` argument, use a **type-only** import (these get erased at runtime and don't need a resolver):
83
+ For type-checking `ctx`, use a **type-only** import (erased at runtime, no resolver needed):
90
84
 
91
85
  ```ts
92
86
  import type { ExtensionContext } from 'zidane'
93
87
 
94
88
  export default {
95
89
  name: 'hello',
96
- setup(ctx: ExtensionContext) {
97
- ctx.registerHook('tool:after', e => ctx.logger.info('ran', e))
98
- },
90
+ setup(ctx: ExtensionContext) { /* … */ },
99
91
  }
100
92
  ```
101
93
 
@@ -111,70 +103,44 @@ Three namespaces:
111
103
 
112
104
  ### Renderer-agnostic
113
105
 
114
- ```ts
115
- ctx.name // your extension name
116
- ctx.version // optional
117
- ctx.dir // absolute path to the extension folder (undefined for inline)
118
- ctx.scope // 'project' | 'user' | 'inline' | 'builtin'
119
- ctx.logger // Logger scoped to `extension:<name>`
120
- ctx.paths // cwd/projectRoot/dataDir/cacheDir/configDir/stateDir
121
- ctx.storage.path(scope, ...segments)
122
- ctx.session.get(key) / set(key, value) / delete(key)
123
- ctx.config.get(key)
124
-
125
- ctx.registerTool(name, toolDef)
126
- ctx.registerHook(event, handler)
127
- ctx.registerMcpServer(serverConfig)
128
- ctx.registerSkill(skillConfig)
129
- ctx.registerCompletionProvider(provider)
130
- ctx.registerInlineAutocompleteProvider(provider)
131
- ctx.registerCommand(command)
132
- ctx.registerTheme(theme)
133
- ctx.registerAgent(profile)
134
- ctx.registerProvider(descriptor)
135
- ctx.registerSystemPromptFragment(text)
136
- ctx.registerContextBreakdownSection(contributor)
137
- ctx.registerContextPanelSection(section)
138
- ctx.registerToolResultMetadata(registration)
139
- ctx.registerArtifactKind(registration)
140
- ctx.artifacts.list(query) / ctx.artifacts.find(kind, uri)
141
- ctx.registerRunTransform(transform)
142
- ctx.registerBehaviorTransform(transform)
143
- ctx.registerProviderMiddleware(middleware)
144
- ctx.registerToolCatalogTransform(transform)
145
- ctx.registerMcpConfigTransform(transform)
146
- ctx.registerSkillPolicy(policy)
147
- ctx.registerConfigCategory(category)
148
- ctx.registerConfigItem(categoryId?, item)
149
- ```
150
-
151
106
  | API | Use it for | Notes |
152
107
  |---|---|---|
153
- | `registerTool(name, ToolDef)` | Custom agent tools | Profile tools win on name collision; built-in interaction tools (`ask_user`, `present_plan`) always win. |
154
- | `registerHook(event, handler)` | Observability / policy on `AgentHooks` | Fires on the **parent** agent only — for subagent activity subscribe to `child:tool:before` etc. |
155
- | `registerMcpServer(McpServerConfig)` | Programmatic MCP server (no `mcps.json` edit) | Discovered `mcps.json` wins on `name` collision. |
156
- | `registerSkill(SkillConfig)` | Programmatic `defineSkill()` injection | Force-included in the user's `enabledSkills` allowlist so an existing allowlist doesn't silently hide the extension's skill. |
157
- | `registerCompletionProvider(provider)` | New `/` `@` `!` triggers in the prompt | One-character trigger; collides with built-ins (`/` `@` `#`) → ignored. |
158
- | `registerInlineAutocompleteProvider({ id, suggest })` | Ghost-text autocomplete in the prompt (no trigger — runs on every keystroke pause) | `suggest(ctx)` returns a suffix string, a candidate list (user rotates with up/down, Tab accepts), or null. Gated by the user's `inlineAutocomplete` setting (default off). Built-in path provider runs first; first non-empty answer wins; dedupe by `id`, first registration wins; 150 ms per-provider deadline. |
159
- | `registerCommand(Command)` | A `/<name>` workflow — ordered handler steps (or a bare `run`) that `emit` local output (full markdown, or collapsed diff-colorable `detail` blocks), call `ctx.runAgent` for interactive sub-runs, `ctx.generateData` for silent schema-pinned JSON (no turns, no transcript; takes a zod v4 schema — result type inferred — or a plain JSON Schema object; optional `model` pins a catalog model), or `ctx.spawnAgents?` for parallel ISOLATED child agents (own context windows, full tool set, per-task `model` pinning — rendered like `spawn`-tool children); `ctx.appendContext?` persists a compact model-visible context note; `ctx.session?` exposes a read-only live view of the surrounding session (`id`/`turns`/`runs`/`metadata`) and `ctx.interaction?` offers user interaction (`confirm` yes/no, `askUser` forms — selects support `multi: true` — `presentPlan` approval) — the optional surfaces are host-dependent, so guard for bare hosts | `emit` output is display-only (persisted, never sent to the model); `appendContext` is the sanctioned model-visible escape hatch. Names dedupe first-registration-wins. Extensions extend `/` here rather than via a raw completion provider. |
160
- | `registerTheme(Theme)` | New `Settings → Theme` option | The picker's option list folds extension themes in at render time; built-in theme ids (`default`, `catppuccin-mocha`, …) shadow extension ids on collision. |
161
- | `registerAgent(AgentProfile)` | A complete agent (preset = tools + system + behavior + hooks + mcpServers + skills) added to the registry. Appears in the `shift+tab` cycle alongside Build / Plan. | Built-in profiles (`build`, `plan`) win on id collision; host-supplied profiles win over extensions too. |
162
- | `registerProvider(ProviderDescriptor)` | New LLM provider in the auth + model pickers | Host-supplied providers (incl. `BUILTIN_PROVIDERS`) win on `key` collision. A `models` list on the descriptor REPLACES the built-in registry, so the picker shows only your provider's models. For an OAuth provider, `await registerCustomOAuthProvider(flow)` in `setup` — zidane's own OAuth registry (`src/providers/oauth-registry.ts`; pi-ai removed its global one in 0.80.8) resolves the flow by id on EVERY token resolve, not just on refresh. A gateway speaking a built-in wire format reuses its factory: `anthropic({ baseURL, authMode: 'bearer', credentialSource: { providerId: '<key>' }, capabilities })` gets bearer auth, credentials under your own key (with the shared lazy refresh + cross-process lock), and no Claude Code persona. `extensions/bolt` is a complete example: device-flow login + Anthropic-compatible proxy. |
163
- | `registerSystemPromptFragment(text)` | Append a paragraph to every system prompt | Joined with `\n\n` separators, appended to `ctx.system` via a `system:transform` hook re-attached on every agent rebuild (works for every profile — Build / Plan / host-supplied). |
164
- | `registerContextBreakdownSection(fn)` | Existing low-level context panel contributor | May return a section or array of sections; async contributors are supported. |
165
- | `registerContextPanelSection(section)` | Richer context panel section with `id`, `title`, static rows, and/or async `load` | Normalized into the same context panel model. `registerContextBreakdownSection` remains supported. |
166
- | `registerToolResultMetadata(registration)` | Interpret namespaced metadata attached to tool results | Can project metadata entries into context-panel rows. |
167
- | `registerArtifactKind({ kind, label, detect?, validateMeta?, render?, resolve? })` | Give meaning to a **session artifact kind** — a durable, typed pointer to something a session produced (PR, branch, commit, file, deployment, issue…). See [Session artifacts](#session-artifacts). | Kinds are a global namespace, first registration wins (built-ins `pull_request` / `branch` / `commit` / `file` go through the same API). `detect` rides `tool:transform` (and `child:tool:transform`) but can only APPEND to the reserved `metadata.artifacts` namespace — it never changes what the model sees. Unknown kinds always render via `label` + `uri`. |
168
- | `ctx.artifacts.list({ scope?, kind?, since?, limit? })` / `ctx.artifacts.find(kind, uri)` | Read the artifact store: `scope: 'session'` (default), `'project'` (every session under the active project root), `'all'` | Late-bound like `ctx.sessions`: `[]` when detached or the store lacks artifact support. Live updates: `registerHook('artifact:recorded', …)` (and `child:artifact:recorded` for subagents). |
169
- | `registerRunTransform(fn)` | Synchronously rewrite `agent.run()` options before precondition checks | Applied in extension order. Throws are logged under `ZIDANE_DEBUG` and ignored. |
170
- | `registerBehaviorTransform(fn)` | Synchronously rewrite resolved behavior (`maxTurns`, retry, budgets, etc.) | Run-level `behavior` options are re-applied after transforms so explicit caller choices win. |
171
- | `registerProviderMiddleware(fn)` | Wrap provider instances for tracing, fallback, metrics, or policy | Runs when a provider factory is called; returning `undefined` is treated as pass-through. |
172
- | `registerToolCatalogTransform(fn)` | Hide, rename, or replace tools before disclosure/execution | Runs for both pre-run context estimates and actual runs. |
173
- | `registerMcpConfigTransform(fn)` | Programmatically rewrite MCP server configs before connection | Discovered/user configs are merged first, then transforms run in extension order. |
174
- | `registerSkillPolicy(policy)` | Auto-activate a skill based on run context | `policy.when` may be async; failures are fail-open with debug logging. |
175
- | `registerConfigCategory` / `registerConfigItem` | Typed settings in Settings → Extensions | Items can declare `scope`, `requiresRestart`, and `onChange` metadata; `ctx.config.get` reads current values default-first. A `string` item can set `secret: true` — the row renders masked (last 4 kept) and the editor opens empty, so a token isn't shoulder-surfable. Presentation only: the value still lives in the settings bag, so it's for service tokens, not for anything belonging in the credential store. |
176
-
177
- `ctx.session` is extension-scoped, JSON-only, byte-bounded state attached to the active `Session`. It is available during agent runs and isolated by extension name. `ctx.storage.path()` returns a path under the host's data/cache/config/project root, clamped to `<scope>/extensions/<extension-name>/...` so `..` segments cannot escape the extension's storage directory.
108
+ | `name` / `version` / `dir` / `scope` / `logger` / `paths` / `signal` | Identity, a `Logger` scoped `extension:<name>`, cwd/projectRoot/dataDir/cacheDir/configDir/stateDir, and an `AbortSignal` that flips when setup times out or the registry disposes | `dir` is `undefined` for inline extensions |
109
+ | `storage.path(scope, ...segments)` | A path under the host's data/cache/config/project root | Clamped to `<scope>/extensions/<name>/...`; `..` segments can't escape it |
110
+ | `session.get(key)` / `set(key, value)` / `delete(key)` | Extension-scoped, JSON-only, byte-bounded state on the active `Session` | Isolated per extension name; live during agent runs |
111
+ | `config.get(key)` | Read this extension's own config values, default-first | Safe during `setup` (returns defaults) and in hooks/tools (live values) |
112
+ | `disabled` | `true` when this build only declares config schema for a disabled extension | Gate side-effecting setup work on `!ctx.disabled` |
113
+ | `registerTool(name, ToolDef)` | Custom agent tools | Profile tools win on name collision; built-in interaction tools (`ask_user`, `present_plan`) always win |
114
+ | `registerHook(event, handler)` | Observability / policy on `AgentHooks` | Fires on the **parent** agent only — subscribe to `child:tool:before` etc. for subagents |
115
+ | `registerMcpServer(McpServerConfig)` | Programmatic MCP server (no `mcps.json` edit) | Discovered `mcps.json` wins on `name` collision |
116
+ | `registerSkill(SkillConfig)` | Programmatic `defineSkill()` injection | Force-included in the user's `enabledSkills` allowlist |
117
+ | `loadAgentPlugin(dir \| options)` | Load an Agent Plugin (agent-plugins.org) — skills + MCP servers in one call | Must be awaited inside `setup`; malformed manifests come back as diagnostics on the result, never a thrown error |
118
+ | `registerCompletionProvider(provider)` | New `/` `@` `!` triggers in the prompt | One-character trigger; collides with built-ins (`/` `@` `#`) → ignored |
119
+ | `registerInlineAutocompleteProvider({ id, suggest })` | Ghost-text ahead of the cursor, no trigger | Gated by the user's `inlineAutocomplete` setting (off by default); first non-empty answer wins, 150 ms deadline |
120
+ | `registerCommand(Command)` | A `/<name>` workflow | Names dedupe first-registration-wins; see [Authoring patterns](#a-custom-slash-command) |
121
+ | `registerTheme(Theme)` | New `Settings → Theme` option | Built-in theme ids shadow extension ids on collision |
122
+ | `registerAgent(AgentProfile)` | A complete agent profile in the `shift+tab` cycle | Built-in ids (`build`, `plan`) win on collision |
123
+ | `registerSubagent(ExtensionSubagentDef)` | A named overlay the model selects via `spawn`'s `subagent_type` | Adds beat the host base on name collision |
124
+ | `registerSubagentOverride(name, fn)` | Edit an existing subagent (host- or extension-provided) by name | Applied after adds, so overrides win over both; `fn` returning `undefined` deletes the entry |
125
+ | `registerProvider(ProviderDescriptor)` | New LLM provider in the auth + model pickers | Host-supplied providers win on `key` collision; a `models` list REPLACES the built-in registry for that provider |
126
+ | `registerSystemPromptFragment(text)` | Append a paragraph to every system prompt | Joined with `\n\n`, re-attached on every agent rebuild |
127
+ | `registerContextBreakdownSection(fn)` | Existing low-level context panel contributor | May return one section or an array; async is supported |
128
+ | `registerContextPanelSection(section)` | Richer context panel section (`id`, `title`, rows, optional async `load`) | Normalized into the same model as above |
129
+ | `registerToolResultMetadata(registration)` | Interpret namespaced metadata attached to tool results | Can project entries into context-panel rows |
130
+ | `registerArtifactKind({ kind, label, detect?, validateMeta?, render?, resolve? })` | Give meaning to a session artifact kind | Global namespace, first registration wins. See [ARTIFACTS.md](./ARTIFACTS.md) |
131
+ | `artifacts.list(query)` / `artifacts.find(kind, uri)` | Read the artifact store | Late-bound like `sessions`: `[]` when detached. See [ARTIFACTS.md](./ARTIFACTS.md) |
132
+ | `registerRunTransform(fn)` | Rewrite `agent.run()` options before precondition checks | Synchronous, applied in extension order; throws logged under `ZIDANE_DEBUG` and ignored |
133
+ | `registerBehaviorTransform(fn)` | Rewrite resolved behavior (`maxTurns`, retry, budgets) | Run-level `behavior` options re-applied after, so explicit caller choices win |
134
+ | `registerProviderMiddleware(fn)` | Wrap provider instances (tracing, fallback, metrics, policy) | Runs per provider-factory call; `undefined` return = pass-through |
135
+ | `registerToolCatalogTransform(fn)` | Hide, rename, or replace tools before disclosure/execution | Runs for both context estimates and actual runs |
136
+ | `registerMcpConfigTransform(fn)` | Rewrite MCP server configs before connection | Runs after discovered/user configs are merged |
137
+ | `registerSkillPolicy(policy)` | Auto-activate a skill based on run context | `policy.when` may be async; failures are fail-open |
138
+ | `registerToolPolicy({ tools?, decide })` | Gate tool calls pre-execution: allow / `{ block: reason }` / `{ patch: input }` | Patches are re-validated against the tool's schema; earlier verdicts (built-in guards, approval flow) win |
139
+ | `registerToolResultTransform(fn)` | Chained middleware over finished tool results | Rides `tool:transform`; byte budgets still apply after |
140
+ | `registerContextTransform(fn)` | Rewrite wire messages per provider call | Sugar over `registerProviderMiddleware` |
141
+ | `registerPromptTransform(fn)` | Transform or consume a submitted prompt | Runs after `/` command interception, before skill-reference resolution |
142
+ | `registerConfigCategory(category)` / `registerConfigItem(categoryId?, item)` | Typed settings in `Settings → Extensions` | See [Settings UI](#settings-ui) |
143
+ | `bus?.emit(payload, opts)` / `bus?.onMessage(fn)` | Private cross-window message channel | Present only when the definition declares the `'ipc'` permission; payloads are JSON ≤ 64 KiB |
178
144
 
179
145
  ### TUI-specific
180
146
 
@@ -182,28 +148,24 @@ ctx.registerConfigItem(categoryId?, item)
182
148
  ctx.tui.registerKeybinding({
183
149
  id: 'my-ext.openPanel', // globally unique, namespace by extension
184
150
  default: 'ctrl+shift+j', // empty string = unbound by default
185
- label: 'Jokes', // shown in Settings → Keybindings
151
+ label: 'Jokes',
186
152
  description: 'Open the joke panel',
187
153
  onTrigger: dispatch => dispatch.openModal('jokes'),
188
154
  })
189
155
 
190
156
  ctx.tui.registerFooterHint({ key: '^J', label: 'jokes' })
191
-
192
- ctx.tui.registerModal({
193
- id: 'jokes',
194
- node: <JokesPanel />,
195
- })
196
-
197
- // HUD — fire-and-forget, callable for the extension's whole lifetime
198
- // (setup, hooks, commands, timers). No-ops on hosts without a UI.
199
- ctx.tui.notify('Indexed 42 files', { tone: 'info' }) // one-shot transcript line
200
- ctx.tui.setStatus('indexing… 42/117', { loader: true }) // single dim line at the transcript tail, with working glyph
201
- ctx.tui.setStatus('✓ indexed', { timeout: 3000 }) // transient — auto-clears after 3s
202
- ctx.tui.setBanner(['review pending: 3 files'], { timeout: 10_000 }) // block above the prompt, next to the todo indicator
203
- ctx.tui.setStatus(null) // clear (also wiped automatically on disposal)
204
-
205
- // Artifact detail pane in the /artifacts modal (row rendering stays on the
206
- // renderer-agnostic `registerArtifactKind({ render })`):
157
+ ctx.tui.registerModal({ id: 'jokes', node: <JokesPanel /> })
158
+
159
+ // HUD — fire-and-forget, callable for the extension's whole lifetime.
160
+ // No-ops on hosts without a UI.
161
+ ctx.tui.notify('Indexed 42 files', { tone: 'info' }) // one-shot transcript line
162
+ ctx.tui.setStatus('indexing… 42/117', { loader: true }) // dim line at the transcript tail
163
+ ctx.tui.setStatus('✓ indexed', { timeout: 3000 }) // transient — auto-clears
164
+ ctx.tui.setBanner(['review pending: 3 files'], { timeout: 10_000 }) // block above the prompt
165
+ ctx.tui.setStatus(null) // clear (also wiped on disposal)
166
+
167
+ // Artifact detail pane in the /artifacts modal (row rendering is on the
168
+ // renderer-agnostic registerArtifactKind({ render })):
207
169
  ctx.tui.registerArtifactRenderer({
208
170
  kind: 'my-ext.deployment',
209
171
  renderDetail: artifact => <DeploymentStatus url={artifact.uri} meta={artifact.meta} />,
@@ -213,9 +175,7 @@ ctx.tui.registerArtifactRenderer({
213
175
  ctx.tui.registerToolRenderer({
214
176
  tools: ['shell'], // omit = offer for every tool
215
177
  render: ({ text }) =>
216
- looksLikeTestOutput(text)
217
- ? <TestSummaryTable output={text} /> // replaces the default ┃ block
218
- : null, // null = fall through to default
178
+ looksLikeTestOutput(text) ? <TestSummaryTable output={text} /> : null, // null = default view
219
179
  })
220
180
 
221
181
  ctx.tui.registerRenderer('commit-graph', data => // auto-namespaced: '<ext>/commit-graph'
@@ -228,54 +188,11 @@ ctx.appendNote([{
228
188
  // command emit() takes the same block shape (full '<ext>/<id>' required there)
229
189
  ```
230
190
 
231
- **The rendering invariant — data stays data.** Nothing a renderer paints is
232
- ever persisted: tool renderers are views over the tool turns you already
233
- have (zero schema change), and `custom` blocks persist `{ renderer-id,
234
- JSON-safe data, fallback markdown }` — the fallback IS the persisted output,
235
- so a pre-custom host (or an old zidane) renders these turns natively.
236
- Resolution happens at render time against the live filtered registry:
237
- disable the extension in Settings and rows fall back instantly; `/reload`
238
- re-skins old rows with new renderer code; a throwing renderer is
239
- error-bounded per row (fallback + dim notice — the transcript survives).
240
- V1 boundary: components are display-only (no focus/keyboard inside
241
- transcript rows) and TUI-scoped like modals — the fallback is the
242
- headless/GUI story. Tool renderers re-skin result rows, they don't
243
- resurrect them: `showToolResults: false` hides custom views too — output
244
- that must stay visible unconditionally belongs in a `custom` block.
245
-
246
- **Async data (fetch in a renderer):** `render` itself is synchronous by
247
- design — but the node it returns mounts as a real React subtree, so async
248
- work happens the standard React way, inside your component:
191
+ **Rendering invariant — data stays data.** Nothing a renderer paints is persisted: tool renderers are views over tool turns you already have; `custom` blocks persist `{ renderer-id, JSON-safe data, fallback markdown }` — the fallback IS the persisted output, so a pre-custom host renders natively. Disabling the extension falls rows back instantly; `/reload` re-skins them; a throwing renderer is error-bounded per row. Components are display-only (no focus/keyboard inside transcript rows); `showToolResults: false` hides custom views too.
249
192
 
250
- ```tsx
251
- function PrStatus({ pr }: { pr: number }) {
252
- const [state, setState] = useState<'loading' | { title: string }>('loading')
253
- useEffect(() => {
254
- let cancelled = false
255
- fetch(`https://api.github.com/repos/o/r/pulls/${pr}`)
256
- .then(r => r.json())
257
- .then((data) => {
258
- if (!cancelled)
259
- setState({ title: data.title })
260
- })
261
- .catch(() => {}) // keep the loading state; the row must never throw upward
262
- return () => {
263
- cancelled = true
264
- }
265
- }, [pr])
266
- return <text>{state === 'loading' ? '⏳ loading…' : `#${pr} ${state.title}`}</text>
267
- }
193
+ **Async data:** `render` is synchronous, but the node it returns mounts as a real React subtree — `fetch`/`useEffect` inside your component like any React code.
268
194
 
269
- ctx.tui.registerRenderer('pr-status', data => <PrStatus pr={(data as { pr: number }).pr} />)
270
- ```
271
-
272
- You get loading states, re-render on arrival, and cancellation for free —
273
- an async `render` signature would re-implement all of that (per-row promise
274
- caches, race handling) for no added capability, so it's deliberately not
275
- offered.
276
-
277
- **Caching + viewport gating — the hooks** (from `zidane/tui`, importable in
278
- extensions thanks to host-module mapping):
195
+ **Caching + viewport gating** (`useRowVisible`, `useSessionState` from `zidane/tui`):
279
196
 
280
197
  ```tsx
281
198
  import { useRowVisible, useSessionState } from 'zidane/tui'
@@ -284,112 +201,58 @@ function PrStatus({ pr }: { pr: number }) {
284
201
  const visible = useRowVisible() // false until scrolled in (latched)
285
202
  const [data, setData] = useSessionState<Pr | null>('pr', null)
286
203
  useEffect(() => {
287
- if (!visible || data) return // off-screen OR cached → no fetch
288
- fetchPr(pr).then(setData) // setData persists into the session
204
+ if (!visible || data) return
205
+ fetchPr(pr).then(setData)
289
206
  }, [visible, data, pr])
290
207
  return <text>{data ? data.title : visible ? '⏳' : '·'}</text>
291
208
  }
292
209
  ```
293
210
 
294
- - `useSessionState(id, initial)` — durable per-ROW state, keyed
295
- `renderer:<localId>:<turnId>:<id>` in the session's extension-state bag
296
- (your own namespace — inspect or clear it via `ctx.session`). Reload the
297
- session and the row rehydrates instead of refetching. Values are
298
- JSON-safe by type; live rows share the persisted turn's id, so keys agree
299
- live and after reload.
300
- - `useRowVisible(margin?)` — `true` once the row has entered the viewport
301
- (±margin rows of warm-up), LATCHED — mechanically, not just semantically:
302
- once visible, the row UNSUBSCRIBES from the tracker, so scrolling it out
303
- and back in causes zero re-renders and zero effect re-runs (test-pinned).
304
- Re-run-on-revisit belongs to your own state keys. Reports `true`
305
- immediately when no visibility tracker exists (headless-ish hosts) — work
306
- is never gated behind a tracker that isn't there. One shared ~200ms poll
307
- for the whole transcript, no per-row timers, and scrolling doesn't
308
- re-render the transcript either (subscription store, not React state).
211
+ - `useSessionState(id, initial)` — durable per-ROW state, keyed `renderer:<localId>:<turnId>:<id>` in the session's extension-state bag. Reload and the row rehydrates instead of refetching.
212
+ - `useRowVisible(margin?)` — `true` once the row enters the viewport, LATCHED: once visible it unsubscribes from the tracker, so scrolling out and back causes zero re-renders. `true` immediately on hosts without a visibility tracker.
309
213
  - Both throw outside an extension-rendered row.
310
214
 
311
- **Module identity — ambient imports just work.** The TUI maps `react`,
312
- `react/jsx-runtime`, and the `@opentui/*` packages to its OWN live module
313
- instances before any extension loads (a Bun virtual-module plugin — see
314
- `extensions/host-modules.ts`), so `import { useState } from 'react'` in an
315
- extension always gets the reconciler's copy. Without this, bare imports
316
- resolve from the extension's directory — landing on nothing, or on a second
317
- react whose hooks throw "Invalid hook call". Don't bundle or depend on your
318
- own `react`/`@opentui/*`; import them bare and let the host supply them.
319
-
320
- The loader itself maps `zidane` and `zidane/providers` the same way (renderer-neutral,
321
- so headless / ACP hosts get it too) — `import { anthropic, registerCustomOAuthProvider }
322
- from 'zidane'` in an extension resolves to the host's LIVE modules, which matters for
323
- anything stateful: the provider factories, the credential store, and pi-ai's OAuth
324
- registry must be the host's copies, not a second instance loaded from your directory.
215
+ **Module identity.** The TUI maps `react`, `react/jsx-runtime`, and `@opentui/*` to its own live module instances before any extension loads (a Bun virtual-module plugin, see `src/extensions/host-modules.ts`), so `import { useState } from 'react'` gets the reconciler's copy — don't bundle your own. The loader maps `zidane` / `zidane/providers` the same way (renderer-neutral, headless/ACP hosts included), which matters for stateful singletons: provider factories, the credential store, the OAuth registry.
325
216
 
326
217
  ```tsx
327
-
328
218
  // Prompt + interception surfaces
329
219
  ctx.submitPrompt('run the tests', { delivery: 'followUp' }) // as-if-typed; 'auto' | 'steer' | 'followUp'
330
- ctx.registerToolPolicy({ // gate tool calls pre-execution
331
- tools: ['shell'],
332
- decide: call => String(call.input.command).includes('rm -rf')
333
- ? { block: 'dangerous' } // or { patch: input } — re-validated against the tool schema
334
- : 'allow',
335
- })
336
- ctx.registerToolResultTransform(({ tool, result }) => // chained result middleware
337
- tool === 'shell' ? { result: redact(result) } : undefined)
338
- ctx.registerContextTransform(messages => // rewrite wire messages per provider call
339
- messages.filter(keep))
340
- ctx.registerPromptTransform(prompt => // transform/consume submitted prompts
341
- prompt.startsWith('!q ') ? `Be brief: ${prompt.slice(3)}` : undefined)
342
- // Not a parallel pipeline: `registerToolPolicy` registers into the SAME
343
- // `tool:gate` hook bucket as the agent's built-in guards, and
344
- // `registerToolResultTransform` rides `tool:transform` — one mechanism,
345
- // one composition order. These are ergonomic (typed verdicts, schema
346
- // re-validation) layers over hooks, not a second interception system.
220
+
221
+ // Not a parallel pipeline: registerToolPolicy rides the SAME `tool:gate`
222
+ // hook bucket as the agent's built-in guards, and registerToolResultTransform
223
+ // rides `tool:transform` — one mechanism, one composition order.
347
224
 
348
225
  // Late-bound host controls (null/false/empty until the host attaches)
349
226
  ctx.runtime.model() // active model id
350
- ctx.runtime.models() // { provider: { key, name }, model: ModelInfo }[] across credentialed providers
227
+ ctx.runtime.models() // { provider: { key, name }, model: ModelInfo }[]
351
228
  ctx.runtime.setThinking('high')
352
229
  await ctx.sessions.list() // { id, title?, updatedAt? }[]
353
230
  await ctx.sessions.switch(id) // or ctx.sessions.create()
354
231
  ctx.appendNote([{ text: '### Indexed\n42 files' }]) // durable display-only transcript note
355
232
 
356
233
  // Shell + files through the SESSION'S execution context — work happens
357
- // wherever the agent's tools run (host, docker, remote sandbox), with the
358
- // same transport output caps and containment. Hand-rolled child_process
359
- // always runs on the HOST and silently targets the wrong filesystem under
360
- // sandboxed contexts — ctx.workspace is the sandbox-correct way.
234
+ // wherever the agent's tools run (host, docker, remote sandbox). Hand-rolled
235
+ // child_process always runs on the HOST and silently targets the wrong
236
+ // filesystem under sandboxed contexts.
361
237
  const { stdout, exitCode } = await ctx.workspace.exec('git status --porcelain')
362
238
  const pkg = await ctx.workspace.readFile('package.json')
363
239
  await ctx.workspace.writeFile('.cache/report.md', report)
364
240
  const size = await ctx.workspace.fileSize('big.log') // probe before whole-reads
365
- // Detached (no live session) ⇒ these REJECT rather than silently no-op
366
- // (spawn failures reject with their real cause). The guard is
367
- // side-effect-free — it never spawns a container just to answer:
368
- if (await ctx.workspace.available()) { /* … */ }
369
- // Calling before the session's first run spawns the execution handle on
370
- // demand — same lifecycle seam (and `execution:ready` hook) as agent.run().
241
+ if (await ctx.workspace.available()) { /* … */ } // detached ⇒ calls above REJECT, not no-op
371
242
  ```
372
243
 
373
- `/reload` (built-in command) hot-reloads the registry: re-discover, re-import
374
- each extension's module graph fresh (on-disk edits apply live), re-run `setup`,
375
- swap — the outgoing instance's teardowns run and its HUD paint is wiped
376
- (generation-guarded, so the fresh instance's paint survives the swap ordering).
377
- Dispose also retires the instance's HUD writer: late status/banner writes from
378
- teardown closures or leaked timers are inert (`notify` stays live for farewell
379
- lines). Toggling an extension off in Settings hides its paint and mutes its
380
- notifications immediately; toggling back on restores them.
244
+ `/reload` (built-in command) hot-reloads the registry: re-discover, re-import each extension's module graph fresh, re-run `setup`, swap — outgoing teardowns run and the outgoing HUD paint is wiped. Toggling an extension off in Settings hides its paint and mutes notifications immediately; toggling back on restores them.
381
245
 
382
- `onTrigger` receives an `ExtensionKeybindingDispatchContext` — `{ extensionName, openModal(id), closeModal() }`. Heavy work should hop to its own microtask; the dispatcher is synchronous.
246
+ `onTrigger` receives `{ extensionName, openModal(id), closeModal() }`. Heavy work should hop to its own microtask; the dispatcher is synchronous.
383
247
 
384
- The dispatcher iterates **built-ins first, then extensions** — so an extension declaring `default: 'ctrl+o'` collides with `openSettings` and never fires. The loader emits a `logger.warn` on detect-time collisions (built-in + cross-extension). The framework also **reserves `escape` and `ctrl+c`** — extensions can't bind those chords (the registration is dropped with a warning), so the user always has a working "abort / back" key.
248
+ The dispatcher iterates **built-ins first, then extensions** — an extension declaring `default: 'ctrl+o'` collides with `openSettings` and never fires (logged at detect time). The framework **reserves `escape` and `ctrl+c`** — extensions can't bind those chords (registration dropped with a warning).
385
249
 
386
- Users can rebind any extension keybinding from `~/.zidane/keybindings.json` by its `id`:
250
+ Users rebind any extension keybinding from `~/.zidane/keybindings.json` by its `id`:
387
251
 
388
252
  ```jsonc
389
- // ~/.zidane/keybindings.json
390
253
  {
391
254
  "openSettings": "ctrl+o",
392
- "my-ext.openPanel": "ctrl+shift+j" // ← extension binding override
255
+ "my-ext.openPanel": "ctrl+shift+j"
393
256
  }
394
257
  ```
395
258
 
@@ -401,7 +264,7 @@ Extension bindings appear in `Settings → Keybindings` under a trailing **Exten
401
264
  ctx.gui.registerPanel({ id, label, render })
402
265
  ```
403
266
 
404
- `ctx.gui.registerPanel` calls land in the registry, but GUI panels are not rendered yet. GUI parity that is wired today includes extension discovery/settings, extension summaries, completion providers, and completion submit hooks (`resolveReferences` / `onSubmit`) through IPC.
267
+ Calls land in the registry, but GUI panels aren't rendered yet. GUI parity wired today: extension discovery/settings, extension summaries, completion providers, and completion submit hooks (`resolveReferences` / `onSubmit`) through IPC.
405
268
 
406
269
  ## How it boots
407
270
 
@@ -423,9 +286,9 @@ flowchart TD
423
286
 
424
287
  Key invariants:
425
288
 
426
- 1. **State first.** `runTui` reads `state.json` before discovery so the registry knows the enable allowlist as it builds. The allowlist gates **contributions, not execution**: a disabled-but-trusted extension is still imported and its `setup()` still runs — but only to declare its config schema (so its settings are browsable before you enable it). It runs with `ctx.disabled === true` and with the runtime bridges (`tui.*`, `submitPrompt`, `runtime`, `sessions`, `workspace`) withheld as inert no-ops, and it classifies as `inactive` so none of its behavioral contributions reach the active surface. Extensions that do side-effecting setup work (network probes, spawning processes, warming caches) must gate it on `!ctx.disabled`.
289
+ 1. **State first.** `runTui` reads `state.json` before discovery so the registry knows the enable allowlist as it builds. The allowlist gates **contributions, not execution**: a disabled-but-trusted extension is still imported and its `setup()` still runs — but only to declare its config schema. It runs with `ctx.disabled === true` and the runtime bridges (`tui.*`, `submitPrompt`, `runtime`, `sessions`, `workspace`) withheld as inert no-ops. Gate side-effecting setup work on `!ctx.disabled`.
427
290
  2. **Setup-then-merge.** Each extension's contributions are captured per-extension on `LoadedExtension.contributions`. The host can re-merge any subset via `mergeContributions(active, inactive)` to flip toggles without re-running `setup`.
428
- 3. **Sync `resolveConfig`.** Setup can be async; `resolveConfig` can't. The host pre-builds the registry, then passes it via `ChatOptions.extensionRegistry`. `runTui` does this internally; SDK / GUI hosts do the same:
291
+ 3. **Sync `resolveConfig`.** Setup can be async; `resolveConfig` can't. The host pre-builds the registry, then passes it via `ChatOptions.extensionRegistry`:
429
292
 
430
293
  ```ts
431
294
  import {
@@ -442,24 +305,19 @@ Key invariants:
442
305
 
443
306
  ## Enabling / disabling
444
307
 
445
- Each extension shows up in `Settings → Extensions` with a checkbox. Toggling writes to `Settings.enabledExtensions` (semantics mirror `enabledSkills` / `enabledMcps`):
308
+ Each extension shows up in `Settings → Extensions` with a checkbox. Toggling writes `Settings.enabledExtensions` (semantics mirror `enabledSkills` / `enabledMcps`):
446
309
 
447
- - `undefined` (fresh install) — **every** discovered extension runs.
310
+ - `undefined` (fresh install) — every discovered extension runs.
448
311
  - `[]` — none run.
449
312
  - `[names]` — explicit allowlist.
450
313
 
451
- Disabled extensions:
452
- - Still run `setup()` at boot with `ctx.disabled === true` — schema-declaration only. Runtime bridges are withheld (`ctx.tui.*` / `ctx.submitPrompt` / `ctx.runtime` are inert), so an inactive extension can't paint status, inject prompts, or touch the session. Author-level side effects must be gated on `!ctx.disabled`.
453
- - Land on `registry.inactive` so the toggle UI still lists them, and expose their **config schema** (categories + items) so their settings render before you enable them.
454
- - Don't contribute tools / hooks / mcps / skills / themes / agents / providers / keybindings / footer hints / modals.
314
+ Disabled extensions still run `setup()` at boot with `ctx.disabled === true` (schema-declaration only, runtime bridges inert), land on `registry.inactive`, and expose their config schema so settings render before you enable them. They don't contribute tools/hooks/mcps/skills/themes/agents/providers/keybindings/footer hints/modals.
455
315
 
456
- **Toggle = live filter; `/reload` = full re-apply.** Toggling live-filters tools / hooks / mcpServers / skills / completion providers / keybindings / footer hints (re-applied at the next session activation) and hides the extension's HUD paint + notifications immediately — but `setup()` isn't re-run and agent profiles + providers + themes stay visible in their pickers. Run `/reload` (or restart) to fully apply a toggle: the registry rebuilds against the current allowlist, so disabled extensions re-run `setup()` in schema-only mode (`ctx.disabled`, no behavioral contributions) and their picker entries disappear. Picking an extension-contributed profile after toggling its extension off — without reloading — activates a half-functional state: the profile's own preset works, but the extension's hooks / tools are suppressed.
316
+ **Toggle = live filter; `/reload` = full re-apply.** Toggling live-filters tools/hooks/mcpServers/skills/completion providers/keybindings/footer hints and hides HUD paint immediately — but `setup()` isn't re-run and agent profiles/providers/themes stay visible in their pickers until `/reload` (or restart) rebuilds the registry against the current allowlist. Picking an extension-contributed profile after toggling its extension off — without reloading — activates a half-functional state: the profile's preset works, but the extension's hooks/tools are suppressed.
457
317
 
458
318
  ## Installing from GitHub / npm
459
319
 
460
- `Settings → Extensions → ctrl+g` fetches, validates, and installs an extension
461
- into the roots discovery already scans — then hot-reloads the registry, so
462
- it's live without a restart. Type a source into the inline input:
320
+ `Settings → Extensions → ctrl+g` fetches, validates, and installs an extension into the roots discovery already scans, then hot-reloads the registry — live, no restart. Type a source into the inline input:
463
321
 
464
322
  ```
465
323
  owner/repo # GitHub, single-extension repo
@@ -469,41 +327,22 @@ owner/repo#main:packages/ext # search a subdirectory (monorepos); pasted tr
469
327
  @scope/pkg@1.2.0 # npm (exact versions or dist-tags; ranges are not resolved)
470
328
  ```
471
329
 
472
- …then pick the scope with ←/→: **global** (`~/.zidane/extensions/` — always
473
- trusted, the default) or **this project** (`<project>/.zidane/extensions/`,
474
- which still requires project-extension trust to load — the panel warns when
475
- the project isn't trusted). Progress streams into the panel's status line.
476
- Bare `a/b` is GitHub (npm slash-names are always `@scope/…`), anything else
477
- is npm.
330
+ …then pick the scope with ←/→: **global** (`~/.zidane/extensions/`, always trusted) or **this project** (`<project>/.zidane/extensions/`, requires project-extension trust to load). Bare `a/b` is GitHub (npm slash-names are always `@scope/…`); anything else is npm.
478
331
 
479
332
  **Recognized package shapes** (same for both targets):
480
333
 
481
334
  - a root `index.{ts,tsx,mjs,js}` — single-extension repo/package
482
- - `extensions/<name>/index.*` directories — a collection; append the name to
483
- the source input (the error notice lists the candidates when you don't)
484
- - for monorepos, `:path/to/dir` (GitHub only) re-roots the search — both
485
- shapes above then apply to that directory; a pasted
486
- `github.com/owner/repo/tree/<ref>/<path>` URL means the same thing
487
-
488
- **Pipeline guarantees** (`installExtensionFromSpec` in the SDK — the GUI calls
489
- the identical core):
490
-
491
- - The module is imported and validated (definition shape, apiVersion,
492
- folder-safe name, manifest agreement) **in a staging dir before anything
493
- touches your roots** — a failure installs nothing. This executes the
494
- extension's top-level code once; your explicit install request is the consent.
495
- - The target folder is named from the *validated* `definition.name`, so
496
- discovery's folder-name invariant holds by construction.
497
- - Dependencies install with the first of bun/npm/pnpm/yarn on PATH, **never
498
- running lifecycle scripts** (bun's default; the others get `--ignore-scripts`).
499
- - An existing install of the same name is refused unless you confirm the
500
- overwrite. A `.install.json` provenance file (raw spec, resolved
501
- version/ref, exact commit SHA for git installs, scope, selected candidate,
502
- timestamp) is written — it's what powers updates.
503
-
504
- **Bundled extensions** — this repo ships installable packages under
505
- `extensions/`, which double as real-world examples of the pipeline (note the
506
- subdir syntax — paste either source into the `ctrl+g` input):
335
+ - `extensions/<name>/index.*` directories — a collection; append the name to the source input
336
+ - for monorepos, `:path/to/dir` (GitHub only) re-roots the search
337
+
338
+ **Pipeline guarantees** (`installExtensionFromSpec` — the GUI calls the identical core):
339
+
340
+ - The module is imported and validated (definition shape, apiVersion, folder-safe name, manifest agreement) **in a staging dir before anything touches your roots** — a failure installs nothing. This runs the extension's top-level code once; your explicit install request is the consent.
341
+ - The target folder is named from the *validated* `definition.name`.
342
+ - Dependencies install with the first of bun/npm/pnpm/yarn on PATH, **never running lifecycle scripts**.
343
+ - An existing install of the same name is refused unless you confirm the overwrite. A `.install.json` provenance file (raw spec, resolved version/ref, commit SHA, scope, timestamp) is written — it powers updates.
344
+
345
+ **Bundled extensions** — this repo ships installable packages under `extensions/` as real-world examples (paste either source into the `ctrl+g` input):
507
346
 
508
347
  ```
509
348
  Tahul/zidane:extensions/git-flow # /commit — agent-written commit messages + one-tap push
@@ -511,51 +350,31 @@ Tahul/zidane:extensions/headroom # compress large tool outputs via a local He
511
350
  Tahul/zidane:extensions/pxpipe # image tool outputs in-process + optional pxpipe proxy routing
512
351
  ```
513
352
 
514
- All mirror the packaging conventions to copy for
515
- your own extensions (`package.json` with `zidane-extension` keyword, entry
516
- `index.ts`, `zidane` as a peer).
517
-
518
- **Update / remove** live on the focused row of the same tab:
519
-
520
- - `ctrl+u` — update: first a cheap no-op probe — `git ls-remote` compares the
521
- recorded commit SHA (npm: one registry GET re-resolves the version); an
522
- unchanged remote reports "already up to date" without fetching or reloading
523
- anything. (The probe compares the remote against the *recorded* install,
524
- not your local files — to restore a hand-edited install, remove and
525
- reinstall.) Otherwise it re-resolves the recorded spec (`owner/repo` follows
526
- the branch, bare `pkg` follows `latest`; pinned refs/versions stay pinned)
527
- and replaces the install **in place** (same root, even a
528
- `.agents/extensions` one; an upstream rename removes the superseded
529
- folder), reporting the commit delta (`abc1234 → def5678`). Hand-authored
530
- extensions have no provenance — nothing to update from.
531
- - `ctrl+x` (twice — arm, then confirm; cursor moves disarm) — remove: deletes
532
- the extension's directory and drops it from the enable allowlist. Works on
533
- hand-authored extensions too; a containment guard refuses any directory
534
- that isn't a direct child of a recognized extensions root.
535
-
536
- Install, update, and remove all finish with the `/reload` hot-swap, and all
537
- are thin wrappers over `installExtensionFromSpec` /
538
- `updateInstalledExtension` / `removeInstalledExtension` in the SDK (the GUI
539
- calls the same functions).
353
+ All mirror the packaging conventions to copy for your own extensions (`package.json` with `zidane-extension` keyword, entry `index.ts`, `zidane` as a peer).
540
354
 
541
- ## Collisions + precedence
355
+ **Update / remove** on the focused row of the same tab:
542
356
 
543
- When extension contributions overlap with host or built-in surfaces:
357
+ - `ctrl+u` — update: a cheap no-op probe (`git ls-remote` / one npm registry GET) compares the recorded install; unchanged reports "already up to date". Otherwise re-resolves the recorded spec (`owner/repo` follows the branch, bare `pkg` follows `latest`; pinned refs/versions stay pinned) and replaces the install in place, reporting the commit delta. Hand-authored extensions have no provenance to update from.
358
+ - `ctrl+x` (twice — arm, then confirm) — remove: deletes the extension's directory and drops it from the enable allowlist. A containment guard refuses any directory that isn't a direct child of a recognized extensions root.
359
+
360
+ Install, update, and remove all finish with the `/reload` hot-swap.
361
+
362
+ ## Collisions + precedence
544
363
 
545
364
  | Surface | Collision rule |
546
365
  |---|---|
547
366
  | Tools | Profile tools win, then later extensions (load order). Interaction tools (`ask_user`, `present_plan`) always win. |
548
- | Themes | Built-in theme ids win. Cross-extension collisions: last write wins. |
367
+ | Themes | Built-in theme ids win. Cross-extension: last write wins. |
549
368
  | Agent profiles | Host-supplied profiles win on `id`. |
550
369
  | Providers | Host-supplied providers (incl. `BUILTIN_PROVIDERS`) win on `key`. |
551
370
  | MCP servers | Discovered `mcps.json` wins on `name`. |
552
371
  | Keybindings | Built-in actions match first; collisions logged at boot. |
553
372
  | Completion providers | First registered trigger wins per character. |
554
373
  | Skills | Concatenated catalog; per-name first registration wins. |
555
- | Runtime transforms / middleware | Applied in extension load order after registry merge; failures are logged only when `ZIDANE_DEBUG` is set. |
556
- | Storage/session keys | Namespaced by extension name; unsafe keys (`__proto__`, `prototype`, `constructor`) are rejected or ignored. |
374
+ | Runtime transforms / middleware | Applied in extension load order after registry merge; failures logged only under `ZIDANE_DEBUG`. |
375
+ | Storage/session keys | Namespaced by extension name; unsafe keys (`__proto__`, `prototype`, `constructor`) rejected or ignored. |
557
376
 
558
- The mental model: **extensions extend, they don't shadow.** The few cases where extensions *could* shadow (other extensions' tools, themes, keybinding ids) resolve by load order with a warning when relevant.
377
+ The mental model: **extensions extend, they don't shadow.** The few cases where extensions *could* shadow (other extensions' tools, themes, keybinding ids) resolve by load order with a warning.
559
378
 
560
379
  ## Hooks: parent vs subagent
561
380
 
@@ -564,8 +383,8 @@ The mental model: **extensions extend, they don't shadow.** The few cases where
564
383
  To observe subagent activity, subscribe to the `child:*` mirror events (`child:tool:before`, `child:tool:after`, `child:stream:text`, …). They fire on the parent's hook bus with `{ childId, depth }` stamped on the context.
565
384
 
566
385
  ```ts
567
- ctx.registerHook('tool:after', (e) => log('parent tool', e))
568
- ctx.registerHook('child:tool:after', (e) => log('subagent tool', e, 'depth=', e.depth))
386
+ ctx.registerHook('tool:after', e => log('parent tool', e))
387
+ ctx.registerHook('child:tool:after', e => log('subagent tool', e, 'depth=', e.depth))
569
388
  ```
570
389
 
571
390
  ## Authoring patterns
@@ -582,10 +401,7 @@ export default defineExtension({
582
401
  ctx.registerTool('jq_query', {
583
402
  name: 'jq_query',
584
403
  description: 'Run a jq filter against the given JSON.',
585
- schema: z.object({
586
- json: z.string(),
587
- filter: z.string(),
588
- }),
404
+ schema: z.object({ json: z.string(), filter: z.string() }),
589
405
  async execute({ json, filter }) {
590
406
  // ... shell out, call a JS jq, whatever
591
407
  return JSON.stringify(result, null, 2)
@@ -598,28 +414,19 @@ export default defineExtension({
598
414
  ### A theme
599
415
 
600
416
  ```ts
601
- import { defineExtension } from 'zidane'
602
-
603
- export default defineExtension({
604
- name: 'sunset',
605
- setup(ctx) {
606
- ctx.registerTheme({
607
- id: 'sunset',
608
- label: 'Sunset',
609
- colors: { brand: '#ff6b35', accent: '#f7c59f', /* … */ },
610
- select: { /* … */ },
611
- surfaces: { /* … */ },
612
- syntax: { /* … */ },
613
- })
614
- },
417
+ ctx.registerTheme({
418
+ id: 'sunset',
419
+ label: 'Sunset',
420
+ colors: { brand: '#ff6b35', accent: '#f7c59f' /* … */ },
421
+ select: { /* … */ },
422
+ surfaces: { /* … */ },
423
+ syntax: { /* … */ },
615
424
  })
616
425
  ```
617
426
 
618
- After restart: `Settings → Theme → Sunset`.
619
-
620
427
  ### A complete agent profile
621
428
 
622
- `registerAgent` adds a profile to the same registry the built-in Build / Plan ride. The `shift+tab` keybinding (`cycleAgent`) cycles through every registered profile in registration order — extension profiles show up in the rotation automatically, no other UI wiring needed.
429
+ `registerAgent` adds a profile to the same registry Build/Plan ride. `shift+tab` (`cycleAgent`) cycles every registered profile in registration order.
623
430
 
624
431
  ```ts
625
432
  import { defineExtension, definePreset } from 'zidane'
@@ -631,7 +438,7 @@ export default defineExtension({
631
438
  ctx.registerAgent({
632
439
  id: 'reviewer',
633
440
  label: 'Reviewer',
634
- description: 'Read-only code reviewer — explores + critiques, never writes.',
441
+ description: 'Read-only code reviewer.',
635
442
  accent: 'model',
636
443
  preset: definePreset({
637
444
  name: 'reviewer',
@@ -644,154 +451,74 @@ export default defineExtension({
644
451
  })
645
452
  ```
646
453
 
647
- After restart: `shift+tab` cycles `build → plan → reviewer → build → ...`. The footer agent badge ("Build" / "Plan" / "Reviewer") flips in lockstep.
648
-
649
- Notes:
650
-
651
- - Profile ids must be unique across the merged registry. Built-in ids (`build`, `plan`) shadow extension ids on collision — you can't override the built-ins from an extension.
652
- - The `preset` field is a full `Preset` (same shape `composePresets` accepts): `tools`, `system`, `behavior`, `hooks`, `mcpServers`, `skills`. Anything you'd put in a host-side `agents` registry works here too.
653
- - The picker modal (`AgentPickerModal`) is exported from `zidane/tui` for embedders who want an explicit picker UI — not wired by default, since the cycle is enough for most setups.
454
+ Profile ids must be unique; built-in ids (`build`, `plan`) shadow extension ids on collision. `preset` is a full `Preset` — `tools`, `system`, `behavior`, `hooks`, `mcpServers`, `skills`. The picker modal (`AgentPickerModal`) is exported from `zidane/tui` for embedders who want an explicit UI.
654
455
 
655
456
  ### An MCP server
656
457
 
657
458
  ```ts
658
- export default defineExtension({
659
- name: 'linear-bundled',
660
- setup(ctx) {
661
- ctx.registerMcpServer({
662
- name: 'linear',
663
- transport: 'stdio',
664
- command: 'npx',
665
- args: ['-y', 'linear-mcp'],
666
- })
667
- },
459
+ ctx.registerMcpServer({
460
+ name: 'linear',
461
+ transport: 'stdio',
462
+ command: 'npx',
463
+ args: ['-y', 'linear-mcp'],
668
464
  })
669
465
  ```
670
466
 
671
- Same effect as adding the entry to `mcps.json`, but the extension carries it alongside its docs / version / setup logic.
672
-
673
- ### Observability + logging
674
-
675
- ```ts
676
- export default defineExtension({
677
- name: 'audit-log',
678
- setup(ctx) {
679
- const file = openAuditLog()
680
- ctx.registerHook('tool:after', ({ name, callId }) => file.write(`${name}:${callId}\n`))
681
- ctx.registerHook('child:tool:after', ({ name, callId, depth }) => file.write(`[d${depth}] ${name}:${callId}\n`))
682
- ctx.registerHook('agent:done', ({ runId }) => file.flush())
683
- },
684
- })
685
- ```
467
+ Same effect as an `mcps.json` entry, but carried alongside the extension's docs/version/setup logic.
686
468
 
687
469
  ### A custom slash command
688
470
 
689
471
  ```ts
690
- export default defineExtension({
691
- name: 'jokes',
692
- setup(ctx) {
693
- ctx.registerCompletionProvider({
694
- id: 'jokes',
695
- trigger: '!',
696
- label: 'Jokes',
697
- async suggest(query) {
698
- const items = await fetchJokes(query)
699
- return items.map(j => ({
700
- id: j.id,
701
- label: j.title,
702
- description: j.summary,
703
- insertText: `!${j.title} `,
704
- data: j,
705
- }))
706
- },
707
- parseReferences(text) {
708
- // walk text, find every "!title" occurrence, return spans
709
- return []
710
- },
711
- })
472
+ ctx.registerCommand({
473
+ name: 'deploy',
474
+ run: async (c) => {
475
+ const rows = await ctx.artifacts.list({ scope: 'project', kind: 'coolify.deployment' })
476
+ c.emit({ text: rows.map(r => `- [${r.label}](${r.uri})`).join('\n') || '_none_' })
712
477
  },
713
478
  })
714
479
  ```
715
480
 
716
- `resolveReferences` and `onSubmit` are optional submit hooks. The TUI and GUI both call them before dispatching a prompt, using the shared `resolveCompletionSubmit` core helper:
717
-
718
- ```ts
719
- ctx.registerCompletionProvider({
720
- id: 'tickets',
721
- trigger: '#',
722
- label: 'Tickets',
723
- async suggest(query, _ctx, signal) {
724
- return searchTickets(query, { signal })
725
- },
726
- parseReferences(text) {
727
- return findTicketRefs(text)
728
- },
729
- async resolveReferences(refs, _ctx, signal) {
730
- return Promise.all(refs.map(ref => hydrateTicketRef(ref, { signal })))
731
- },
732
- async onSubmit({ references }) {
733
- await rememberSubmittedTickets(references)
734
- },
735
- })
736
- ```
481
+ `emit` output is display-only (persisted, never sent to the model). A command may also call `ctx.runAgent` for interactive sub-runs, `ctx.generateData` for silent schema-pinned JSON, `ctx.spawnAgents?` for parallel isolated child agents, and `ctx.appendContext?` to persist a model-visible note — the optional surfaces are host-dependent, so guard for bare hosts.
737
482
 
738
- ### Runtime transforms + session state
483
+ ### A completion provider
739
484
 
740
485
  ```ts
741
- export default defineExtension({
742
- name: 'cost-guard',
743
- setup(ctx) {
744
- ctx.registerRunTransform((options) => ({
745
- ...options,
746
- behavior: {
747
- ...options.behavior,
748
- maxCostUsd: Math.min(options.behavior?.maxCostUsd ?? 5, 1),
749
- },
750
- }))
751
-
752
- ctx.registerProviderMiddleware((provider) => ({
753
- ...provider,
754
- stream: async (options, callbacks) => {
755
- await ctx.session.set('lastModel', options.model)
756
- return provider.stream(options, callbacks)
757
- },
758
- }))
486
+ ctx.registerCompletionProvider({
487
+ id: 'jokes',
488
+ trigger: '!',
489
+ label: 'Jokes',
490
+ async suggest(query) {
491
+ const items = await fetchJokes(query)
492
+ return items.map(j => ({ id: j.id, label: j.title, insertText: `!${j.title} `, data: j }))
759
493
  },
494
+ parseReferences(text) { return [] }, // walk text, find every "!title" occurrence
760
495
  })
761
496
  ```
762
497
 
763
- Transforms are synchronous and fail-open. Use them for cheap composition decisions: budget caps, tool catalog shaping, provider wrappers, MCP config rewrites, or skill activation policy. Put I/O in provider middleware, hooks, completion submit hooks, or tools.
498
+ `resolveReferences` and `onSubmit` are optional submit hooks the TUI/GUI call before dispatching a prompt.
764
499
 
765
- ### Context panel sections + tool metadata
500
+ ### Runtime transforms + session state
766
501
 
767
502
  ```ts
768
- ctx.registerContextPanelSection({
769
- id: 'build-info',
770
- title: 'Build Info',
771
- rows: [
772
- { label: 'Branch', value: currentBranch },
773
- { label: 'CI', value: ciUrl },
774
- ],
775
- })
776
-
777
- ctx.registerToolResultMetadata({
778
- namespace: 'acme.trace',
779
- contextRows({ entries }) {
780
- return entries.map(({ value }) => ({
781
- label: 'Trace',
782
- value: String(value),
783
- }))
503
+ ctx.registerRunTransform(options => ({
504
+ ...options,
505
+ behavior: { ...options.behavior, maxCostUsd: Math.min(options.behavior?.maxCostUsd ?? 5, 1) },
506
+ }))
507
+
508
+ ctx.registerProviderMiddleware(provider => ({
509
+ ...provider,
510
+ stream: async (options, callbacks) => {
511
+ await ctx.session.set('lastModel', options.model)
512
+ return provider.stream(options, callbacks)
784
513
  },
785
- })
514
+ }))
786
515
  ```
787
516
 
788
- Tool-result metadata is namespaced. The registry does not validate `schema` yet; it records the declaration so hosts and future tooling can project or validate metadata consistently.
517
+ Transforms are synchronous and fail-open — use them for cheap composition decisions (budget caps, tool catalog shaping, provider wrappers). Put I/O in provider middleware, hooks, or tools.
789
518
 
790
519
  ### Session artifacts
791
520
 
792
- An **artifact** is a durable, typed pointer to something a session produced *outside* its transcript — a pull request, a branch, a commit, a created file, a deployment, an issue. The session stores the locator plus small metadata (`kind`, `uri`, `label`, `meta ≤ 16 KB`, `runId`/`turnId` for the transcript anchor, `projectRoot`); the content stays where it lives. Artifacts are queryable per session and per project (`/artifacts`, `/artifacts project`, ⌃⇧A), badge the sessions list, and show up in `#` completion (`#pr` → the PR URL).
793
-
794
- Core knows nothing about what a `pull_request` is. `kind` is an open string; an extension gives it meaning:
521
+ An extension gives meaning to an artifact `kind` — a durable, typed pointer to something a session produced (PR, branch, commit, file, deployment). Full model, storage, and surfaces: [ARTIFACTS.md](./ARTIFACTS.md).
795
522
 
796
523
  ```ts
797
524
  export default defineExtension({
@@ -800,40 +527,20 @@ export default defineExtension({
800
527
  ctx.registerArtifactKind({
801
528
  kind: 'coolify.deployment', // namespace by extension; [\w.:-]+
802
529
  label: 'Deployment',
803
- // Inspect finished tool results; append artifacts. Never mutates the result.
804
530
  detect: ({ tool, result, isError }) => {
805
531
  if (isError || tool !== 'mcp_Coolify_deploy') return
806
532
  const m = /deployment ([\w-]+)/.exec(String(result))
807
533
  return m && { uri: `https://coolify.example/deployments/${m[1]}`, label: m[1], meta: { id: m[1], state: 'queued' } }
808
534
  },
809
- validateMeta: meta => typeof meta === 'object' && meta !== null && 'id' in meta, // failing → meta dropped, artifact kept
810
535
  render: a => ({ title: a.label, href: a.uri, status: String((a.meta as { state?: string })?.state ?? '') }),
811
- resolve: async a => ({ meta: { ...(a.meta as object), state: await fetchState(a) } }), // lazy, on view; never polled
536
+ resolve: async a => ({ meta: { ...(a.meta as object), state: await fetchState(a) } }), // lazy, on view
812
537
  })
813
538
 
814
- // Optional TUI detail pane under the selected row.
815
539
  ctx.tui.registerArtifactRenderer({ kind: 'coolify.deployment', renderDetail: a => <DeployLogs id={a.uri} /> })
816
-
817
- // Read back: every deployment this project produced, across sessions.
818
- ctx.registerCommand({
819
- name: 'deployments',
820
- run: async (c) => {
821
- const rows = await ctx.artifacts.list({ scope: 'project', kind: 'coolify.deployment' })
822
- c.emit({ text: rows.map(r => `- [${r.label}](${r.uri})`).join('\n') || '_none_' })
823
- },
824
- })
825
540
  },
826
541
  })
827
542
  ```
828
543
 
829
- Producers, from most to least explicit:
830
-
831
- 1. **Tool-result metadata.** Any `tool:transform` hook may append to the reserved `metadata.artifacts` namespace (`SessionArtifactInput[]`); the loop lifts each entry into the store after the tool settles. `detect` is sugar over exactly this.
832
- 2. **Built-in detectors** (`artifactsPreset`, composed under every TUI / daemon profile): `gh pr create` / `glab mr create` / `*create_pull_request` → `pull_request`; `git commit` → `commit`; `git switch -c` / `checkout -b` / `push -u` → `branch`; `write_file` that *creates* → `file`. Keyed on the command or tool name, never on free text.
833
- 3. **`record_artifact` tool** — the model records what detection cannot see (a PR opened from a background shell task, a deployment URL). Same namespace, same bounds.
834
-
835
- Semantics worth knowing: identity is `(sessionId, kind, uri)` — re-emitting refreshes `label` / `meta` and keeps the original `id` / `runId` / `turnId`; ≤ 200 artifacts per session (files evicted first); a stored artifact whose kind has no registration (extension removed) still renders as `label` + `uri` — rows are never dropped. Subagent artifacts land on the shared parent session under the child's `runId`; observe them with `child:artifact:recorded`.
836
-
837
544
  ### A keybinding + modal
838
545
 
839
546
  ```tsx
@@ -841,11 +548,7 @@ import { defineExtension } from 'zidane'
841
548
  import { Modal } from 'zidane/tui'
842
549
 
843
550
  function JokesPanel() {
844
- return (
845
- <Modal title="jokes">
846
- <text>haha</text>
847
- </Modal>
848
- )
551
+ return <Modal title="jokes"><text>haha</text></Modal>
849
552
  }
850
553
 
851
554
  export default defineExtension({
@@ -864,57 +567,21 @@ export default defineExtension({
864
567
  })
865
568
  ```
866
569
 
867
- `node` also accepts a **loader thunk** — `() => import('./panel')` — invoked only when the modal first opens. The imported module `export default`s the node and zidane unwraps `{ default }` for you. This keeps the panel's UI (and the `react/jsx-runtime` edge JSX injects) off the import graph of headless / embedded hosts that load the extension but never render a TUI modal. See [Headless-safe UI](#headless-safe-ui).
868
-
869
- ```ts
870
- // index.ts — no UI imports, no JSX (keeps the entry renderer-neutral)
871
- import { defineExtension } from 'zidane'
872
-
873
- export default defineExtension({
874
- name: 'jokes-ui',
875
- setup(ctx) {
876
- ctx.tui.registerModal({ id: 'jokes-panel', node: () => import('./panel') })
877
- ctx.tui.registerKeybinding({
878
- id: 'jokes-ui.open',
879
- default: 'ctrl+shift+j',
880
- label: 'Jokes',
881
- description: 'Open the jokes panel',
882
- onTrigger: dispatch => dispatch.openModal('jokes-panel'),
883
- })
884
- },
885
- })
886
- ```
887
-
888
- ```tsx
889
- // panel.tsx — UI + JSX live here, imported lazily on first open
890
- import { Modal } from 'zidane/tui'
891
-
892
- export default (
893
- <Modal title="jokes">
894
- <text>haha</text>
895
- </Modal>
896
- )
897
- ```
570
+ `node` also accepts a **loader thunk** — `() => import('./panel')` — invoked only when the modal first opens, keeping the panel's UI (and the `react/jsx-runtime` import JSX injects) off the import graph of headless hosts. See [Headless-safe UI](#headless-safe-ui).
898
571
 
899
572
  Notes:
900
573
 
901
- - **Wrap your panel in `<Modal>` from `zidane/tui`.** It centers the panel, paints the bordered chrome, and installs the `esc` → close handler. The framework's `ModalRoot` also closes on `esc` as a baseline so a raw `<box>` still dismisses, but `<Modal>` is the canonical pattern.
902
- - **Fullscreen panels**: pass `fullscreen` (plus `horizontalMargin={0} verticalMargin={0}` for edge-to-edge) and the panel fills the terminal and tracks resizes live — the same layout the built-in Settings workbench uses. Sizing knobs (`maxWidth` / `minWidth` / `maxHeight`) are ignored under `fullscreen`; give overflowing content its own `<scrollbox>`.
903
-
904
- ```tsx
905
- <Modal title="dashboard" fullscreen horizontalMargin={0} verticalMargin={0}>
906
- <scrollbox style={{ flexGrow: 1 }}>{/* … */}</scrollbox>
907
- </Modal>
908
- ```
909
- - Set `<Modal disableEscape>` if the panel needs to swallow `esc` itself (e.g. cancel a pending confirmation). The framework's baseline esc handler honors the opt-out automatically.
910
- - TUI components need `@opentui/react` resolvable at runtime. For the bundled binary, ship the modal as a small renderless registration and rely on `ctx.tui.registerFooterHint` + `onTrigger` to call a shell command instead.
574
+ - **Wrap your panel in `<Modal>` from `zidane/tui`.** It centers the panel, paints the bordered chrome, and installs the `esc` → close handler.
575
+ - **Fullscreen panels**: pass `fullscreen` (plus `horizontalMargin={0} verticalMargin={0}`) and the panel fills the terminal and tracks resizes — the layout the Settings workbench uses. Sizing knobs (`maxWidth`/`minWidth`/`maxHeight`) are ignored under `fullscreen`.
576
+ - Set `<Modal disableEscape>` if the panel needs to swallow `esc` itself.
577
+ - TUI components need `@opentui/react` resolvable at runtime; for a bundled binary, prefer a renderless registration + `ctx.tui.registerFooterHint`/`onTrigger` calling a shell command.
911
578
 
912
579
  ## Settings UI
913
580
 
914
581
  Open Settings (`ctrl+o`), navigate to the **Extensions** tab (alongside Skills + MCPs):
915
582
 
916
583
  ```
917
- [ Agent ] [ UI ] [ Keybindings ] [ Authentication ] [ Skills ] [ MCP servers ] [ Extensions 2 / 3 ]
584
+ [ Agent ] [ UI ] [ Keybindings ] [ Authentication ] [ Skills ] [ MCP servers ] [ Extensions 1 / 2 ]
918
585
 
919
586
  search extensions — name, version, description…
920
587
 
@@ -922,25 +589,15 @@ Open Settings (`ctrl+o`), navigate to the **Extensions** tab (alongside Skills +
922
589
 
923
590
  ▶ [✓] hello v0.1.0
924
591
  Logs every tool call · [project: ~/proj/.zidane/extensions/hello]
925
- [✓] jq v0.2.0
926
- Run jq filters · [user: ~/.zidane/extensions/jq]
927
592
  [ ] notes
928
593
  Personal note-taking integration · [user: ~/.zidane/extensions/notes]
929
594
 
930
595
  ←→ tabs · ↑↓ navigate · ↵ toggle · esc close
931
596
  ```
932
597
 
933
- Failed extensions render at the top as a red preamble warning so authoring mistakes surface without hiding the working catalog. Disabled-no-error extensions stay in the toggle list (so the user can flip them back on). The `[source: path]` suffix shows where each extension lives on disk.
934
-
935
- Keybindings show up in `Settings → Keybindings` under an **Extensions** section:
936
-
937
- ```
938
- Extensions
939
- ctrl+shift+j Jokes
940
- Open the jokes panel
941
- ```
598
+ Failed extensions render at the top as a red preamble warning. Disabled-no-error extensions stay in the toggle list. The `[source: path]` suffix shows where each extension lives on disk.
942
599
 
943
- Config items can declare lifecycle metadata:
600
+ Config items declare lifecycle metadata:
944
601
 
945
602
  ```ts
946
603
  ctx.registerConfigItem('limits', {
@@ -954,27 +611,21 @@ ctx.registerConfigItem('limits', {
954
611
  })
955
612
  ```
956
613
 
957
- `scope` is currently host metadata; existing stores still persist values in the per-extension settings bag unless a host implements scoped writes. `onChange` is likewise advisory for hosts/UIs (`'live'`, `'agent-rebuild'`, or `'restart'`). `requiresRestart` remains accepted as a compatibility hint.
958
-
959
- Item types: `boolean` (toggle), `number` (input with `min`/`max`/`step`), `string` (input, optional `pattern`/`multiline`), `enum` (cycling choice over static `options`), `model` — an inline searchable picker over the host's credentialed model catalog (the ctrl+m interaction: type to filter, `↵` picks; with `multi: true`, `↵` toggles and `esc` applies) — and `action` (imperative row, below). `model` persists a plain id — or a comma-separated id list when `multi` — and degrades to the free string input on hosts without a catalog, so read it with a tolerant parser and validate ids against `ctx.runtime.models()` yourself.
614
+ `scope` and `onChange` (`'live'` / `'agent-rebuild'` / `'restart'`) are advisory host metadata. Item types: `boolean`, `number` (`min`/`max`/`step`), `string` (`pattern`/`multiline`, or `secret: true` for a masked row + empty-on-open editor), `enum` (cycling over static `options`), `model` (searchable picker over the credentialed model catalog; `multi: true` toggles a comma-separated id list), and `action` (below).
960
615
 
961
616
  ### Config actions (`type: 'action'`)
962
617
 
963
- An `action` item renders as a **press ↵ to run** row instead of a value editor — use it for imperative setup steps that belong next to the settings they configure (start a service, log in, run a migration):
618
+ Renders as a **press ↵ to run** row for imperative setup steps (start a service, log in, run a migration):
964
619
 
965
620
  ```ts
966
621
  ctx.registerConfigItem('headroom', {
967
622
  type: 'action',
968
623
  key: 'runContainer',
969
624
  label: 'Run container',
970
- description: 'Start the Headroom proxy in Docker and set Base URL to it.',
971
625
  run: async () => {
972
626
  const baseUrl = await startMyService() // throw on failure — the message lands on the row
973
627
  return { message: `proxy running at ${baseUrl}`, config: { baseUrl } }
974
628
  },
975
- // Optional passive probe — no ↵ needed: called when the settings panel
976
- // surfaces the row (and re-called after any action settles), so the row
977
- // shows live state. Return undefined to stay quiet (e.g. tool missing).
978
629
  status: async () => {
979
630
  const url = await findMyService()
980
631
  return url ? { state: 'ok', message: `running at ${url}` } : { state: 'off', message: 'not running' }
@@ -982,55 +633,40 @@ ctx.registerConfigItem('headroom', {
982
633
  })
983
634
  ```
984
635
 
985
- The contract:
986
-
987
- - **Success** — resolve with `{ message?, config? }` (or nothing). `message` becomes the row's feedback line; `config` names THIS extension's config keys to persist. Actions can't write settings directly — the host validates each returned entry against the registered item's schema (`sanitizeExtensionConfigActionPatch`) and drops anything unknown or invalid, so a buggy action degrades instead of corrupting settings.
988
- - **Failure** — throw / reject; the error message renders on the row in the error color.
989
- - **Runtime** — the host serializes actions (one in flight per settings panel) but imposes no timeout: bound your own work (`AbortSignal.timeout`, CLI timeouts) or the row shows `running…` until you settle.
990
- - **No value** — action keys hold nothing: `ctx.config.get('runContainer')` is `undefined`, and action items are omitted from the GUI wire schema (`toWireConfigSchema`) because `run` can't cross IPC — today actions are TUI-only.
991
- - **Status probe** — the optional `status` callback is passive decoration: it must be fast (the host caps each probe at ~3s and drops late results), must not mutate anything, and a throw is treated as "no status". `state` drives the row color: `ok` (accent), `off` (mute), `error` (error).
992
-
993
- The bundled `headroom` extension uses this for a **Run container** / **Stop container** pair managing a `zidane-headroom` container (`ghcr.io/chopratejas/headroom`) on the host: run reuses/starts/creates the container published on an ephemeral loopback port and returns `config: { baseUrl }` so the proxy URL is re-synced on every run; its `status` probe shows `running at …` / `container stopped` when you open Settings; stop leaves `baseUrl` untouched so a later run picks up where you left off. Run also forwards any provider credentials present in the host environment (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, AWS / Google) into the container via `docker run -e KEY` (value read from the host env, never placed in argv), and warns in the result message when none are found — a proxy without credentials boots and answers `/stats` but fails open on compression.
636
+ - **Success** — resolve `{ message?, config? }`. `config` names this extension's own keys to persist; the host validates each entry against the registered item's schema and drops anything unknown.
637
+ - **Failure** — throw; the message renders in the error color.
638
+ - **Runtime** — the host serializes actions (one in flight per panel), no timeout — bound your own work.
639
+ - **Status probe** — optional, passive, capped at ~3s; a throw means "no status". `state`: `ok` / `off` / `error`.
640
+ - Action keys hold no value (`ctx.config.get('runContainer')` is `undefined`) and are omitted from the GUI wire schema — actions are TUI-only today.
994
641
 
995
642
  ## Limitations + gotchas (v1)
996
643
 
997
- - **Hot-reload via `/reload`.** Edit your extension, run `/reload` — discovery evicts the extension's module graph before importing, so the current on-disk code (deep imports included) re-evaluates; module-level state resets with it. `teardown` runs when the registry that ran `setup` is disposed (`/reload` swap, cwd change, process teardown) — it is **not** fired by an in-session enable/disable toggle, which live-filters until the next reload.
998
- - **No sandboxing.** Extensions run with the host's full Node access — same trust model as `SKILL.md` shell access. Don't load extensions from untrusted projects you wouldn't otherwise run.
999
- - **No cross-extension dependency declarations.** If extension A relies on extension B's tool, that's on the author to coordinate.
1000
- - **No compaction lifecycle provider yet.** Extensions can transform run behavior and inspect context/tool metadata, but `compactConversation` is still host-driven (TUI/GUI) rather than an extension lifecycle surface.
1001
- - **Eager modal `node`s load UI everywhere.** A modal registered with an eager `node: <Panel/>` builds its UI at `setup()`, so it joins the import graph of headless / embedded hosts too. Use the lazy `node: () => import('./panel')` form to keep TUI UI off non-TUI graphs — see [Headless-safe UI](#headless-safe-ui).
644
+ - **Hot-reload via `/reload`.** Discovery evicts the extension's module graph before importing, so on-disk edits apply live; module-level state resets with it. `teardown` runs on registry disposal (`/reload`, cwd change, process teardown) — **not** on an in-session enable/disable toggle.
645
+ - **No sandboxing.** Extensions run with the host's full Node access — same trust model as `SKILL.md` shell access.
646
+ - **No cross-extension dependency declarations.** Coordination between extensions is on the authors.
647
+ - **No compaction lifecycle provider yet.** `compactConversation` is still host-driven (TUI/GUI), not an extension surface.
648
+ - **Eager modal `node`s load UI everywhere.** A modal registered with `node: <Panel/>` builds its UI at `setup()`, joining the import graph of headless hosts too. Use the lazy `node: () => import('./panel')` form — see [Headless-safe UI](#headless-safe-ui).
1002
649
  - **Hooks don't bubble to subagents** — see [Hooks: parent vs subagent](#hooks-parent-vs-subagent).
1003
- - **Compiled binary import resolution.** `zidane-tui` binaries can't dynamically resolve `import { defineExtension } from 'zidane'` against the bundle. Use the zero-import alternative or type-only imports — see above.
1004
- - **Setup runs on the boot critical path.** Slow imports (heavy deps, network discovery) add latency to TUI startup. Imports run in parallel across extensions, but a single slow one still blocks `Promise.all`. Keep `setup` cheap.
650
+ - **Compiled binary import resolution.** `zidane-tui` binaries can't dynamically resolve `import { defineExtension } from 'zidane'`. Use the zero-import alternative or type-only imports.
651
+ - **Setup runs on the boot critical path.** Slow imports add latency to TUI startup; keep `setup` cheap.
1005
652
 
1006
653
  ## SDK surface
1007
654
 
1008
- For hosts (custom CLIs, GUI shells, scripts) that want to drive extensions without `runTui`:
655
+ For hosts (custom CLIs, GUI shells, scripts) driving extensions without `runTui`:
1009
656
 
1010
657
  ```ts
1011
658
  import {
1012
- defineExtension, // identity helper
1013
- discoverExtensions, // disk walk + import
1014
- inlineLoadedExtension, // wrap a host-supplied def
1015
- buildExtensionRegistry, // run setup, merge contributions
1016
- mergeContributions, // re-merge a filtered subset (toggle)
1017
- isExtensionDefinition, // structural type guard
1018
- defaultExtensionScanPaths,
1019
- scanExtensionDirectories,
1020
- EMPTY_EXTENSION_REGISTRY,
659
+ defineExtension, discoverExtensions, inlineLoadedExtension,
660
+ buildExtensionRegistry, mergeContributions, isExtensionDefinition,
661
+ defaultExtensionScanPaths, scanExtensionDirectories, EMPTY_EXTENSION_REGISTRY,
1021
662
  } from 'zidane'
1022
663
 
1023
664
  import {
1024
- // Live-host wiring (the TUI uses these internally; GUI shells do the same):
1025
- createExtensionHud, // ctx.tui.notify / setStatus / setBanner backing
1026
- createExtensionPromptBridge, // ctx.submitPrompt backing
1027
- createExtensionHostRuntimeBridge, // ctx.runtime / ctx.sessions / appendNote backing
665
+ // Live-host wiring (TUI uses these internally; GUI shells do the same):
666
+ createExtensionHud, createExtensionPromptBridge, createExtensionHostRuntimeBridge,
1028
667
  // Install / update / remove pipeline (what the Settings panel calls):
1029
- installExtensionFromSpec,
1030
- updateInstalledExtension,
1031
- removeInstalledExtension,
1032
- readExtensionInstallProvenance,
1033
- ExtensionInstallError,
668
+ installExtensionFromSpec, updateInstalledExtension, removeInstalledExtension,
669
+ readExtensionInstallProvenance, ExtensionInstallError,
1034
670
  } from 'zidane'
1035
671
 
1036
672
  import type {
@@ -1050,18 +686,11 @@ import type {
1050
686
  } from 'zidane'
1051
687
  ```
1052
688
 
1053
- Everything above is also importable from the `zidane/extensions` subpath
1054
- (same barrel, narrower module graph for hosts that don't want the root).
1055
-
1056
- Same exports are available from `zidane/chat` for hosts that pull the chat layer (not the full SDK barrel).
1057
-
1058
- The TUI-specific bits (`ExtensionsSettingsModal`) live in `zidane/tui`.
689
+ Also importable from `zidane/extensions` (same barrel, narrower module graph) and `zidane/chat` (for hosts that pull the chat layer, not the full SDK). The TUI-specific bits (`ExtensionsSettingsModal`) live in `zidane/tui`.
1059
690
 
1060
691
  ### Folding extensions into an agent
1061
692
 
1062
- `runTui` and the GUI wire the registry in for you. For an embedded harness, build
1063
- the registry (async — it runs `setup()`), then hand the **result** to `createAgent`
1064
- (sync) or `runHeadless`:
693
+ `runTui` and the GUI wire the registry in for you. For an embedded harness, build the registry (async — it runs `setup()`), then hand the **result** to `createAgent` (sync) or `runHeadless`:
1065
694
 
1066
695
  ```ts
1067
696
  import { buildExtensionRegistry, createAgent, inlineLoadedExtension, runHeadless } from 'zidane'
@@ -1071,72 +700,37 @@ const registry = await buildExtensionRegistry(defs.map(inlineLoadedExtension))
1071
700
  // Long-lived agent:
1072
701
  const agent = createAgent({ provider, execution, session, extensions: registry })
1073
702
 
1074
- // One-shot headless run (`preset` swaps the default `basic` bundle wholesale):
703
+ // One-shot headless run:
1075
704
  await runHeadless({ prompt, provider, preset: myPreset, extensions: registry })
1076
705
  ```
1077
706
 
1078
- `createAgent({ extensions })` folds the registry's tools / hooks / MCP servers /
1079
- skills / system fragments / subagents / runtime transforms / typed metadata into
1080
- the agent, with your explicit options winning on collision. It is **not** just
1081
- `composePresets(buildExtensionPreset(registry), preset)`: the fold also rebuilds
1082
- the `spawn` tool's subagent menu (`withSubagents`), *appends* system fragments
1083
- rather than replacing `system`, carries context-panel sections and tool-result
1084
- metadata into `agent.getContextBreakdown()`, and leaves `ctx.config.get` reading
1085
- live through the registry's `readSettings`. The renderer/picker surfaces a registry
1086
- carries — `themes`, `agents`, `providers`, `completionProviders`, keybindings, footer
1087
- hints — are **not** folded into a bare agent; they need a host (TUI/GUI) that consumes
1088
- them.
707
+ `createAgent({ extensions })` folds the registry's tools/hooks/MCP servers/skills/system fragments/subagents/runtime transforms/typed metadata into the agent, with explicit options winning on collision. It also rebuilds the `spawn` tool's subagent menu, *appends* system fragments rather than replacing `system`, and carries context-panel sections + tool-result metadata into `agent.getContextBreakdown()`. Renderer/picker surfaces (`themes`, `agents`, `providers`, `completionProviders`, keybindings, footer hints) are **not** folded into a bare agent — they need a host (TUI/GUI).
1089
708
 
1090
709
  ### Headless-safe UI
1091
710
 
1092
- Every **agent-level** contribution — tools, hooks, MCP servers, skills, system
1093
- fragments, subagents, typed config — is headless-safe: it's plain data /
1094
- callbacks, so registering it never drags a UI runtime onto a non-TUI host's
1095
- import graph. TUI keybindings and footer hints are likewise just callbacks +
1096
- data and are fine.
1097
-
1098
- The one exception is a modal's **eager** `node`. Because it's a `ReactNode`
1099
- built during `setup()`, the JSX producing it is evaluated at registration time,
1100
- so it lands on the import graph of *every* host that loads the extension —
1101
- headless and embedded included.
711
+ Every **agent-level** contribution (tools, hooks, MCP servers, skills, system fragments, subagents, typed config) is headless-safe: plain data/callbacks, no UI runtime on the import graph. TUI keybindings and footer hints are likewise callbacks + data.
1102
712
 
1103
- To keep a TUI modal's UI off non-TUI graphs:
713
+ The exception is a modal's **eager** `node` — a `ReactNode` built during `setup()`, so its JSX is evaluated at registration time and lands on every host's import graph, headless included. To keep it off non-TUI graphs:
1104
714
 
1105
- - Register it with the **lazy** form: `node: () => import('./panel')`. The thunk
1106
- runs only when the modal first opens.
1107
- - Keep the extension's **entry** module free of UI imports **and** JSX — a single
1108
- piece of JSX injects a static `react/jsx-runtime` import that defeats the split.
1109
- - Put the component + its JSX in the lazily-imported `./panel` module and
1110
- `export default` the node (zidane unwraps the module's `default`).
715
+ - Register with the lazy form: `node: () => import('./panel')`. The thunk runs only when the modal first opens.
716
+ - Keep the extension's entry module free of UI imports **and** JSX (a single piece of JSX injects a static `react/jsx-runtime` import that defeats the split).
717
+ - Put the component + JSX in the lazily-imported module and `export default` the node.
1111
718
 
1112
- This mirrors the guarantee zidane enforces on its own extension core: the
1113
- `src/extensions/**` tree is renderer-neutral (no `react` / `@opentui` at
1114
- runtime), checked by the `bundle-invariant` test. The lazy `node` form extends
1115
- the same discipline to *your* extension's entry module.
719
+ This mirrors zidane's own invariant: `src/extensions/**` is renderer-neutral (no `react`/`@opentui` at runtime), checked by the `bundle-invariant` test.
1116
720
 
1117
721
  ## Preset vs extensions
1118
722
 
1119
- Both layer contributions onto an agent, but they answer different questions — and an
1120
- extension never shadows anything the host didn't already cede:
723
+ Both layer contributions onto an agent, but answer different questions — an extension never shadows anything the host didn't already cede:
1121
724
 
1122
725
  | | `preset` (+ explicit `createAgent` options) | `extensions` (an `ExtensionRegistry`) |
1123
726
  |---|---|---|
1124
727
  | Role | The host's authoritative bundle — the agent "profile" | A contributed layer composed *under* the host |
1125
- | Precedence | **Wins** on every collision | Loses to preset/explicit (mirrors the TUI's `composePresets(extensionPreset, profile.preset)`) |
728
+ | Precedence | **Wins** on every collision | Loses to preset/explicit (mirrors `composePresets(extensionPreset, profile.preset)`) |
1126
729
  | Unit | A static object you author | A *built* artifact: ran `setup()`, allowlist-gated, carries inactive/errored entries |
1127
730
  | Lifecycle | Fixed at construction | Discovered, trusted, enabled/disabled per user |
1128
731
  | Surface | tools, hooks, system, mcpServers, skills, behavior | all of the above **plus** subagents, typed config, themes, agent profiles, providers, completion providers, keybindings, footer hints, storage/session state, runtime transforms, context-panel sections, metadata |
1129
732
 
1130
- **Rule of thumb:**
1131
-
1132
- - **Host-owned and authoritative** (your core tools, your system prompt, your behavior
1133
- defaults) → `preset` / explicit options.
1134
- - **Contributed, third-party, user-toggleable, or anything needing the extension-only
1135
- surfaces** (subagents, typed config, themes, providers, keybindings, storage/session state, runtime transforms) → `extensions`.
1136
-
1137
- If a tool or hook lives in both, the preset/explicit one wins (hooks from both still
1138
- fire). The litmus test: *would a user expect to toggle it off in `Settings → Extensions`?*
1139
- If yes, it's an extension.
733
+ **Rule of thumb:** host-owned and authoritative (core tools, system prompt, behavior defaults) → `preset`/explicit options. Contributed, third-party, user-toggleable, or extension-only surfaces (subagents, typed config, themes, providers, keybindings, storage/session state, runtime transforms) → `extensions`. The litmus test: *would a user expect to toggle it off in `Settings → Extensions`?* If yes, it's an extension.
1140
734
 
1141
735
  ## File layout reference
1142
736
 
@@ -1152,15 +746,13 @@ If yes, it's an extension.
1152
746
  ~/.zidane/extensions/ # user-global, zidane-specific
1153
747
  ```
1154
748
 
1155
- Each `<name>/` is free to ship whatever it needs — additional files, `package.json` (including optional `package.json#zidane` manifest metadata), assets. The loader reads `extension.json` / `package.json#zidane` for metadata and only imports `index.{ts,tsx,mjs,js}` for executable code.
749
+ Each `<name>/` is free to ship whatever it needs — additional files, `package.json` (including optional `package.json#zidane` manifest metadata), assets. The loader reads `extension.json`/`package.json#zidane` for metadata and only imports `index.{ts,tsx,mjs,js}` for executable code.
1156
750
 
1157
751
  ## Quick checklist
1158
752
 
1159
- Building your first extension:
1160
-
1161
753
  - [ ] Create `.zidane/extensions/<your-name>/index.ts`
1162
754
  - [ ] `export default defineExtension({ name, setup })` (or the zero-import object form)
1163
- - [ ] Optionally add `extension.json` with `apiVersion: 1`, `capabilities`, and `permissions`
755
+ - [ ] Optionally add `extension.json` with `apiVersion: 1`, `capabilities`, `permissions`
1164
756
  - [ ] Register what you need via `ctx.*` inside `setup`
1165
757
  - [ ] Restart the TUI
1166
758
  - [ ] Toggle on/off in `Settings → Extensions`