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.
- package/dist/{acp-24uU2WDs.js → acp-Irby04Us.js} +7 -8
- package/dist/{acp-24uU2WDs.js.map → acp-Irby04Us.js.map} +1 -1
- package/dist/acp-cli.js +7 -7
- package/dist/acp.d.ts +2 -3
- package/dist/acp.d.ts.map +1 -1
- package/dist/acp.js +1 -1
- package/dist/{agent-Cch2ayTt.js → agent-DkSmGkDM.js} +10871 -5411
- package/dist/agent-DkSmGkDM.js.map +1 -0
- package/dist/agent.d.ts +1 -2
- package/dist/agent.js +2 -2
- package/dist/{anthropic-RGqsJlC8.js → anthropic-CTHsdFG9.js} +46 -6
- package/dist/anthropic-CTHsdFG9.js.map +1 -0
- package/dist/{auth-C-ms5N2x.js → auth-BDSu_0t3.js} +13 -86
- package/dist/auth-BDSu_0t3.js.map +1 -0
- package/dist/chat/pure.d.ts +4 -4
- package/dist/chat/pure.js +4 -3
- package/dist/chat.d.ts +45 -8
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +9 -6
- package/dist/chat.js.map +1 -1
- package/dist/completion-core-CXYSVhZo.js +147 -0
- package/dist/completion-core-CXYSVhZo.js.map +1 -0
- package/dist/{context-breakdown-kO-pDsay.js → context-breakdown-Dzo25N45.js} +2 -23
- package/dist/context-breakdown-Dzo25N45.js.map +1 -0
- package/dist/contexts/daytona.d.ts +3 -3
- package/dist/contexts/daytona.js +1 -1
- package/dist/contexts/docker.d.ts +10 -2
- package/dist/contexts/docker.d.ts.map +1 -1
- package/dist/contexts/docker.js +142 -103
- package/dist/contexts/docker.js.map +1 -1
- package/dist/contexts/e2b.d.ts +2 -2
- package/dist/contexts/e2b.js +1 -1
- package/dist/contexts/sandbox.d.ts +1 -1
- package/dist/contexts/sandbox.js +4 -1
- package/dist/contexts/sandbox.js.map +1 -1
- package/dist/{contexts-Lzf5huID.js → contexts-Vone3qQQ.js} +26 -37
- package/dist/contexts-Vone3qQQ.js.map +1 -0
- package/dist/contexts.d.ts +3 -3
- package/dist/contexts.js +1 -1
- package/dist/errors.d.ts +1 -2
- package/dist/errors.js +1 -1
- package/dist/eval.d.ts +1 -1
- package/dist/eval.js +3 -3
- package/dist/exec-file-size-PXkr-MGA.js +63 -0
- package/dist/exec-file-size-PXkr-MGA.js.map +1 -0
- package/dist/extensions.d.ts +2 -0
- package/dist/extensions.js +2 -0
- package/dist/generate-data-WL_yGVL6.js +324 -0
- package/dist/generate-data-WL_yGVL6.js.map +1 -0
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +15 -10
- package/dist/headless.js.map +1 -1
- package/dist/index-B8UtiZy7.d.ts +14967 -0
- package/dist/index-B8UtiZy7.d.ts.map +1 -0
- package/dist/{index-B3ciFUZx.d.ts → index-DsPQbyUn.d.ts} +2 -2
- package/dist/index-DsPQbyUn.d.ts.map +1 -0
- package/dist/index.d.ts +6 -12
- package/dist/index.js +20 -43
- package/dist/index.js.map +1 -1
- package/dist/{logger-Ktm-lj1s.js → logger-DBX9uYOw.js} +108 -5
- package/dist/logger-DBX9uYOw.js.map +1 -0
- package/dist/login-DOF8pMVM.js +539 -0
- package/dist/login-DOF8pMVM.js.map +1 -0
- package/dist/{mcp-Dn5W65Lv.js → mcp-CcvuVXB9.js} +2 -2
- package/dist/{mcp-Dn5W65Lv.js.map → mcp-CcvuVXB9.js.map} +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +1 -1
- package/dist/{messages-YZQLKaz2.js → messages-CwujiS74.js} +4 -4
- package/dist/messages-CwujiS74.js.map +1 -0
- package/dist/{openai-compat-0Y7jcncn.js → openai-compat-BvgYGSR1.js} +7 -6
- package/dist/{openai-compat-0Y7jcncn.js.map → openai-compat-BvgYGSR1.js.map} +1 -1
- package/dist/output/stream-json.d.ts +1 -2
- package/dist/output/stream-json.d.ts.map +1 -1
- package/dist/output/terminal.d.ts +1 -2
- package/dist/output/terminal.d.ts.map +1 -1
- package/dist/{glob-DluQFSw1.js → output-cap-kBUHBlnq.js} +50 -2
- package/dist/output-cap-kBUHBlnq.js.map +1 -0
- package/dist/presets.d.ts +1 -2
- package/dist/presets.js +1 -1
- package/dist/providers/anthropic.d.ts +2 -2
- package/dist/providers/anthropic.js +2 -2
- package/dist/providers/arcee.d.ts +1 -1
- package/dist/providers/arcee.js +1 -1
- package/dist/providers/baseten.d.ts +1 -1
- package/dist/providers/baseten.js +1 -1
- package/dist/providers/cerebras.d.ts +1 -1
- package/dist/providers/cerebras.js +1 -1
- package/dist/providers/local.d.ts +1 -1
- package/dist/providers/local.js +1 -1
- package/dist/providers/openai-compat.d.ts +1 -1
- package/dist/providers/openai-compat.js +1 -1
- package/dist/providers/openai.d.ts +1 -1
- package/dist/providers/openai.js +4 -4
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +1 -1
- package/dist/providers/openrouter.js +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers/xai.js +1 -1
- package/dist/{providers-DgmdYGKm.js → providers-z6LDMAr3.js} +3 -3
- package/dist/{providers-DgmdYGKm.js.map → providers-z6LDMAr3.js.map} +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +4 -4
- package/dist/{read-state-BymF41Jb.js → read-state-Dq_TXUX1.js} +2 -2
- package/dist/{read-state-BymF41Jb.js.map → read-state-Dq_TXUX1.js.map} +1 -1
- package/dist/{interpolate-BtIgcCuz.js → resolve-Cr8JSL_O.js} +562 -136
- package/dist/resolve-Cr8JSL_O.js.map +1 -0
- package/dist/restate.d.ts +2 -2
- package/dist/restate.js +2 -2
- package/dist/{sandbox-Dgy-m6UF.d.ts → sandbox-CgjG2VvQ.d.ts} +2 -2
- package/dist/{sandbox-Dgy-m6UF.d.ts.map → sandbox-CgjG2VvQ.d.ts.map} +1 -1
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/{session-CqrAFAKJ.js → session-CsdDIPY8.js} +108 -3
- package/dist/session-CsdDIPY8.js.map +1 -0
- package/dist/session.d.ts +2 -2
- package/dist/session.js +3 -3
- package/dist/skills-Bsb0rvWI.js +295 -0
- package/dist/skills-Bsb0rvWI.js.map +1 -0
- package/dist/skills.d.ts +2 -3
- package/dist/skills.js +3 -24
- package/dist/{errors-CkTvg97v.d.ts → timeout-8vSIRjzl.d.ts} +10 -2
- package/dist/timeout-8vSIRjzl.d.ts.map +1 -0
- package/dist/{timeout-kGGSXagK.js → timeout-BDetbuN6.js} +59 -2
- package/dist/timeout-BDetbuN6.js.map +1 -0
- package/dist/tool-formatters-DY0TDb1r.d.ts +378 -0
- package/dist/tool-formatters-DY0TDb1r.d.ts.map +1 -0
- package/dist/tools/fetch-url.d.ts +1 -1
- package/dist/tools/web-search.d.ts +1 -1
- package/dist/tools.d.ts +2 -3
- package/dist/tools.js +3 -4
- package/dist/{transcript-anchors-BHIgMlPq.d.ts → transcript-anchors-Cb-bDbNH.d.ts} +233 -640
- package/dist/transcript-anchors-Cb-bDbNH.d.ts.map +1 -0
- package/dist/{transcript-anchors-DsoBHg43.js → transcript-anchors-DSRPWjiH.js} +391 -961
- package/dist/transcript-anchors-DSRPWjiH.js.map +1 -0
- package/dist/tui.d.ts +335 -11
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +6375 -1892
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-Dw02ZoSQ.d.ts → turn-operations-CgMROLsr.d.ts} +29 -4
- package/dist/turn-operations-CgMROLsr.d.ts.map +1 -0
- package/dist/{turn-operations-tG0vuBNc.js → turn-operations-Dq9Kx7m2.js} +321 -81
- package/dist/turn-operations-Dq9Kx7m2.js.map +1 -0
- package/dist/{types-BqdTeBaC.d.ts → types-BDccavwj.d.ts} +42 -1
- package/dist/types-BDccavwj.d.ts.map +1 -0
- package/dist/{types-CyVGdbia.js → types-BIarq1qE.js} +90 -2
- package/dist/types-BIarq1qE.js.map +1 -0
- package/dist/types.d.ts +5 -6
- package/dist/types.js +1 -1
- package/dist/{xai-tPdbAGkq.js → xai-B_e6TOA8.js} +2 -2
- package/dist/{xai-tPdbAGkq.js.map → xai-B_e6TOA8.js.map} +1 -1
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHAT.md +7 -6
- package/docs/EXECUTION_CONTEXT.md +16 -0
- package/docs/EXTENSIONS.md +1063 -0
- package/docs/SKILL.md +16 -0
- package/docs/TUI.md +10 -3
- package/package.json +12 -3
- package/dist/agent-Cch2ayTt.js.map +0 -1
- package/dist/agent-EFWt6uQ_.d.ts +0 -6914
- package/dist/agent-EFWt6uQ_.d.ts.map +0 -1
- package/dist/anthropic-RGqsJlC8.js.map +0 -1
- package/dist/auth-C-ms5N2x.js.map +0 -1
- package/dist/context-breakdown-kO-pDsay.js.map +0 -1
- package/dist/contexts-Lzf5huID.js.map +0 -1
- package/dist/errors-CkTvg97v.d.ts.map +0 -1
- package/dist/glob-DluQFSw1.js.map +0 -1
- package/dist/glob-shell-rJMoCIGb.js +0 -21
- package/dist/glob-shell-rJMoCIGb.js.map +0 -1
- package/dist/index-B3ciFUZx.d.ts.map +0 -1
- package/dist/index-BtzE3Uaq.d.ts +0 -2738
- package/dist/index-BtzE3Uaq.d.ts.map +0 -1
- package/dist/index-tSzOaWNt.d.ts +0 -316
- package/dist/index-tSzOaWNt.d.ts.map +0 -1
- package/dist/interpolate-BtIgcCuz.js.map +0 -1
- package/dist/logger-DGiGf7DW.d.ts +0 -102
- package/dist/logger-DGiGf7DW.d.ts.map +0 -1
- package/dist/logger-Ktm-lj1s.js.map +0 -1
- package/dist/login-CCA-1lgK.js +0 -267
- package/dist/login-CCA-1lgK.js.map +0 -1
- package/dist/messages-YZQLKaz2.js.map +0 -1
- package/dist/policy-DcGlpaNs.d.ts +0 -129
- package/dist/policy-DcGlpaNs.d.ts.map +0 -1
- package/dist/presets-BQWYMHdd.js +0 -114
- package/dist/presets-BQWYMHdd.js.map +0 -1
- package/dist/run-summary-DkN8KsHM.d.ts +0 -249
- package/dist/run-summary-DkN8KsHM.d.ts.map +0 -1
- package/dist/session-CqrAFAKJ.js.map +0 -1
- package/dist/shell-quote-BmnhZmdM.js +0 -33
- package/dist/shell-quote-BmnhZmdM.js.map +0 -1
- package/dist/skills.js.map +0 -1
- package/dist/timeout-CpFm0jJ6.d.ts +0 -10
- package/dist/timeout-CpFm0jJ6.d.ts.map +0 -1
- package/dist/timeout-kGGSXagK.js.map +0 -1
- package/dist/tool-formatters-C6iLgc9f.d.ts +0 -1454
- package/dist/tool-formatters-C6iLgc9f.d.ts.map +0 -1
- package/dist/tools-CCd8e-HT.js +0 -1739
- package/dist/tools-CCd8e-HT.js.map +0 -1
- package/dist/transcript-anchors-BHIgMlPq.d.ts.map +0 -1
- package/dist/transcript-anchors-DsoBHg43.js.map +0 -1
- package/dist/turn-operations-Dw02ZoSQ.d.ts.map +0 -1
- package/dist/turn-operations-tG0vuBNc.js.map +0 -1
- package/dist/types-BqdTeBaC.d.ts.map +0 -1
- 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
|