zidane 7.0.14 → 7.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +18 -9
  2. package/dist/{acp-CZpX91YE.js → acp-Bw2b_kka.js} +3 -3
  3. package/dist/{acp-CZpX91YE.js.map → acp-Bw2b_kka.js.map} +1 -1
  4. package/dist/acp-cli.js +4 -4
  5. package/dist/acp.d.ts +1 -1
  6. package/dist/acp.js +1 -1
  7. package/dist/{agent-B1jsFaDq.js → agent-B_uQo4f1.js} +649 -48
  8. package/dist/agent-B_uQo4f1.js.map +1 -0
  9. package/dist/agent.d.ts +1 -1
  10. package/dist/agent.js +1 -1
  11. package/dist/{anthropic-Da39cE-d.js → anthropic-QbHqqTkV.js} +2 -2
  12. package/dist/{anthropic-Da39cE-d.js.map → anthropic-QbHqqTkV.js.map} +1 -1
  13. package/dist/{approvals-S100xf1d.js → approvals-B9aIQJRQ.js} +287 -45
  14. package/dist/approvals-B9aIQJRQ.js.map +1 -0
  15. package/dist/artifacts-CV2hUKnL.js +112 -0
  16. package/dist/artifacts-CV2hUKnL.js.map +1 -0
  17. package/dist/{auth-DfEWnkC7.js → auth-plw6npda.js} +19 -7
  18. package/dist/auth-plw6npda.js.map +1 -0
  19. package/dist/chat/pure.d.ts +3 -3
  20. package/dist/chat/pure.js +1 -1
  21. package/dist/chat.d.ts +6 -6
  22. package/dist/chat.js +5 -5
  23. package/dist/contexts/daytona.d.ts +1 -1
  24. package/dist/contexts/e2b.d.ts +1 -1
  25. package/dist/daemon-cli.js +1 -1
  26. package/dist/eval.d.ts +1 -1
  27. package/dist/eval.js +1 -1
  28. package/dist/extensions.d.ts +2 -2
  29. package/dist/extensions.js +2 -2
  30. package/dist/headless.d.ts +1 -1
  31. package/dist/headless.js +2 -2
  32. package/dist/{index-DFGECPfm.d.ts → index-Dc_zYpEu.d.ts} +351 -2
  33. package/dist/index-Dc_zYpEu.d.ts.map +1 -0
  34. package/dist/{index-DP7xYXr9.d.ts → index-Df2CH6bn.d.ts} +92 -7
  35. package/dist/index-Df2CH6bn.d.ts.map +1 -0
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.js +7 -6
  38. package/dist/{ipc-BLLscoIl.js → ipc-T_pPnHkp.js} +2 -2
  39. package/dist/{ipc-BLLscoIl.js.map → ipc-T_pPnHkp.js.map} +1 -1
  40. package/dist/ipc.d.ts +54 -6
  41. package/dist/ipc.d.ts.map +1 -1
  42. package/dist/ipc.js +3 -3
  43. package/dist/mcp.d.ts +1 -1
  44. package/dist/{oauth-C4GvY_p1.js → oauth-EIBUuSDK.js} +2 -2
  45. package/dist/{oauth-C4GvY_p1.js.map → oauth-EIBUuSDK.js.map} +1 -1
  46. package/dist/output/stream-json.d.ts +1 -1
  47. package/dist/output/terminal.d.ts +1 -1
  48. package/dist/plugins.d.ts +1 -1
  49. package/dist/presets.d.ts +2 -2
  50. package/dist/presets.js +2 -2
  51. package/dist/providers/anthropic.d.ts +1 -1
  52. package/dist/providers/anthropic.js +1 -1
  53. package/dist/providers/arcee.d.ts +1 -1
  54. package/dist/providers/baseten.d.ts +1 -1
  55. package/dist/providers/cerebras.d.ts +1 -1
  56. package/dist/providers/local.d.ts +1 -1
  57. package/dist/providers/openai-compat.d.ts +1 -1
  58. package/dist/providers/openai.d.ts +1 -1
  59. package/dist/providers/openai.js +1 -1
  60. package/dist/providers/openrouter.d.ts +1 -1
  61. package/dist/providers/xai.d.ts +1 -1
  62. package/dist/providers/xai.js +2 -2
  63. package/dist/{providers-BYgyPb15.js → providers-CoK1rZ1p.js} +2 -2
  64. package/dist/{providers-BYgyPb15.js.map → providers-CoK1rZ1p.js.map} +1 -1
  65. package/dist/providers.d.ts +1 -1
  66. package/dist/providers.js +3 -3
  67. package/dist/restate.d.ts +1 -1
  68. package/dist/restate.js +1 -1
  69. package/dist/{server-C8gULJ0d.js → server-DXM8ZbGi.js} +64 -6
  70. package/dist/server-DXM8ZbGi.js.map +1 -0
  71. package/dist/session/sqlite.d.ts +1 -1
  72. package/dist/session/sqlite.d.ts.map +1 -1
  73. package/dist/session/sqlite.js +117 -1
  74. package/dist/session/sqlite.js.map +1 -1
  75. package/dist/{session-K3icbPH9.js → session-DPZnncaQ.js} +60 -1
  76. package/dist/session-DPZnncaQ.js.map +1 -0
  77. package/dist/session.d.ts +2 -2
  78. package/dist/session.js +3 -2
  79. package/dist/skills.d.ts +1 -1
  80. package/dist/{tool-formatters-C-s_-u0v.d.ts → tool-formatters-BgdOYAy9.d.ts} +2 -2
  81. package/dist/{tool-formatters-C-s_-u0v.d.ts.map → tool-formatters-BgdOYAy9.d.ts.map} +1 -1
  82. package/dist/tools/fetch-url.d.ts +1 -1
  83. package/dist/tools/web-search.d.ts +1 -1
  84. package/dist/tools.d.ts +2 -2
  85. package/dist/tools.js +2 -2
  86. package/dist/{transcript-anchors-D1ToF-EW.d.ts → transcript-anchors-CtnwuQJM.d.ts} +3 -3
  87. package/dist/transcript-anchors-CtnwuQJM.d.ts.map +1 -0
  88. package/dist/tui.d.ts +3 -3
  89. package/dist/tui.d.ts.map +1 -1
  90. package/dist/tui.js +576 -102
  91. package/dist/tui.js.map +1 -1
  92. package/dist/{turn-operations-CZLsNgw-.js → turn-operations-DkJ1R0p8.js} +4 -3
  93. package/dist/turn-operations-DkJ1R0p8.js.map +1 -0
  94. package/dist/{turn-operations-Bp5-BAki.d.ts → turn-operations-khAvr7KJ.d.ts} +5 -3
  95. package/dist/turn-operations-khAvr7KJ.d.ts.map +1 -0
  96. package/dist/types.d.ts +1 -1
  97. package/dist/{xai-DILdFGzN.js → xai-CazjStOK.js} +3 -3
  98. package/dist/{xai-DILdFGzN.js.map → xai-CazjStOK.js.map} +1 -1
  99. package/docs/ACP.md +6 -0
  100. package/docs/ARCHITECTURE.md +179 -356
  101. package/docs/ARTIFACTS.md +130 -0
  102. package/docs/CHAT.md +426 -1312
  103. package/docs/EXECUTION_CONTEXT.md +46 -19
  104. package/docs/EXTENSIONS.md +234 -584
  105. package/docs/HEADLESS.md +5 -0
  106. package/docs/INTERACTIONS.md +21 -22
  107. package/docs/IPC.md +318 -335
  108. package/docs/PLUGINS.md +2 -0
  109. package/docs/README.md +57 -0
  110. package/docs/RESTATE.md +199 -491
  111. package/docs/RUN_IN_BACKGROUND.md +107 -558
  112. package/docs/SKILL.md +171 -238
  113. package/docs/TUI.md +213 -591
  114. package/package.json +1 -1
  115. package/dist/agent-B1jsFaDq.js.map +0 -1
  116. package/dist/approvals-S100xf1d.js.map +0 -1
  117. package/dist/auth-DfEWnkC7.js.map +0 -1
  118. package/dist/index-DFGECPfm.d.ts.map +0 -1
  119. package/dist/index-DP7xYXr9.d.ts.map +0 -1
  120. package/dist/server-C8gULJ0d.js.map +0 -1
  121. package/dist/session-K3icbPH9.js.map +0 -1
  122. package/dist/transcript-anchors-D1ToF-EW.d.ts.map +0 -1
  123. package/dist/turn-operations-Bp5-BAki.d.ts.map +0 -1
  124. package/dist/turn-operations-CZLsNgw-.js.map +0 -1
@@ -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,66 +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.registerRunTransform(transform)
140
- ctx.registerBehaviorTransform(transform)
141
- ctx.registerProviderMiddleware(middleware)
142
- ctx.registerToolCatalogTransform(transform)
143
- ctx.registerMcpConfigTransform(transform)
144
- ctx.registerSkillPolicy(policy)
145
- ctx.registerConfigCategory(category)
146
- ctx.registerConfigItem(categoryId?, item)
147
- ```
148
-
149
106
  | API | Use it for | Notes |
150
107
  |---|---|---|
151
- | `registerTool(name, ToolDef)` | Custom agent tools | Profile tools win on name collision; built-in interaction tools (`ask_user`, `present_plan`) always win. |
152
- | `registerHook(event, handler)` | Observability / policy on `AgentHooks` | Fires on the **parent** agent only — for subagent activity subscribe to `child:tool:before` etc. |
153
- | `registerMcpServer(McpServerConfig)` | Programmatic MCP server (no `mcps.json` edit) | Discovered `mcps.json` wins on `name` collision. |
154
- | `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. |
155
- | `registerCompletionProvider(provider)` | New `/` `@` `!` triggers in the prompt | One-character trigger; collides with built-ins (`/` `@` `#`) → ignored. |
156
- | `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. |
157
- | `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. |
158
- | `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. |
159
- | `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. |
160
- | `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. |
161
- | `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). |
162
- | `registerContextBreakdownSection(fn)` | Existing low-level context panel contributor | May return a section or array of sections; async contributors are supported. |
163
- | `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. |
164
- | `registerToolResultMetadata(registration)` | Interpret namespaced metadata attached to tool results | Can project metadata entries into context-panel rows. |
165
- | `registerRunTransform(fn)` | Synchronously rewrite `agent.run()` options before precondition checks | Applied in extension order. Throws are logged under `ZIDANE_DEBUG` and ignored. |
166
- | `registerBehaviorTransform(fn)` | Synchronously rewrite resolved behavior (`maxTurns`, retry, budgets, etc.) | Run-level `behavior` options are re-applied after transforms so explicit caller choices win. |
167
- | `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. |
168
- | `registerToolCatalogTransform(fn)` | Hide, rename, or replace tools before disclosure/execution | Runs for both pre-run context estimates and actual runs. |
169
- | `registerMcpConfigTransform(fn)` | Programmatically rewrite MCP server configs before connection | Discovered/user configs are merged first, then transforms run in extension order. |
170
- | `registerSkillPolicy(policy)` | Auto-activate a skill based on run context | `policy.when` may be async; failures are fail-open with debug logging. |
171
- | `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. |
172
-
173
- `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 |
174
144
 
175
145
  ### TUI-specific
176
146
 
@@ -178,33 +148,34 @@ ctx.registerConfigItem(categoryId?, item)
178
148
  ctx.tui.registerKeybinding({
179
149
  id: 'my-ext.openPanel', // globally unique, namespace by extension
180
150
  default: 'ctrl+shift+j', // empty string = unbound by default
181
- label: 'Jokes', // shown in Settings → Keybindings
151
+ label: 'Jokes',
182
152
  description: 'Open the joke panel',
183
153
  onTrigger: dispatch => dispatch.openModal('jokes'),
184
154
  })
185
155
 
186
156
  ctx.tui.registerFooterHint({ key: '^J', label: 'jokes' })
187
-
188
- ctx.tui.registerModal({
189
- id: 'jokes',
190
- node: <JokesPanel />,
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 })):
169
+ ctx.tui.registerArtifactRenderer({
170
+ kind: 'my-ext.deployment',
171
+ renderDetail: artifact => <DeploymentStatus url={artifact.uri} meta={artifact.meta} />,
191
172
  })
192
173
 
193
- // HUD — fire-and-forget, callable for the extension's whole lifetime
194
- // (setup, hooks, commands, timers). No-ops on hosts without a UI.
195
- ctx.tui.notify('Indexed 42 files', { tone: 'info' }) // one-shot transcript line
196
- ctx.tui.setStatus('indexing… 42/117', { loader: true }) // single dim line at the transcript tail, with working glyph
197
- ctx.tui.setStatus('✓ indexed', { timeout: 3000 }) // transient — auto-clears after 3s
198
- ctx.tui.setBanner(['review pending: 3 files'], { timeout: 10_000 }) // block above the prompt, next to the todo indicator
199
- ctx.tui.setStatus(null) // clear (also wiped automatically on disposal)
200
-
201
174
  // Custom transcript rendering — view-layer only, fallback-mandatory:
202
175
  ctx.tui.registerToolRenderer({
203
176
  tools: ['shell'], // omit = offer for every tool
204
177
  render: ({ text }) =>
205
- looksLikeTestOutput(text)
206
- ? <TestSummaryTable output={text} /> // replaces the default ┃ block
207
- : null, // null = fall through to default
178
+ looksLikeTestOutput(text) ? <TestSummaryTable output={text} /> : null, // null = default view
208
179
  })
209
180
 
210
181
  ctx.tui.registerRenderer('commit-graph', data => // auto-namespaced: '<ext>/commit-graph'
@@ -217,54 +188,11 @@ ctx.appendNote([{
217
188
  // command emit() takes the same block shape (full '<ext>/<id>' required there)
218
189
  ```
219
190
 
220
- **The rendering invariant — data stays data.** Nothing a renderer paints is
221
- ever persisted: tool renderers are views over the tool turns you already
222
- have (zero schema change), and `custom` blocks persist `{ renderer-id,
223
- JSON-safe data, fallback markdown }` — the fallback IS the persisted output,
224
- so a pre-custom host (or an old zidane) renders these turns natively.
225
- Resolution happens at render time against the live filtered registry:
226
- disable the extension in Settings and rows fall back instantly; `/reload`
227
- re-skins old rows with new renderer code; a throwing renderer is
228
- error-bounded per row (fallback + dim notice — the transcript survives).
229
- V1 boundary: components are display-only (no focus/keyboard inside
230
- transcript rows) and TUI-scoped like modals — the fallback is the
231
- headless/GUI story. Tool renderers re-skin result rows, they don't
232
- resurrect them: `showToolResults: false` hides custom views too — output
233
- that must stay visible unconditionally belongs in a `custom` block.
234
-
235
- **Async data (fetch in a renderer):** `render` itself is synchronous by
236
- design — but the node it returns mounts as a real React subtree, so async
237
- work happens the standard React way, inside your component:
238
-
239
- ```tsx
240
- function PrStatus({ pr }: { pr: number }) {
241
- const [state, setState] = useState<'loading' | { title: string }>('loading')
242
- useEffect(() => {
243
- let cancelled = false
244
- fetch(`https://api.github.com/repos/o/r/pulls/${pr}`)
245
- .then(r => r.json())
246
- .then((data) => {
247
- if (!cancelled)
248
- setState({ title: data.title })
249
- })
250
- .catch(() => {}) // keep the loading state; the row must never throw upward
251
- return () => {
252
- cancelled = true
253
- }
254
- }, [pr])
255
- return <text>{state === 'loading' ? '⏳ loading…' : `#${pr} ${state.title}`}</text>
256
- }
257
-
258
- ctx.tui.registerRenderer('pr-status', data => <PrStatus pr={(data as { pr: number }).pr} />)
259
- ```
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.
260
192
 
261
- You get loading states, re-render on arrival, and cancellation for free —
262
- an async `render` signature would re-implement all of that (per-row promise
263
- caches, race handling) for no added capability, so it's deliberately not
264
- offered.
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.
265
194
 
266
- **Caching + viewport gating — the hooks** (from `zidane/tui`, importable in
267
- extensions thanks to host-module mapping):
195
+ **Caching + viewport gating** (`useRowVisible`, `useSessionState` from `zidane/tui`):
268
196
 
269
197
  ```tsx
270
198
  import { useRowVisible, useSessionState } from 'zidane/tui'
@@ -273,112 +201,58 @@ function PrStatus({ pr }: { pr: number }) {
273
201
  const visible = useRowVisible() // false until scrolled in (latched)
274
202
  const [data, setData] = useSessionState<Pr | null>('pr', null)
275
203
  useEffect(() => {
276
- if (!visible || data) return // off-screen OR cached → no fetch
277
- fetchPr(pr).then(setData) // setData persists into the session
204
+ if (!visible || data) return
205
+ fetchPr(pr).then(setData)
278
206
  }, [visible, data, pr])
279
207
  return <text>{data ? data.title : visible ? '⏳' : '·'}</text>
280
208
  }
281
209
  ```
282
210
 
283
- - `useSessionState(id, initial)` — durable per-ROW state, keyed
284
- `renderer:<localId>:<turnId>:<id>` in the session's extension-state bag
285
- (your own namespace — inspect or clear it via `ctx.session`). Reload the
286
- session and the row rehydrates instead of refetching. Values are
287
- JSON-safe by type; live rows share the persisted turn's id, so keys agree
288
- live and after reload.
289
- - `useRowVisible(margin?)` — `true` once the row has entered the viewport
290
- (±margin rows of warm-up), LATCHED — mechanically, not just semantically:
291
- once visible, the row UNSUBSCRIBES from the tracker, so scrolling it out
292
- and back in causes zero re-renders and zero effect re-runs (test-pinned).
293
- Re-run-on-revisit belongs to your own state keys. Reports `true`
294
- immediately when no visibility tracker exists (headless-ish hosts) — work
295
- is never gated behind a tracker that isn't there. One shared ~200ms poll
296
- for the whole transcript, no per-row timers, and scrolling doesn't
297
- 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.
298
213
  - Both throw outside an extension-rendered row.
299
214
 
300
- **Module identity — ambient imports just work.** The TUI maps `react`,
301
- `react/jsx-runtime`, and the `@opentui/*` packages to its OWN live module
302
- instances before any extension loads (a Bun virtual-module plugin — see
303
- `extensions/host-modules.ts`), so `import { useState } from 'react'` in an
304
- extension always gets the reconciler's copy. Without this, bare imports
305
- resolve from the extension's directory — landing on nothing, or on a second
306
- react whose hooks throw "Invalid hook call". Don't bundle or depend on your
307
- own `react`/`@opentui/*`; import them bare and let the host supply them.
308
-
309
- The loader itself maps `zidane` and `zidane/providers` the same way (renderer-neutral,
310
- so headless / ACP hosts get it too) — `import { anthropic, registerCustomOAuthProvider }
311
- from 'zidane'` in an extension resolves to the host's LIVE modules, which matters for
312
- anything stateful: the provider factories, the credential store, and pi-ai's OAuth
313
- 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.
314
216
 
315
217
  ```tsx
316
-
317
218
  // Prompt + interception surfaces
318
219
  ctx.submitPrompt('run the tests', { delivery: 'followUp' }) // as-if-typed; 'auto' | 'steer' | 'followUp'
319
- ctx.registerToolPolicy({ // gate tool calls pre-execution
320
- tools: ['shell'],
321
- decide: call => String(call.input.command).includes('rm -rf')
322
- ? { block: 'dangerous' } // or { patch: input } — re-validated against the tool schema
323
- : 'allow',
324
- })
325
- ctx.registerToolResultTransform(({ tool, result }) => // chained result middleware
326
- tool === 'shell' ? { result: redact(result) } : undefined)
327
- ctx.registerContextTransform(messages => // rewrite wire messages per provider call
328
- messages.filter(keep))
329
- ctx.registerPromptTransform(prompt => // transform/consume submitted prompts
330
- prompt.startsWith('!q ') ? `Be brief: ${prompt.slice(3)}` : undefined)
331
- // Not a parallel pipeline: `registerToolPolicy` registers into the SAME
332
- // `tool:gate` hook bucket as the agent's built-in guards, and
333
- // `registerToolResultTransform` rides `tool:transform` — one mechanism,
334
- // one composition order. These are ergonomic (typed verdicts, schema
335
- // 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.
336
224
 
337
225
  // Late-bound host controls (null/false/empty until the host attaches)
338
226
  ctx.runtime.model() // active model id
339
- ctx.runtime.models() // { provider: { key, name }, model: ModelInfo }[] across credentialed providers
227
+ ctx.runtime.models() // { provider: { key, name }, model: ModelInfo }[]
340
228
  ctx.runtime.setThinking('high')
341
229
  await ctx.sessions.list() // { id, title?, updatedAt? }[]
342
230
  await ctx.sessions.switch(id) // or ctx.sessions.create()
343
231
  ctx.appendNote([{ text: '### Indexed\n42 files' }]) // durable display-only transcript note
344
232
 
345
233
  // Shell + files through the SESSION'S execution context — work happens
346
- // wherever the agent's tools run (host, docker, remote sandbox), with the
347
- // same transport output caps and containment. Hand-rolled child_process
348
- // always runs on the HOST and silently targets the wrong filesystem under
349
- // 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.
350
237
  const { stdout, exitCode } = await ctx.workspace.exec('git status --porcelain')
351
238
  const pkg = await ctx.workspace.readFile('package.json')
352
239
  await ctx.workspace.writeFile('.cache/report.md', report)
353
240
  const size = await ctx.workspace.fileSize('big.log') // probe before whole-reads
354
- // Detached (no live session) ⇒ these REJECT rather than silently no-op
355
- // (spawn failures reject with their real cause). The guard is
356
- // side-effect-free — it never spawns a container just to answer:
357
- if (await ctx.workspace.available()) { /* … */ }
358
- // Calling before the session's first run spawns the execution handle on
359
- // demand — same lifecycle seam (and `execution:ready` hook) as agent.run().
241
+ if (await ctx.workspace.available()) { /* … */ } // detached ⇒ calls above REJECT, not no-op
360
242
  ```
361
243
 
362
- `/reload` (built-in command) hot-reloads the registry: re-discover, re-import
363
- each extension's module graph fresh (on-disk edits apply live), re-run `setup`,
364
- swap — the outgoing instance's teardowns run and its HUD paint is wiped
365
- (generation-guarded, so the fresh instance's paint survives the swap ordering).
366
- Dispose also retires the instance's HUD writer: late status/banner writes from
367
- teardown closures or leaked timers are inert (`notify` stays live for farewell
368
- lines). Toggling an extension off in Settings hides its paint and mutes its
369
- 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.
370
245
 
371
- `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.
372
247
 
373
- 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).
374
249
 
375
- 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`:
376
251
 
377
252
  ```jsonc
378
- // ~/.zidane/keybindings.json
379
253
  {
380
254
  "openSettings": "ctrl+o",
381
- "my-ext.openPanel": "ctrl+shift+j" // ← extension binding override
255
+ "my-ext.openPanel": "ctrl+shift+j"
382
256
  }
383
257
  ```
384
258
 
@@ -390,7 +264,7 @@ Extension bindings appear in `Settings → Keybindings` under a trailing **Exten
390
264
  ctx.gui.registerPanel({ id, label, render })
391
265
  ```
392
266
 
393
- `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.
394
268
 
395
269
  ## How it boots
396
270
 
@@ -412,9 +286,9 @@ flowchart TD
412
286
 
413
287
  Key invariants:
414
288
 
415
- 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`.
416
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`.
417
- 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`:
418
292
 
419
293
  ```ts
420
294
  import {
@@ -431,24 +305,19 @@ Key invariants:
431
305
 
432
306
  ## Enabling / disabling
433
307
 
434
- 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`):
435
309
 
436
- - `undefined` (fresh install) — **every** discovered extension runs.
310
+ - `undefined` (fresh install) — every discovered extension runs.
437
311
  - `[]` — none run.
438
312
  - `[names]` — explicit allowlist.
439
313
 
440
- Disabled extensions:
441
- - 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`.
442
- - 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.
443
- - 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.
444
315
 
445
- **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.
446
317
 
447
318
  ## Installing from GitHub / npm
448
319
 
449
- `Settings → Extensions → ctrl+g` fetches, validates, and installs an extension
450
- into the roots discovery already scans — then hot-reloads the registry, so
451
- 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:
452
321
 
453
322
  ```
454
323
  owner/repo # GitHub, single-extension repo
@@ -458,41 +327,22 @@ owner/repo#main:packages/ext # search a subdirectory (monorepos); pasted tr
458
327
  @scope/pkg@1.2.0 # npm (exact versions or dist-tags; ranges are not resolved)
459
328
  ```
460
329
 
461
- …then pick the scope with ←/→: **global** (`~/.zidane/extensions/` — always
462
- trusted, the default) or **this project** (`<project>/.zidane/extensions/`,
463
- which still requires project-extension trust to load — the panel warns when
464
- the project isn't trusted). Progress streams into the panel's status line.
465
- Bare `a/b` is GitHub (npm slash-names are always `@scope/…`), anything else
466
- 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.
467
331
 
468
332
  **Recognized package shapes** (same for both targets):
469
333
 
470
334
  - a root `index.{ts,tsx,mjs,js}` — single-extension repo/package
471
- - `extensions/<name>/index.*` directories — a collection; append the name to
472
- the source input (the error notice lists the candidates when you don't)
473
- - for monorepos, `:path/to/dir` (GitHub only) re-roots the search — both
474
- shapes above then apply to that directory; a pasted
475
- `github.com/owner/repo/tree/<ref>/<path>` URL means the same thing
476
-
477
- **Pipeline guarantees** (`installExtensionFromSpec` in the SDK — the GUI calls
478
- the identical core):
479
-
480
- - The module is imported and validated (definition shape, apiVersion,
481
- folder-safe name, manifest agreement) **in a staging dir before anything
482
- touches your roots** — a failure installs nothing. This executes the
483
- extension's top-level code once; your explicit install request is the consent.
484
- - The target folder is named from the *validated* `definition.name`, so
485
- discovery's folder-name invariant holds by construction.
486
- - Dependencies install with the first of bun/npm/pnpm/yarn on PATH, **never
487
- running lifecycle scripts** (bun's default; the others get `--ignore-scripts`).
488
- - An existing install of the same name is refused unless you confirm the
489
- overwrite. A `.install.json` provenance file (raw spec, resolved
490
- version/ref, exact commit SHA for git installs, scope, selected candidate,
491
- timestamp) is written — it's what powers updates.
492
-
493
- **Bundled extensions** — this repo ships installable packages under
494
- `extensions/`, which double as real-world examples of the pipeline (note the
495
- 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):
496
346
 
497
347
  ```
498
348
  Tahul/zidane:extensions/git-flow # /commit — agent-written commit messages + one-tap push
@@ -500,51 +350,31 @@ Tahul/zidane:extensions/headroom # compress large tool outputs via a local He
500
350
  Tahul/zidane:extensions/pxpipe # image tool outputs in-process + optional pxpipe proxy routing
501
351
  ```
502
352
 
503
- All mirror the packaging conventions to copy for
504
- your own extensions (`package.json` with `zidane-extension` keyword, entry
505
- `index.ts`, `zidane` as a peer).
506
-
507
- **Update / remove** live on the focused row of the same tab:
508
-
509
- - `ctrl+u` — update: first a cheap no-op probe — `git ls-remote` compares the
510
- recorded commit SHA (npm: one registry GET re-resolves the version); an
511
- unchanged remote reports "already up to date" without fetching or reloading
512
- anything. (The probe compares the remote against the *recorded* install,
513
- not your local files — to restore a hand-edited install, remove and
514
- reinstall.) Otherwise it re-resolves the recorded spec (`owner/repo` follows
515
- the branch, bare `pkg` follows `latest`; pinned refs/versions stay pinned)
516
- and replaces the install **in place** (same root, even a
517
- `.agents/extensions` one; an upstream rename removes the superseded
518
- folder), reporting the commit delta (`abc1234 → def5678`). Hand-authored
519
- extensions have no provenance — nothing to update from.
520
- - `ctrl+x` (twice — arm, then confirm; cursor moves disarm) — remove: deletes
521
- the extension's directory and drops it from the enable allowlist. Works on
522
- hand-authored extensions too; a containment guard refuses any directory
523
- that isn't a direct child of a recognized extensions root.
524
-
525
- Install, update, and remove all finish with the `/reload` hot-swap, and all
526
- are thin wrappers over `installExtensionFromSpec` /
527
- `updateInstalledExtension` / `removeInstalledExtension` in the SDK (the GUI
528
- 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).
529
354
 
530
- ## Collisions + precedence
355
+ **Update / remove** on the focused row of the same tab:
356
+
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.
531
359
 
532
- When extension contributions overlap with host or built-in surfaces:
360
+ Install, update, and remove all finish with the `/reload` hot-swap.
361
+
362
+ ## Collisions + precedence
533
363
 
534
364
  | Surface | Collision rule |
535
365
  |---|---|
536
366
  | Tools | Profile tools win, then later extensions (load order). Interaction tools (`ask_user`, `present_plan`) always win. |
537
- | Themes | Built-in theme ids win. Cross-extension collisions: last write wins. |
367
+ | Themes | Built-in theme ids win. Cross-extension: last write wins. |
538
368
  | Agent profiles | Host-supplied profiles win on `id`. |
539
369
  | Providers | Host-supplied providers (incl. `BUILTIN_PROVIDERS`) win on `key`. |
540
370
  | MCP servers | Discovered `mcps.json` wins on `name`. |
541
371
  | Keybindings | Built-in actions match first; collisions logged at boot. |
542
372
  | Completion providers | First registered trigger wins per character. |
543
373
  | Skills | Concatenated catalog; per-name first registration wins. |
544
- | Runtime transforms / middleware | Applied in extension load order after registry merge; failures are logged only when `ZIDANE_DEBUG` is set. |
545
- | 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. |
546
376
 
547
- 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.
548
378
 
549
379
  ## Hooks: parent vs subagent
550
380
 
@@ -553,8 +383,8 @@ The mental model: **extensions extend, they don't shadow.** The few cases where
553
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.
554
384
 
555
385
  ```ts
556
- ctx.registerHook('tool:after', (e) => log('parent tool', e))
557
- 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))
558
388
  ```
559
389
 
560
390
  ## Authoring patterns
@@ -571,10 +401,7 @@ export default defineExtension({
571
401
  ctx.registerTool('jq_query', {
572
402
  name: 'jq_query',
573
403
  description: 'Run a jq filter against the given JSON.',
574
- schema: z.object({
575
- json: z.string(),
576
- filter: z.string(),
577
- }),
404
+ schema: z.object({ json: z.string(), filter: z.string() }),
578
405
  async execute({ json, filter }) {
579
406
  // ... shell out, call a JS jq, whatever
580
407
  return JSON.stringify(result, null, 2)
@@ -587,28 +414,19 @@ export default defineExtension({
587
414
  ### A theme
588
415
 
589
416
  ```ts
590
- import { defineExtension } from 'zidane'
591
-
592
- export default defineExtension({
593
- name: 'sunset',
594
- setup(ctx) {
595
- ctx.registerTheme({
596
- id: 'sunset',
597
- label: 'Sunset',
598
- colors: { brand: '#ff6b35', accent: '#f7c59f', /* … */ },
599
- select: { /* … */ },
600
- surfaces: { /* … */ },
601
- syntax: { /* … */ },
602
- })
603
- },
417
+ ctx.registerTheme({
418
+ id: 'sunset',
419
+ label: 'Sunset',
420
+ colors: { brand: '#ff6b35', accent: '#f7c59f' /* … */ },
421
+ select: { /* … */ },
422
+ surfaces: { /* … */ },
423
+ syntax: { /* … */ },
604
424
  })
605
425
  ```
606
426
 
607
- After restart: `Settings → Theme → Sunset`.
608
-
609
427
  ### A complete agent profile
610
428
 
611
- `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.
612
430
 
613
431
  ```ts
614
432
  import { defineExtension, definePreset } from 'zidane'
@@ -620,7 +438,7 @@ export default defineExtension({
620
438
  ctx.registerAgent({
621
439
  id: 'reviewer',
622
440
  label: 'Reviewer',
623
- description: 'Read-only code reviewer — explores + critiques, never writes.',
441
+ description: 'Read-only code reviewer.',
624
442
  accent: 'model',
625
443
  preset: definePreset({
626
444
  name: 'reviewer',
@@ -633,149 +451,96 @@ export default defineExtension({
633
451
  })
634
452
  ```
635
453
 
636
- After restart: `shift+tab` cycles `build → plan → reviewer → build → ...`. The footer agent badge ("Build" / "Plan" / "Reviewer") flips in lockstep.
637
-
638
- Notes:
639
-
640
- - 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.
641
- - 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.
642
- - 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.
643
455
 
644
456
  ### An MCP server
645
457
 
646
458
  ```ts
647
- export default defineExtension({
648
- name: 'linear-bundled',
649
- setup(ctx) {
650
- ctx.registerMcpServer({
651
- name: 'linear',
652
- transport: 'stdio',
653
- command: 'npx',
654
- args: ['-y', 'linear-mcp'],
655
- })
656
- },
459
+ ctx.registerMcpServer({
460
+ name: 'linear',
461
+ transport: 'stdio',
462
+ command: 'npx',
463
+ args: ['-y', 'linear-mcp'],
657
464
  })
658
465
  ```
659
466
 
660
- Same effect as adding the entry to `mcps.json`, but the extension carries it alongside its docs / version / setup logic.
661
-
662
- ### Observability + logging
663
-
664
- ```ts
665
- export default defineExtension({
666
- name: 'audit-log',
667
- setup(ctx) {
668
- const file = openAuditLog()
669
- ctx.registerHook('tool:after', ({ name, callId }) => file.write(`${name}:${callId}\n`))
670
- ctx.registerHook('child:tool:after', ({ name, callId, depth }) => file.write(`[d${depth}] ${name}:${callId}\n`))
671
- ctx.registerHook('agent:done', ({ runId }) => file.flush())
672
- },
673
- })
674
- ```
467
+ Same effect as an `mcps.json` entry, but carried alongside the extension's docs/version/setup logic.
675
468
 
676
469
  ### A custom slash command
677
470
 
678
471
  ```ts
679
- export default defineExtension({
680
- name: 'jokes',
681
- setup(ctx) {
682
- ctx.registerCompletionProvider({
683
- id: 'jokes',
684
- trigger: '!',
685
- label: 'Jokes',
686
- async suggest(query) {
687
- const items = await fetchJokes(query)
688
- return items.map(j => ({
689
- id: j.id,
690
- label: j.title,
691
- description: j.summary,
692
- insertText: `!${j.title} `,
693
- data: j,
694
- }))
695
- },
696
- parseReferences(text) {
697
- // walk text, find every "!title" occurrence, return spans
698
- return []
699
- },
700
- })
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_' })
701
477
  },
702
478
  })
703
479
  ```
704
480
 
705
- `resolveReferences` and `onSubmit` are optional submit hooks. The TUI and GUI both call them before dispatching a prompt, using the shared `resolveCompletionSubmit` core helper:
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.
482
+
483
+ ### A completion provider
706
484
 
707
485
  ```ts
708
486
  ctx.registerCompletionProvider({
709
- id: 'tickets',
710
- trigger: '#',
711
- label: 'Tickets',
712
- async suggest(query, _ctx, signal) {
713
- return searchTickets(query, { signal })
714
- },
715
- parseReferences(text) {
716
- return findTicketRefs(text)
717
- },
718
- async resolveReferences(refs, _ctx, signal) {
719
- return Promise.all(refs.map(ref => hydrateTicketRef(ref, { signal })))
720
- },
721
- async onSubmit({ references }) {
722
- await rememberSubmittedTickets(references)
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 }))
723
493
  },
494
+ parseReferences(text) { return [] }, // walk text, find every "!title" occurrence
724
495
  })
725
496
  ```
726
497
 
498
+ `resolveReferences` and `onSubmit` are optional submit hooks the TUI/GUI call before dispatching a prompt.
499
+
727
500
  ### Runtime transforms + session state
728
501
 
729
502
  ```ts
730
- export default defineExtension({
731
- name: 'cost-guard',
732
- setup(ctx) {
733
- ctx.registerRunTransform((options) => ({
734
- ...options,
735
- behavior: {
736
- ...options.behavior,
737
- maxCostUsd: Math.min(options.behavior?.maxCostUsd ?? 5, 1),
738
- },
739
- }))
740
-
741
- ctx.registerProviderMiddleware((provider) => ({
742
- ...provider,
743
- stream: async (options, callbacks) => {
744
- await ctx.session.set('lastModel', options.model)
745
- return provider.stream(options, callbacks)
746
- },
747
- }))
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)
748
513
  },
749
- })
514
+ }))
750
515
  ```
751
516
 
752
- 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.
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.
518
+
519
+ ### Session artifacts
753
520
 
754
- ### Context panel sections + tool metadata
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).
755
522
 
756
523
  ```ts
757
- ctx.registerContextPanelSection({
758
- id: 'build-info',
759
- title: 'Build Info',
760
- rows: [
761
- { label: 'Branch', value: currentBranch },
762
- { label: 'CI', value: ciUrl },
763
- ],
764
- })
524
+ export default defineExtension({
525
+ name: 'coolify',
526
+ setup(ctx) {
527
+ ctx.registerArtifactKind({
528
+ kind: 'coolify.deployment', // namespace by extension; [\w.:-]+
529
+ label: 'Deployment',
530
+ detect: ({ tool, result, isError }) => {
531
+ if (isError || tool !== 'mcp_Coolify_deploy') return
532
+ const m = /deployment ([\w-]+)/.exec(String(result))
533
+ return m && { uri: `https://coolify.example/deployments/${m[1]}`, label: m[1], meta: { id: m[1], state: 'queued' } }
534
+ },
535
+ render: a => ({ title: a.label, href: a.uri, status: String((a.meta as { state?: string })?.state ?? '') }),
536
+ resolve: async a => ({ meta: { ...(a.meta as object), state: await fetchState(a) } }), // lazy, on view
537
+ })
765
538
 
766
- ctx.registerToolResultMetadata({
767
- namespace: 'acme.trace',
768
- contextRows({ entries }) {
769
- return entries.map(({ value }) => ({
770
- label: 'Trace',
771
- value: String(value),
772
- }))
539
+ ctx.tui.registerArtifactRenderer({ kind: 'coolify.deployment', renderDetail: a => <DeployLogs id={a.uri} /> })
773
540
  },
774
541
  })
775
542
  ```
776
543
 
777
- 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.
778
-
779
544
  ### A keybinding + modal
780
545
 
781
546
  ```tsx
@@ -783,11 +548,7 @@ import { defineExtension } from 'zidane'
783
548
  import { Modal } from 'zidane/tui'
784
549
 
785
550
  function JokesPanel() {
786
- return (
787
- <Modal title="jokes">
788
- <text>haha</text>
789
- </Modal>
790
- )
551
+ return <Modal title="jokes"><text>haha</text></Modal>
791
552
  }
792
553
 
793
554
  export default defineExtension({
@@ -806,57 +567,21 @@ export default defineExtension({
806
567
  })
807
568
  ```
808
569
 
809
- `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).
810
-
811
- ```ts
812
- // index.ts — no UI imports, no JSX (keeps the entry renderer-neutral)
813
- import { defineExtension } from 'zidane'
814
-
815
- export default defineExtension({
816
- name: 'jokes-ui',
817
- setup(ctx) {
818
- ctx.tui.registerModal({ id: 'jokes-panel', node: () => import('./panel') })
819
- ctx.tui.registerKeybinding({
820
- id: 'jokes-ui.open',
821
- default: 'ctrl+shift+j',
822
- label: 'Jokes',
823
- description: 'Open the jokes panel',
824
- onTrigger: dispatch => dispatch.openModal('jokes-panel'),
825
- })
826
- },
827
- })
828
- ```
829
-
830
- ```tsx
831
- // panel.tsx — UI + JSX live here, imported lazily on first open
832
- import { Modal } from 'zidane/tui'
833
-
834
- export default (
835
- <Modal title="jokes">
836
- <text>haha</text>
837
- </Modal>
838
- )
839
- ```
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).
840
571
 
841
572
  Notes:
842
573
 
843
- - **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.
844
- - **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>`.
845
-
846
- ```tsx
847
- <Modal title="dashboard" fullscreen horizontalMargin={0} verticalMargin={0}>
848
- <scrollbox style={{ flexGrow: 1 }}>{/* … */}</scrollbox>
849
- </Modal>
850
- ```
851
- - 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.
852
- - 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.
853
578
 
854
579
  ## Settings UI
855
580
 
856
581
  Open Settings (`ctrl+o`), navigate to the **Extensions** tab (alongside Skills + MCPs):
857
582
 
858
583
  ```
859
- [ Agent ] [ UI ] [ Keybindings ] [ Authentication ] [ Skills ] [ MCP servers ] [ Extensions 2 / 3 ]
584
+ [ Agent ] [ UI ] [ Keybindings ] [ Authentication ] [ Skills ] [ MCP servers ] [ Extensions 1 / 2 ]
860
585
 
861
586
  search extensions — name, version, description…
862
587
 
@@ -864,25 +589,15 @@ Open Settings (`ctrl+o`), navigate to the **Extensions** tab (alongside Skills +
864
589
 
865
590
  ▶ [✓] hello v0.1.0
866
591
  Logs every tool call · [project: ~/proj/.zidane/extensions/hello]
867
- [✓] jq v0.2.0
868
- Run jq filters · [user: ~/.zidane/extensions/jq]
869
592
  [ ] notes
870
593
  Personal note-taking integration · [user: ~/.zidane/extensions/notes]
871
594
 
872
595
  ←→ tabs · ↑↓ navigate · ↵ toggle · esc close
873
596
  ```
874
597
 
875
- 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.
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.
876
599
 
877
- Keybindings show up in `Settings → Keybindings` under an **Extensions** section:
878
-
879
- ```
880
- Extensions
881
- ctrl+shift+j Jokes
882
- Open the jokes panel
883
- ```
884
-
885
- Config items can declare lifecycle metadata:
600
+ Config items declare lifecycle metadata:
886
601
 
887
602
  ```ts
888
603
  ctx.registerConfigItem('limits', {
@@ -896,27 +611,21 @@ ctx.registerConfigItem('limits', {
896
611
  })
897
612
  ```
898
613
 
899
- `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.
900
-
901
- 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).
902
615
 
903
616
  ### Config actions (`type: 'action'`)
904
617
 
905
- 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):
906
619
 
907
620
  ```ts
908
621
  ctx.registerConfigItem('headroom', {
909
622
  type: 'action',
910
623
  key: 'runContainer',
911
624
  label: 'Run container',
912
- description: 'Start the Headroom proxy in Docker and set Base URL to it.',
913
625
  run: async () => {
914
626
  const baseUrl = await startMyService() // throw on failure — the message lands on the row
915
627
  return { message: `proxy running at ${baseUrl}`, config: { baseUrl } }
916
628
  },
917
- // Optional passive probe — no ↵ needed: called when the settings panel
918
- // surfaces the row (and re-called after any action settles), so the row
919
- // shows live state. Return undefined to stay quiet (e.g. tool missing).
920
629
  status: async () => {
921
630
  const url = await findMyService()
922
631
  return url ? { state: 'ok', message: `running at ${url}` } : { state: 'off', message: 'not running' }
@@ -924,55 +633,40 @@ ctx.registerConfigItem('headroom', {
924
633
  })
925
634
  ```
926
635
 
927
- The contract:
928
-
929
- - **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.
930
- - **Failure** — throw / reject; the error message renders on the row in the error color.
931
- - **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.
932
- - **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.
933
- - **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).
934
-
935
- 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.
936
641
 
937
642
  ## Limitations + gotchas (v1)
938
643
 
939
- - **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.
940
- - **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.
941
- - **No cross-extension dependency declarations.** If extension A relies on extension B's tool, that's on the author to coordinate.
942
- - **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.
943
- - **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).
944
649
  - **Hooks don't bubble to subagents** — see [Hooks: parent vs subagent](#hooks-parent-vs-subagent).
945
- - **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.
946
- - **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.
947
652
 
948
653
  ## SDK surface
949
654
 
950
- 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`:
951
656
 
952
657
  ```ts
953
658
  import {
954
- defineExtension, // identity helper
955
- discoverExtensions, // disk walk + import
956
- inlineLoadedExtension, // wrap a host-supplied def
957
- buildExtensionRegistry, // run setup, merge contributions
958
- mergeContributions, // re-merge a filtered subset (toggle)
959
- isExtensionDefinition, // structural type guard
960
- defaultExtensionScanPaths,
961
- scanExtensionDirectories,
962
- EMPTY_EXTENSION_REGISTRY,
659
+ defineExtension, discoverExtensions, inlineLoadedExtension,
660
+ buildExtensionRegistry, mergeContributions, isExtensionDefinition,
661
+ defaultExtensionScanPaths, scanExtensionDirectories, EMPTY_EXTENSION_REGISTRY,
963
662
  } from 'zidane'
964
663
 
965
664
  import {
966
- // Live-host wiring (the TUI uses these internally; GUI shells do the same):
967
- createExtensionHud, // ctx.tui.notify / setStatus / setBanner backing
968
- createExtensionPromptBridge, // ctx.submitPrompt backing
969
- createExtensionHostRuntimeBridge, // ctx.runtime / ctx.sessions / appendNote backing
665
+ // Live-host wiring (TUI uses these internally; GUI shells do the same):
666
+ createExtensionHud, createExtensionPromptBridge, createExtensionHostRuntimeBridge,
970
667
  // Install / update / remove pipeline (what the Settings panel calls):
971
- installExtensionFromSpec,
972
- updateInstalledExtension,
973
- removeInstalledExtension,
974
- readExtensionInstallProvenance,
975
- ExtensionInstallError,
668
+ installExtensionFromSpec, updateInstalledExtension, removeInstalledExtension,
669
+ readExtensionInstallProvenance, ExtensionInstallError,
976
670
  } from 'zidane'
977
671
 
978
672
  import type {
@@ -992,18 +686,11 @@ import type {
992
686
  } from 'zidane'
993
687
  ```
994
688
 
995
- Everything above is also importable from the `zidane/extensions` subpath
996
- (same barrel, narrower module graph for hosts that don't want the root).
997
-
998
- Same exports are available from `zidane/chat` for hosts that pull the chat layer (not the full SDK barrel).
999
-
1000
- 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`.
1001
690
 
1002
691
  ### Folding extensions into an agent
1003
692
 
1004
- `runTui` and the GUI wire the registry in for you. For an embedded harness, build
1005
- the registry (async — it runs `setup()`), then hand the **result** to `createAgent`
1006
- (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`:
1007
694
 
1008
695
  ```ts
1009
696
  import { buildExtensionRegistry, createAgent, inlineLoadedExtension, runHeadless } from 'zidane'
@@ -1013,72 +700,37 @@ const registry = await buildExtensionRegistry(defs.map(inlineLoadedExtension))
1013
700
  // Long-lived agent:
1014
701
  const agent = createAgent({ provider, execution, session, extensions: registry })
1015
702
 
1016
- // One-shot headless run (`preset` swaps the default `basic` bundle wholesale):
703
+ // One-shot headless run:
1017
704
  await runHeadless({ prompt, provider, preset: myPreset, extensions: registry })
1018
705
  ```
1019
706
 
1020
- `createAgent({ extensions })` folds the registry's tools / hooks / MCP servers /
1021
- skills / system fragments / subagents / runtime transforms / typed metadata into
1022
- the agent, with your explicit options winning on collision. It is **not** just
1023
- `composePresets(buildExtensionPreset(registry), preset)`: the fold also rebuilds
1024
- the `spawn` tool's subagent menu (`withSubagents`), *appends* system fragments
1025
- rather than replacing `system`, carries context-panel sections and tool-result
1026
- metadata into `agent.getContextBreakdown()`, and leaves `ctx.config.get` reading
1027
- live through the registry's `readSettings`. The renderer/picker surfaces a registry
1028
- carries — `themes`, `agents`, `providers`, `completionProviders`, keybindings, footer
1029
- hints — are **not** folded into a bare agent; they need a host (TUI/GUI) that consumes
1030
- 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).
1031
708
 
1032
709
  ### Headless-safe UI
1033
710
 
1034
- Every **agent-level** contribution — tools, hooks, MCP servers, skills, system
1035
- fragments, subagents, typed config — is headless-safe: it's plain data /
1036
- callbacks, so registering it never drags a UI runtime onto a non-TUI host's
1037
- import graph. TUI keybindings and footer hints are likewise just callbacks +
1038
- data and are fine.
1039
-
1040
- The one exception is a modal's **eager** `node`. Because it's a `ReactNode`
1041
- built during `setup()`, the JSX producing it is evaluated at registration time,
1042
- so it lands on the import graph of *every* host that loads the extension —
1043
- 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.
1044
712
 
1045
- 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:
1046
714
 
1047
- - Register it with the **lazy** form: `node: () => import('./panel')`. The thunk
1048
- runs only when the modal first opens.
1049
- - Keep the extension's **entry** module free of UI imports **and** JSX — a single
1050
- piece of JSX injects a static `react/jsx-runtime` import that defeats the split.
1051
- - Put the component + its JSX in the lazily-imported `./panel` module and
1052
- `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.
1053
718
 
1054
- This mirrors the guarantee zidane enforces on its own extension core: the
1055
- `src/extensions/**` tree is renderer-neutral (no `react` / `@opentui` at
1056
- runtime), checked by the `bundle-invariant` test. The lazy `node` form extends
1057
- 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.
1058
720
 
1059
721
  ## Preset vs extensions
1060
722
 
1061
- Both layer contributions onto an agent, but they answer different questions — and an
1062
- 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:
1063
724
 
1064
725
  | | `preset` (+ explicit `createAgent` options) | `extensions` (an `ExtensionRegistry`) |
1065
726
  |---|---|---|
1066
727
  | Role | The host's authoritative bundle — the agent "profile" | A contributed layer composed *under* the host |
1067
- | 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)`) |
1068
729
  | Unit | A static object you author | A *built* artifact: ran `setup()`, allowlist-gated, carries inactive/errored entries |
1069
730
  | Lifecycle | Fixed at construction | Discovered, trusted, enabled/disabled per user |
1070
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 |
1071
732
 
1072
- **Rule of thumb:**
1073
-
1074
- - **Host-owned and authoritative** (your core tools, your system prompt, your behavior
1075
- defaults) → `preset` / explicit options.
1076
- - **Contributed, third-party, user-toggleable, or anything needing the extension-only
1077
- surfaces** (subagents, typed config, themes, providers, keybindings, storage/session state, runtime transforms) → `extensions`.
1078
-
1079
- If a tool or hook lives in both, the preset/explicit one wins (hooks from both still
1080
- fire). The litmus test: *would a user expect to toggle it off in `Settings → Extensions`?*
1081
- 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.
1082
734
 
1083
735
  ## File layout reference
1084
736
 
@@ -1094,15 +746,13 @@ If yes, it's an extension.
1094
746
  ~/.zidane/extensions/ # user-global, zidane-specific
1095
747
  ```
1096
748
 
1097
- 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.
1098
750
 
1099
751
  ## Quick checklist
1100
752
 
1101
- Building your first extension:
1102
-
1103
753
  - [ ] Create `.zidane/extensions/<your-name>/index.ts`
1104
754
  - [ ] `export default defineExtension({ name, setup })` (or the zero-import object form)
1105
- - [ ] Optionally add `extension.json` with `apiVersion: 1`, `capabilities`, and `permissions`
755
+ - [ ] Optionally add `extension.json` with `apiVersion: 1`, `capabilities`, `permissions`
1106
756
  - [ ] Register what you need via `ctx.*` inside `setup`
1107
757
  - [ ] Restart the TUI
1108
758
  - [ ] Toggle on/off in `Settings → Extensions`