# 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](https://zidane.si/docs/extensions.md#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](#load-order)).
3. Loads the user and project [storage](https://zidane.si/docs/extensions-storage.md) 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.

```ts
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](https://zidane.si/docs/extensions-rpc.md#live-values)).

Load order also sets the order of [interceptors](https://zidane.si/docs/extensions-hooks.md#intercept) and [catalog transforms](https://zidane.si/docs/extensions-catalogs.md#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](#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](https://zidane.si/docs/extensions-placements.md)).

## 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`):

```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](https://zidane.si/docs/extensions-storage.md#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](https://zidane.si/docs/extensions-storage.md#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](https://zidane.si/docs/extensions-components.md)).

## Related

- [Extensions](https://zidane.si/docs/extensions.md): the overview and the definition.
- [Storage](https://zidane.si/docs/extensions-storage.md): what survives a reload and a restart.
- [Testing](https://zidane.si/docs/extensions-testing.md): `host.dispose()` runs the unload in a test.
