Skip to content

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:

  1. Checks the definition: apiVersion: 1, an id equal to the folder name, a valid version, dependencies and settings. A refused one is listed in Settings with the reason.
  2. Orders the extensions by dependencies (load order).
  3. Loads the user and project storage of each one, so reads in setup are synchronous.
  4. 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.

TypeScript
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.

MemberDoes
ctx.signalAn 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 returnA 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):

TypeScript
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).

AppReloads
Terminal appThe 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 appThe 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.

GuardBoundWhat happens
setup timeout10 sThe load fails and rolls back
Callback timeout10 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 accountThe 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 timeout5 s per callThe handler is skipped. The chain goes on with the result so far
Error budget5 failures in 60 sThe 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 detection500 ms of synchronous workThe 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:

FieldHolds
id, displayName, versionFrom the definition
sourceproject, user, config, inline or builtin
statusactive, disabled (off in Settings, or turned off by the error budget) or failed
errorWhy it failed or was turned off

Paths and app facts#

MemberTypeHolds
ctx.app.name'zidane'Always zidane
ctx.app.versionstringThe zidane version
ctx.app.host'tui' | 'desktop' | 'daemon' | 'server' | 'sdk'The process that runs the extension
ctx.app.platformstringprocess.platform
ctx.paths.cwdstringWorking folder of the host
ctx.paths.projectRootstring | undefinedProject root, when the host found one
ctx.paths.dataDirstringPersistent app data
ctx.paths.cacheDirstringFiles that can be deleted
ctx.paths.configDirstring | undefinedUser configuration
ctx.paths.stateDirstring | undefinedRuntime state
ctx.dirstring | undefinedThe 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 onCleanup or 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.
  • setup blocks 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).
  • Extensions: the overview and the definition.
  • Storage: what survives a reload and a restart.
  • Testing: host.dispose() runs the unload in a test.