Define the complete state machine from discovery to uninstall, guaranteeing:
- Predictable behavior
- Recoverable failures
- Auditable start/stop
- Consistency with command palette / AgentTool registration
discovered
→ validated
→ installed
→ enabled
→ loaded
→ running
→ load_error
→ disabled
→ install_error
→ invalid
| State | Meaning |
|---|---|
| discovered | Plugin directory or package scanned |
| validated | manifest / file integrity passed |
| installed | Written to the installed directory and registered |
| enabled | Enabled by the user, allowed to load |
| loaded | Runtime loaded, contribution points registered |
| running | Has an active panel / background logic |
| disabled | Installed but turned off by the user |
| load_error | Load failed after enabling |
| install_error | Install failed |
| invalid | Validation failed, unusable |
Implemented today: the runtime (apps/desktop/electron/main/plugin-runtime.ts) invokes onLoad (when a plugin is loaded on load/enable) and onUnload (dispatched into the plugin process on unload/disable/reload/app quit, 5s budget — 1.5s on quit — then the process is stopped); unloading tears down the plugin's registered commands and tools. The other hooks below are declared in the API but not yet fired.
Planned: once the full lifecycle lands, hooks fire in this order:
onInstall(once, only after a successful install)onEnableonLoad- runtime events
onUnloadonDisableonUninstall
- Hooks must be able to time out (default 5s, configurable)
- A hook exception must not crash the host
- If
onLoadfails, enterload_errorand automatically roll back the contribution points already registered
A service declared in contributes.services is driven by the host, not by the
plugin's own hooks, so its window is strictly inside the plugin's lifetime:
onLoadcompletes and contribution points are registered- For each declared service (at most 4 per plugin, gated on
background.service), the broker callsservice.startin the plugin process with a 5s budget. A start failure marks that one servicefailedand leaves the rest of the plugin loaded. - On unload / disable / reload,
service.stopruns beforeonUnload, so the service is quiet while the plugin still has its API
Status per service is starting | running | stopped | failed plus a
restart count, readable over the plugin IPC surface and shown on the Plugins
page.
The service lives in the plugin's host process, so a crash takes it with the process. The supervisor then restarts the whole plugin:
- Backoff
1s, 2s, 4s, 8s, 16s, capped at 30s - At most 5 restarts; after that the plugin stays down in
failedso the user sees the failure instead of a silent crash loop - A process that survives 60s is considered healthy and the backoff resets to zero
autoRestart: falseon a service opts its plugin out of restarts entirely- Restarts are skipped when the user re-enabled or removed the plugin while the backoff timer was pending
Manual enable / disable always wins over the supervisor: an explicit action clears the pending timer and the attempt counter.
Quitting stops every plugin host as a shutdown, not as a crash. Each plugin
is marked as disposing and its pending restarts are cancelled before anything
else, then services stop and onUnload runs, in parallel across plugins.
This is what separates the two exits: a host process that dies without being marked is reported as a crash, which means an error log, a "stopped unexpectedly" toast, and a supervisor scheduling restarts into an app that is closing. None of that may happen on a clean quit.
A crash report carries the diagnosis. The crash path reports the host
process's exit code because on Windows a hard fault (0xC0000005 and friends,
which Electron hands over as a negative signed int) and a plugin's own
process.exit(1) are different bugs. The exit code reaches the load error, the
failed service state, the plugin.crash audit record and the plugin log
channel. Plugin stdout/stderr is not copied into the crash report because it
may contain workspace data or secrets. Nothing new is persisted, and no new
permission or API surface is involved.
The sequence is bounded — onUnload gets 1.5s per plugin and teardown 3s in
total, after which the children are killed outright. A plugin's cleanup must
never be the reason the app appears to hang on quit.
- Set state to enabled
- Attempt load
- Success: register commands / tools / skills / themes / MCP servers, then start resident services
- Failure: automatically fall back to disabled and surface the error to the user. This is frozen by D017 (enable→load failure auto-disables the plugin).
- Unregister commands / tools
- Close panel
- Stop resident services and disconnect MCP servers
- Cancel any pending restart backoff
- Call
onUnload/onDisable - Persist as disabled
On app startup:
- Scan installed plugins
- Read the enabled state
- Load only enabled plugins
- Skip a single failed plugin without affecting other plugins or the main app
dev-loaded plugins:
- Not copied to
installed - Reference the local path directly
- Can watch and hot reload
- Reload flow:
unload → validate → load
On hot reload:
- Preserve plugin settings as much as possible
- Panel in-memory state is not guaranteed to be preserved
Each plugin's load process should be approximately transactional:
begin
register commands
register tools
register skills
register themes
register MCP servers (lazy connect)
commit
start resident services
On mid-way failure:
rollback all registrations from this plugin
Avoid a half-loaded state where "the command exists but the tool does not".
Record at least:
- plugin.install
- plugin.uninstall
- plugin.enable
- plugin.disable
- plugin.load.success
- plugin.load.error
- plugin.unload
- plugin.crash
- plugin.service.start / plugin.service.stop
- plugin.service.restart / plugin.service.restart.scheduled
- plugin.services.skipped (missing permission or over the per-plugin cap)
Fields:
- pluginId
- version
- source (
installed|dev|marketplace) - ts
- errorCode?
- attempt? / delayMs? (service restarts)
- exitCode? (crash), exitCodeHex? (Windows hard fault)
Before uninstall:
- disable + unload
- Call
onUninstall - Delete installed files
- Clean up plugin-private data (may ask the user whether to keep it)
Default recommendation:
- Clean up settings/data on uninstall (D016: uninstall deletes plugin data by default)
- Provide a "keep data" advanced option (can be deferred)