zidane 5.14.4 → 6.0.0

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