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