Extension lifecycle
How an extension loads, registers, unloads and reloads, how scopes group registrations, and how the app guards each call.
An extension loads, runs setup, and lives until it unloads. Everything it registers belongs to an owner, and unloading the owner withdraws all of it. This page covers the load, owners and scopes, cleanup, reload, and the guards around each call.
Read it before you write an extension that holds timers, sockets or state that depends on a setting.
Load#
For each extension it finds (where they load from), the app:
- Checks the definition:
apiVersion: 1, anidequal to the folder name, a validversion,dependenciesandsettings. A refused one is listed in Settings with the reason. - Orders the extensions by
dependencies(load order). - Loads the user and project storage of each one, so reads in
setupare synchronous. - Runs
setup(ctx), one extension at a time, bounded by 10 seconds.
setup may be async. Registrations made after an await, or later from a handler, count the same as ones made at once. They work until the extension unloads.
import { defineExtension } from 'zidane'
export default defineExtension({
apiVersion: 1,
id: 'ticker',
async setup(ctx) {
const timer = setInterval(() => ctx.logger.info('tick'), 60_000)
ctx.onCleanup(() => clearInterval(timer))
// Registrations after an await still count.
const words = await Promise.resolve(['alpha', 'beta'])
ctx.commands.register({ id: 'words', title: 'Log the words', run: () => ctx.logger.info(words.join(', ')) })
// Runs at unload, after every registration was withdrawn.
return () => ctx.logger.info('ticker unloaded')
},
})A setup that throws, rejects or times out fails the load. The app withdraws what it registered so far (a rollback), shows the error on the extension's row in Settings, and loads the others.
Load order#
dependencies maps other extension ids to semver ranges. An extension loads after the ones it names. Among extensions that are ready at the same time, discovery order wins, so the order is the same on every run.
A missing dependency, a version out of range or a cycle never stops a load. The app logs it, and the contract or registry the extension uses from that dependency reports inactive (Live values).
Load order also sets the order of interceptors and catalog transforms: an earlier extension's runs first.
Owners and disposables#
Every registration returns a Disposable: an object with dispose(). Calling it withdraws the registration. Calling it twice does nothing.
Each registration belongs to an owner. The extension's root owner lives until the extension unloads. Unloading it disposes every registration in reverse order, aborts ctx.signal, and runs the cleanups.
| Member | Does |
|---|---|
ctx.signal | An AbortSignal that aborts when the owner is disposed. Pass it to fetch, ctx.workspace.exec and other long calls |
ctx.onCleanup(fn) | Runs fn when the owner is disposed. Returns a Disposable that cancels it |
ctx.scope() | A child context with its own owner and signal (scopes) |
setup's return | A cleanup that runs at unload, like one passed to onCleanup |
UI contributions return a Handle instead: a Disposable with current and update(patch). update merges a patch, or takes a function of the current item, and pushes the change to every window (placements).
Scopes#
ctx.scope() returns a child context with its own lifetime. It has every domain of ctx. Disposing it withdraws what was registered through it, and leaves the rest. Use one for anything that should exist only "while a setting is on" or "while connected".
The kitchen-sink example registers an MCP server only while its "Tweak catalogs" setting is on (examples/extension-kitchen-sink/catalogs.ts):
import type { Disposable } from 'zidane'
let mcpScope: Disposable | undefined
const syncMcp = (): void => {
const on = ctx.settings.get('tweakCatalogs') === true
if (on && !mcpScope) {
const scope = ctx.scope()
scope.mcp.register({ name: 'kitchen-everything', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-everything'], lazyConnect: true })
mcpScope = scope
}
else if (!on && mcpScope) {
mcpScope.dispose()
mcpScope = undefined
}
}
syncMcp()
ctx.settings.onChange('tweakCatalogs', syncMcp)A scope is disposed with its parent too. A call made through a disposed scope or extension throws DisposedError.
Reload#
The apps reload one extension at a time. A reload disposes the extension's owner, imports its module again, and runs setup at the same place in the load order. Other extensions keep running. ctx.storage.memory keeps its values across a reload, not across a restart (memory).
| App | Reloads |
|---|---|
| Terminal app | The extension whose folder changed, through a folder watch. Every 3 seconds it also looks for folders that appeared or went away. /reload reloads every extension now |
| Desktop app | The extension whose folder changed, once no run is in progress. A new folder under a scan root loads |
| Daemon and server (the web app, and windows in daemon mode) | The extension whose folder changed, at once, even while a session runs. The windows then get the new commands, UI and bundles |
A module that no longer imports shows as failed. Fix it and save again: the next reload brings it back.
Async cleanups get 5 seconds. One that is still running after that is abandoned, and the reload goes on.
Guards#
Extensions run in the app's process and are trusted: nothing sandboxes them. The app guards every call into extension code instead.
| Guard | Bound | What happens |
|---|---|---|
setup timeout | 10 s | The load fails and rolls back |
| Callback timeout | 10 s per call: an RPC method, a palette provider, a panel load or action, a settings action, an artifact kind's detect and resolve, an integration's beforeConnect, verify and account | The call fails with a timeout error, logged against the extension. A command's run and a tool's execute have no timeout: they may run an agent or a long command |
| Interceptor and observer timeout | 5 s per call | The handler is skipped. The chain goes on with the result so far |
| Error budget | 5 failures in 60 s | The app turns the extension off and shows the reason in Settings, such as 5 errors in 60s. Last: tools.x.execute: boom. Its registrations are withdrawn and its cleanups run, as at unload |
| Stall detection | 500 ms of synchronous work | The app logs which extension blocked the event loop, and for how long |
A throw in a callback never reaches the agent or another extension. It is logged with the extension's id and the call's name, and counts toward the budget.
Status#
ctx.extensions.list() and ctx.extensions.get(id) report every extension the app found:
| Field | Holds |
|---|---|
id, displayName, version | From the definition |
source | project, user, config, inline or builtin |
status | active, disabled (off in Settings, or turned off by the error budget) or failed |
error | Why it failed or was turned off |
Paths and app facts#
| Member | Type | Holds |
|---|---|---|
ctx.app.name | 'zidane' | Always zidane |
ctx.app.version | string | The zidane version |
ctx.app.host | 'tui' | 'desktop' | 'daemon' | 'server' | 'sdk' | The process that runs the extension |
ctx.app.platform | string | process.platform |
ctx.paths.cwd | string | Working folder of the host |
ctx.paths.projectRoot | string | undefined | Project root, when the host found one |
ctx.paths.dataDir | string | Persistent app data |
ctx.paths.cacheDir | string | Files that can be deleted |
ctx.paths.configDir | string | undefined | User configuration |
ctx.paths.stateDir | string | undefined | Runtime state |
ctx.dir | string | undefined | The extension's folder. Unset for an extension defined inline in code |
For a folder of the extension's own, use ctx.storage.path (files).
Pitfalls#
- A timer, socket or watcher without a cleanup keeps running after a reload, next to the new one. Clear it in
onCleanupor the returned cleanup. - Pass
ctx.signal(or a scope's) to long calls, so an unload cancels them. - Writing to a disposed scope throws
DisposedError. Keep a reference to the current scope, as in the example above, and dispose it once. setupblocks the extensions that load after it. Start slow work without awaiting it, and register what depends on it when it ends.- In daemon mode the window does not run
setup. Code that must run in the window belongs in a component (components).
Related#
- Extensions: the overview and the definition.
- Storage: what survives a reload and a restart.
- Testing:
host.dispose()runs the unload in a test.