Plugin System Internals

Code-level design for the plugin system (issue #268). This first release ships only the minimal core: a registry that loads compiled-in first-party plugin manifests and exposes each one’s enabled/disabled state to every surface (CLI, TUI, web). Contribution registries (settings, keybinds, themes, commands, status detection, UI slots, panes), the subprocess JSON-RPC worker runtime, the capability model, external installation, and the supply-chain/trust machinery are intentionally deferred to follow-up PRs and are not present in the tree yet.

Manifest schema

aoe-plugin-api is the standalone crate that defines the manifest a plugin ships in aoe-plugin.toml. The core schema is just identity:

  • id (PluginId, a validated dotted-lowercase namespace, e.g. aoe.web),
  • name, version, api_version, and an optional description.

PluginManifest::from_toml_str pre-checks api_version permissively (so a manifest targeting a newer host reports “upgrade aoe” rather than a confusing unknown-field error), then parses strictly (deny_unknown_fields, so a contribution section from a future schema is a hard error today) and validates (api_version in range, non-empty name/version). API_VERSION is the schema/host version this crate understands.

Registry

src/plugin/registry.rs owns the in-process registry.

  • BUILTINS is a static slice of BuiltinPlugin, each embedding its manifest TOML via include_str!. The aoe.web marker is gated on the serve cargo feature, so it is present in every dashboard/release build and absent from a TUI-only build. default-plugins (on by default) reserves the on-by-default slot for bundled plugins that do not require the dashboard. CI runs a --no-default-features leg (.github/workflows/tests.yml) that builds with every default plugin compiled out and runs the e2e suite, so a default plugin cannot silently grow an undeclared core dependency: core must still create, attach to, and destroy a tmux + worktree session from CLI and TUI with all default plugins disabled (invariant 1 of #268).
  • PluginRegistry::load(config) parses every builtin manifest, resolves each plugin’s enabled flag from [plugins."<id>"] in config.toml (default enabled), and collects any parse errors as non-fatal load_errors.
  • LoadedPlugin { manifest, enabled } exposes id(), active(), and view().

src/plugin/mod.rs holds the process-wide REGISTRY (an RwLock<Option<Arc<PluginRegistry>>>); registry() loads it lazily from the global config and reload_registry() rebuilds it after an enable/disable.

View model

src/plugin/view.rs defines PluginView { id, name, version, description, enabled, builtin }, a Serialize struct built straight off LoadedPlugin. The CLI, the TUI plugin manager, and the web dashboard all render from the same view, so plugin fields are never re-derived per surface.

Enable/disable

src/plugin/install::set_enabled(id, enabled) validates the id against the registry, writes [plugins."<id>"].enabled through the normal save_config path, and reloads the registry. The three surfaces are thin twins over it:

  • CLI: aoe plugin enable|disable (src/cli/plugin.rs).
  • TUI: the command-palette / settings-tab plugin manager (src/tui/dialogs/plugin_manager.rs); the settings tab stages the change and persists it on the normal settings save.
  • Web: POST /api/plugins/{id}/enabled, gated on read-write mode and (when login is enabled) an elevated session (src/server/api/plugins.rs).

The one behavior wired to a plugin’s state today: aoe serve refuses to start while aoe.web is disabled (src/cli/serve.rs).

Persisted plugin state (#2091)

Two storage slots hold plugin data on disk ahead of the APIs that read and write them, so the later API PRs (#2094, #2095) stay focused on behavior:

  • Per-plugin settings. PluginConfig.settings (src/session/config.rs) is an opaque toml::Table persisted as [plugins."<id>".settings] in config.toml. It is kept schema-free on purpose: values survive on disk even while the plugin is disabled, and the typed schema that validates and renders them arrives with the Tier 0 settings registry (#2094). enabled is declared before settings so the scalar reads above the nested table; the toml serializer emits scalars before subtables regardless, so the order is for readability. An empty table is omitted.
  • Per-session plugin data. Instance.plugin_meta (src/session/instance.rs) is a BTreeMap<String, serde_json::Value> keyed by plugin id, persisted per session in sessions.json. Each plugin owns only its own slot; data for an uninstalled plugin is retained (cheap, and reinstalling restores it). The read/write/cas host API over it (session.meta.{get,set,cas}) ships with the Tier 1 host (see below).

Both fields are additive (#[serde(default, skip_serializing_if = ...)]): absent in older on-disk rows, so they deserialize to empty and need no data migration.

Shared substrate

Two neutral modules hold the protocol-agnostic plumbing that both src/acp/ and the future plugin host build on, so the host never depends on ACP (the dependency arrow runs consumer -> substrate):

  • src/process/worker.rs: worker-subprocess plumbing, process-group signalling (terminate/kill/reap), pid liveness, the runner self-inspection state machine, and the <dir>/<id>.{json,sock,log,restart} path builders. The consumer supplies the base directory and a pid extractor for record inspection.
  • src/events/: a durable event-log storage core, a topic-keyed SQLite seq log with retention, keyset scans, seq bookkeeping, and attachment blobs over opaque JSON payloads. The consumer holds the Connection and owns its payload type and replay semantics. acp::event_store::EventStore is the first consumer (Schema::new("acp") keeps the existing acp_events tables, so no migration).

Contribution schema (#2093)

PluginManifest extends past identity to the contribution sections a plugin declares: capabilities, commands, keybinds, settings, ui, and a runtime worker entrypoint. These are the sections the first external plugin declares; they are defined in aoe-plugin-api and parsed/validated by the host, but consumed by later issues (the settings registry in #2094, the runtime host in #2095, the command/keybind/UI surfaces in #2366). api_version is now 4 (bumped to 2 for the contribution sections, 3 when the detail-panel slot became the dockable pane slot, then 4 for the status section and the aoe_version field); an older api_version manifest still loads as long as it targets no newer field. Unknown top-level keys remain a hard parse error (deny_unknown_fields).

The themes section ships and is consumed by the theme registry (#2094); the status section (id, label) is parsed and validated here, its consumer is the status reference plugin (#2096). panes is not a manifest section: panes ship as a ui slot of kind pane (#2432), so [[panes]] stays a hard parse error. status and aoe_version (below) require api_version >= 4: under deny_unknown_fields a pre-4 host would otherwise report a bare “unknown field” instead of the “upgrade aoe” message, so the host gates them behind the bump.

aoe_version is an optional semver requirement (">=0.10, <0.12") naming which aoe (host app) versions this plugin version supports. It is distinct from api_version: api_version gates the manifest schema shape, aoe_version gates the host’s app behaviour. The host refuses to install or update a plugin whose range excludes the running aoe, and skips loading one (into the registry’s load errors) rather than bailing, so an aoe upgrade cannot brick startup. Builtins are exempt (they ship with aoe). An absent range means no constraint.

The runtime section is one of two kinds: command (an argv launched from the plugin directory) or release-binary (a compiled worker shipped as a GitHub release asset). Installation resolves and downloads a release-binary asset; the Tier 1 host (below) launches and supervises both kinds.

A command runtime may declare ordered [[runtime.build]] steps, run once at install and update inside the installed plugin directory before the plugin is registered. This is how an interpreted worker sets itself up (create a venv, pip install, npm ci), so it can then launch via a plugin-relative command that never depends on the daemon’s PATH:

[runtime]
kind = "command"
command = [".aoe-build/venv/bin/aoe-github-worker"]   # plugin-relative

[[runtime.build]]
command = ["python3", "-m", "venv", ".aoe-build/venv"]

[[runtime.build]]
command = [".aoe-build/venv/bin/pip", "install", "."]
platforms = ["linux", "macos"]              # optional; omitted runs everywhere

Build output must go under the reserved .aoe-build/ directory, never directly into the source tree. That directory is excluded from tree_hash (see below), so a build that mutates the install tree (a venv, node_modules, compiled artifacts) does not change the source hash and a featured plugin still re-derives Featured at load. A source tree that ships .aoe-build is refused at install.

Each step’s argv resolves through the host’s argv resolver (bare name on PATH, separator path relative to the plugin dir, absolute rejected), evaluated just before the step runs so .aoe-build/venv/bin/pip resolves once the prior step created it. Build steps are free to name bare PATH programs (python3, node, uv): they run in the user’s interactive shell where PATH is reliable, which is exactly why the worker entrypoint, launched later by the daemon, is not. A step’s optional platforms (linux / macos / windows) restricts it to matching hosts. Builds run with cwd set to the plugin dir, with stdin closed and stdout/stderr inherited so the user sees progress.

Why install time and the final dir, not launch or staging: aoe plugin install runs in the user’s interactive shell, where python3 / node / uv are reliably on PATH; the daemon that later launches the worker is not. And a Python venv is not relocatable (console-script shebangs and pyvenv.cfg embed absolute paths), so the build runs in the final <plugins_dir>/<id>, never in a staging tree that is then renamed. A failed build aborts the install with no trace; a failed update restores the prior version from a backup, and a leftover backup from an interrupted update is recovered on the next install/update.

The worker entrypoint (command’s argv[0]) must be plugin-relative: a path containing a separator (.venv/bin/worker), resolved inside the install directory. The host enforces this at manifest validation, so the PATH-independent shape is the default and a bare program name is rejected rather than silently resolved against whatever PATH the daemon happens to have. An absolute path is rejected in every mode (it pins a host path).

A worker that genuinely depends on a system tool, for example command = ["uv", "run", "worker"], opts into that PATH dependency explicitly with system = true:

[runtime]
kind = "command"
command = ["uv", "run", "worker"]
system = true   # argv[0] is a bare PATH program, resolved at launch

system = true requires a bare program name (a path is contradictory and rejected) and moves resolution to launch time against the daemon’s PATH. It is the conscious “I accept the daemon must have this tool” choice, not a fallback a manifest falls into by naming a program that happens not to be on PATH. Because its program is resolved at launch, a system worker is also not PATH-checked at install (the install shell’s PATH is not the daemon’s), so it installs even when the tool is absent from the install environment.

Two trust notes for build steps. They run as the user, unsandboxed, before any capability gate (the same honest D8 model as the worker, just earlier), so a plugin with build steps always prompts at install, even when it requests no capabilities, and discloses the commands verbatim; --yes consents to both. And a build that runs pip install pulls dependency bytes the source tree hash does not attest; a featured plugin should pin them (for example a hash-locked requirements.txt). First-class dependency and release-binary attestation are deferred.

Capabilities and grants (#2093)

Static contributions are not capabilities; a theme or a command needs no approval. A capability gates runtime access to a resource that can affect user data, host state, the OS, or the network. The v1 set (aoe_plugin_api::KNOWN_CAPABILITIES): runtime.worker, session.read, session.write, config.read, config.write, process.spawn, net, fs.read, fs.write, clipboard.read, clipboard.write, notifications. A plugin’s own declared settings need no config.*; that gates host/global or other-plugin config.

Capabilities are open strings (CapabilityId), so a follow-up can add one without an api_version bump. An unknown capability still parses (forward compatibility) but is rejected at install (unsupported capability; upgrade aoe), never silently granted.

A grant (PluginConfig.grant, in config.toml) records the capabilities the user approved and is pinned to the sha256 of the installed manifest bytes (PluginManifest::hash_bytes). The registry treats a community plugin as active only when enabled AND the grant covers the installed manifest (same hash, all declared capabilities present). A changed manifest, hence a changed hash or capability set, invalidates the grant: the plugin stays installed but inactive (needs_reapproval) until aoe plugin update re-prompts and re-approves. Builtins are first-party, auto-granted, and never store a grant.

External install, trust, and the lockfile (#2093)

aoe plugin install <source> installs an external plugin under <app_dir>/plugins/<id>/; aoe plugin stays reserved for management (D4), so there is no web install path. A source is a gh:owner/repo[@ref] slug or a local directory (src/plugin/source.rs).

src/plugin/fetch.rs stages a plugin before install. A GitHub source is git cloned (shallow when possible, a full clone plus checkout for a commit ref), the exact commit is resolved, and .git is stripped; the clone base defaults to https://github.com and is overridable via AOE_GITHUB_CLONE_BASE (a GitHub Enterprise host, or a local file:// base in tests). A local source is copied (minus .git and symlinks). When the manifest declares a release-binary runtime, the matching release asset for the host platform (${os}/${arch}/${version} in the asset template) is downloaded via the GitHub client and unpacked (raw or .tar.gz) into the tree, made executable. The staging tree lives under the plugins dir so the final move into place is an atomic same-filesystem rename.

Trust is host-assigned (TrustLevel): builtin (compiled in, auto-granted) or community (external, capabilities gated). An external plugin whose id sits in a reserved namespace (aoe.* / agent-of-empires.*, lifted only by featured verification in #2364) or collides with a builtin is rejected at install and skipped at load.

plugins.lock (<app_dir>/plugins.lock, TOML, keyed by id, deterministic and timestamp-free like Cargo.lock) records each external plugin’s resolved identity: source slug, requested ref, resolved commit, version, manifest hash, tree hash (see below), trust, and (for a release-binary) the release tag, asset name, and asset sha256. lock_version is 2; a tree_hash-less v1 lock still reads (the field defaults) and is repopulated on the next install/update.

plugin::integrity::tree_hash is a deterministic sha256:<hex> over a plugin’s source tree. Files are sorted by their forward-slash relative path and hashed under a versioned header (aoe-plugin-tree-hash-v1) as file\0<path>\0<len> <content>. .git and the reserved .aoe-build/ build-output directory are skipped (the first is stripped from an installed tree, the second is generated into it by build steps and is not part of the source); a symlink or non-UTF-8 path outside those is a hard error so nothing installed escapes the hash. Skipping .aoe-build is what lets a build-mutating plugin (a venv holds ~thousands of files and a python3 symlink) re-derive the same hash at load that the author’s aoe plugin hash of a clean checkout produced; a fixed reserved name keeps the exclusion out of attacker control, where a manifest-declared list a tampered manifest could widen would not. A source that ships .aoe-build is refused at fetch, so the excluded subtree can never hide vetted source. File mode is excluded for cross-platform determinism, and git clone runs with core.autocrlf=false so line endings never differ by platform. The hash is computed over the staged source before any release-binary worker is injected, so an author’s aoe plugin hash <checkout> reproduces the install-time value; the downloaded worker stays pinned separately by the lock’s asset_sha256.

plugins/featured.toml is the curated index, compiled into the binary. Each entry holds a plugin’s source slug and a version -> tree_hash map of vetted releases: a maintainer’s attestation that each listed tree was reviewed. When a plugin id appears in the index, install and update refuse if the fetched source slug (case-insensitive) does not match, or if the manifest ships a release-binary worker (its bytes are not covered by the tree hash yet). The tree hash is then checked against the entry’s set of vetted hashes: a match is featured-verified. An id-in-index install at a hash that is not in the set is an unvetted version, not a tamper-refuse: it installs as a non-featured plugin (community for a GitHub install), so a maintainer can vet a new release by appending its hash without un-verifying older ones. The reserved-namespace gate is unchanged, so an unvetted version of a reserved-namespace plugin is still refused (only a vetted release lifts that gate). A featured-verified install is the one case allowed to claim a reserved (aoe.* / agent-of-empires.*) namespace; a builtin-id collision is always rejected. To ship a new release, run aoe plugin hash against the new tag and add a "<version>" = "sha256:..." entry inside the entry’s versions map alongside the existing ones. In debug builds AOE_FEATURED_INDEX_PATH overrides the embedded index for tests; a release binary always uses the compiled-in index, since the curated set is a root of trust and must not be redefinable by the environment.

Every surface (CLI aoe plugin list / info, the TUI plugin manager, the web Plugins panel) shows a ValidationState: builtin, featured, community (an unvetted GitHub install), or local (a local-directory install). featured is re-derived live at load (the id is in the embedded index and the on-disk tree hashes to the pin), not trusted from the lockfile, since that same derivation gates the reserved-namespace lift and the lockfile is user-writable; community vs local is derived from the install source. The lockfile records the tree hash and the install-time trust as a resolved record, but the load path does not depend on them for validation. The recompute is cheap (only ids the index names, and a featured plugin ships no release-binary, so its installed source equals its pinned source; build output under .aoe-build is excluded, so a build-mutating plugin re-derives the same hash) and is done live on every load from the on-disk tree, never from a cache: a metadata-keyed cache could be forged to return a stale vetted hash for a tampered tree, so the verified decision always re-hashes content. The manifest-hash grant check still catches a community plugin tampered after install.

aoe plugin hash <dir> prints the tree hash for a plugin directory so an author can produce the value a maintainer pins. Run it on a clean checkout.

Tier 0 contribution registries (#2094)

Tier 0 wires a plugin’s declarative manifest contributions into the host’s registries, with no plugin code execution (that is the Tier 1 host, #2095). Four registries consume the manifest:

Settings

A plugin’s [[settings]] are typed: type (string / bool / integer / select), with options, min/max, a default, and advanced. The host maps each to its single-source settings schema as a virtual plugin:<id> section. settings_schema::runtime_schema() returns the static core schema plus those sections; GET /api/settings/schema serves it, the server validates PATCHes against it (validate_patch_with), and the TUI/web render it through the same generic field path as core settings. The API/validation layer speaks the flat plugin:<id>.<key> shape; only the merge boundary translates to the on-disk storage path plugins.<id>.settings.<key> (settings_schema::plugin). Plugin settings are global-only at Tier 0 (not profile-overridable). In the TUI they render read-only under the Plugins tab; edit them from the web dashboard or aoe settings.

A manifest may also declare a default override for a core setting via [setting_defaults] (keyed by the core section.field). settings_schema::resolve returns the effective value, its source, and the full candidate chain; aoe settings explain <key> and GET /api/settings/resolved surface it.

The effective value of a core key at Tier 0 is the user’s value (when it differs from the baseline default), else the core schema default. A plugin’s setting_defaults override is included in the candidate chain so it is observable, but it is NOT applied at runtime yet, so it never reports as the effective source: nothing layers it during real Config load/merge, so every core consumer still reads the struct default. The runtime host applies these overrides for real (#2095); until then a plugin_default candidate is “declared, not yet in effect”. A plugin’s own setting layers stored value > manifest default. “Highest priority” (for the candidate ordering) is active-plugin order, builtins first.

Themes

A plugin’s [[themes]] (name, path) add theme TOMLs to the picker. Each path is resolved under the plugin’s install directory (absolute or parent-escaping paths are rejected); precedence is builtin > user custom > plugin, so a plugin can never shadow a builtin or a user theme.

Keybinds

A plugin’s [[keybinds]] resolve through a merged resolver (tui::home::bindings::resolve_action): the static core table is tried first and always shadows a plugin binding on the same chord; active plugins’ keybinds are consulted only after. A resolved plugin keybind is inspectable but not runnable at Tier 0 (it shows a “needs the plugin runtime” notice); aoe plugin info lists a plugin’s keybinds and flags any chord core shadows. Execution lands with #2095.

CLI grafting

Active plugins’ [[commands]] are grafted onto the derived clap tree at runtime (cli::graft), so they appear in aoe --help and parse. Core commands always win a name conflict. Dispatch tries the core derive first; a grafted command falls through to the plugin dispatcher, which at Tier 0 reports that running it needs the runtime (#2095).

Tier 1 worker host (#2095)

The worker host runs inside the aoe serve daemon (it is serve-gated, like aoe.web), because the host API it exposes reads and writes the event store and session storage the daemon owns. A TUI-only build has no host. The daemon builds one PluginHost at startup, launches a worker for every active plugin that declares a [runtime], and reaps them all on shutdown (AppState.plugin_host, src/server/mod.rs).

Launching a worker, language-agnostically

The host, not the plugin, decides how to resolve and execute a worker. src/plugin/launch.rs turns a LoadedPlugin into a ResolvedLaunch { program, args, cwd, env }, dispatched off the [runtime] kind in a single match. Adding a new runtime kind later is a new arm there; the supervisor and the transport only ever see a ResolvedLaunch, so nothing downstream changes.

  • command: argv[0] resolves on PATH via which when it is a bare name (an interpreter or system tool like python3 / uv), or relative to the plugin directory when it contains a separator (an in-tree script or binary, for example a build-produced .venv/bin/worker), verified executable. Absolute and parent-traversal paths are rejected. The same policy resolves each [[runtime.build]] step at install time; a plugin’s own entrypoint should be plugin-relative so the daemon’s PATH never decides whether it launches.
  • release-binary: the per-platform binary that installation already placed in the plugin directory.

A missing runtime fails loudly with an actionable hint naming the program (and, for a binary, the host os-arch), matching the project’s error-with-hint style. Filesystem and PATH probing go through a LaunchResolver trait so the resolution policy is unit-tested with no real filesystem.

Builtins do not declare a [runtime] in this release, so resolve_launch returns Err(LaunchError::NoRuntime) for them. The aoe __plugin-worker self-exec path for a builtin worker, and the worker-side SDK, arrive with the first builtin worker that needs them; shipping them now would be unused code.

Transport and supervision

A worker is an executable speaking newline-delimited JSON-RPC 2.0 (src/plugin/protocol.rs) over its stdio: it writes one request object per line to stdout and reads one response per line on stdin. The host is the server. Any language that speaks this wire is a valid worker.

The worker is a child owned by the daemon, not a detached process (this is the ACP supervision model minus its persistence half). There is no socket, no on-disk runner record, and no reattach: a plugin worker is a stateless transformer over a host-owned event stream, so surviving a daemon restart would only strand it with a stale view. The daemon dies, its workers die, a fresh daemon respawns them. What is kept from ACP: process-group reaping (a worker that forks helpers is torn down whole), a per-worker respawn budget so a crash loop does not spin, and a concurrency cap. The worker’s stderr drains to <app_dir>/plugin-workers/<id>.log.

Capability-gated host API

Each host method maps to a capability the plugin declared and was granted; the middleware refuses an undeclared or ungranted call before the method runs (src/plugin/host_api.rs). No new capabilities are introduced; the v1 methods reuse the existing taxonomy:

MethodCapability
events.publish / events.subscriberuntime.worker
session.meta.getsession.read
session.meta.set / session.meta.cassession.write
sessions.listsession.read
config.getruntime.worker

events.* run over a shared plugin event bus (a plugin_host schema on the durable event-log substrate, src/events/); subscribe { topics, after_seq } is a replay-after-cursor read, so a worker polls forward from the last seq it saw. Session metadata is always read and written under the calling plugin’s own plugin_meta[<plugin-id>] slot: the worker sends only a key, never another plugin’s id, so one plugin cannot reach another’s data. A session.meta.cas that loses returns the current value rather than clobbering it. Writes go through Storage’s cross-process lock, so the daemon picks them up on its next session reload (eventual consistency, not a live push).

config.get { key } returns the value at plugins.<plugin-id>.settings.<key> for the calling plugin’s own id, so a worker reads back the settings the user edited on the TUI/web surfaces, falling back to its own default when the key is unset (the call returns null). The id is the caller’s own, never a request parameter, so a plugin can only read its own table. Reading one’s own declared settings needs no config.* capability: config.read / config.write gate host/global or other-plugin configuration, which no host method exposes yet, so config.get rides on runtime.worker like events.*.

Sandboxing

SandboxBackend (src/plugin/sandbox.rs) is the seam between a resolved launch and the spawn. The only v1 backend is NoSandbox, which runs the worker as an ordinary child. Per D8 this is honest, not complete: capability gating at the host API boundary stops a cooperative plugin from overreaching, but a granted worker has no OS-level isolation, so an adversarial plugin is not contained. The capability grant prompt states this on every install. Restricted-environment, landlock, and sandbox-exec backends land later behind the same trait, with no change to the resolver or the supervisor.

UI extension points (#2366)

A plugin worker pushes typed UI state to the host over capability-gated RPCs; the host renders every slot, on the web dashboard and (the terminal-applicable subset) in the daemon-connected TUI. No plugin code runs in either surface and the render path never awaits a worker: the host keeps an in-memory snapshot each surface reads synchronously (see Delivery below for what the TUI renders).

The nine slots are a closed UiSlot set (aoe-plugin-api), kebab-case on the wire: status-bar, row-badge, row-column, sort-key, filter-facet, card, pane, detail-badge, notification. A plugin declares the (slot, id) pairs it may fill in its manifest [[ui]] section; an unknown slot is a hard parse error (the host must know how to render each).

A UI contribution is not a capability and needs no grant, but the slots a plugin declares are disclosed so the user knows it modifies the dashboard before trusting it: the aoe plugin install prompt lists them alongside the requested capabilities, and they show in aoe plugin info, the TUI plugin manager, and the web Plugins panel (via PluginView.ui_contributions).

RPCs (src/plugin/host_api.rs)

  • ui.state.set { slot, id, session_id?, payload } and ui.state.remove { slot, id, session_id? }. Gated by runtime.worker and the (slot, id) being declared in the manifest: no dedicated ui capability is introduced. The payload is validated against the slot’s typed shape and stored normalized; an unknown field or bad tone is rejected. Per-session slots (row-badge, row-column, pane, detail-badge) require a session_id; global slots must not carry one. The text-based slots (status-bar, row-badge, detail-badge) accept optional icon (a lucide icon name in kebab-case, e.g. git-pull-request-arrow; the client maps it through an allowlist, an unknown name renders nothing) and href (when set, the badge renders as a link that opens in a new tab; only http/https URLs are followed).
  • ui.notify { tone, title, body?, session_id? }. Gated by the existing notifications capability (not a slot declaration). Returns a monotonic seq.

Richer payloads: row-badge items and the pane block list

Two slots carry more than a single value, so one entry (one declared (slot, id)) can render a list:

  • row-badge also accepts items: BadgeItem[] where BadgeItem = { text?, icon?, tone?, href?, tooltip? }. Each item renders as a compact, tone-tinted icon (falling back to text), linked when href is a safe URL. The single { text, tone, tooltip, icon, href } form still works. An empty items: [] clears the row.

  • pane also accepts blocks: Block[], an ordered list of typed blocks. The web renderer knows these kinds: heading { text }, row { label, value?, sublabel?, icon?, tone?, color?, href? }, note { text, tone? }, divider {}, section { title?, children: Block[], collapsible?, collapsed? } (nested blocks; collapsible wraps the section in a native <details> the user can fold, and collapsed starts it folded, default open), comment { author, body, path?, line?, resolved?, href? } (a read-only PR review comment: author, optional file:line, a wrapped body excerpt, and an unresolved/resolved marker), and action { label, method, icon? } (a button that forwards method to the plugin’s worker, see below). A block’s optional color is a validated hex literal (#rgb/#rrggbb, normalized; no CSS names, rgb(), var(), or url(), so it can never carry arbitrary CSS) that tints the block’s icon/value where a semantic tone cannot name the hue, e.g. a merged PR’s purple. The simple { title, body } form still works when blocks is absent. A pane also takes an optional default_location (right | bottom) choosing the dock it first opens in; the user can move it between docks afterward, and an optional icon (any lucide icon name, kebab-case) for its activity-bar button, falling back to a generic plugin icon. The host renders each pane as a dockable tool-window (activity-bar toggle, move, close) alongside the built-in diff and terminal panes. Each pane’s body scrolls, and a long comment body is clamped with a “more”/“less” toggle, so a full PR comment list stays browsable.

    A pane entry gets a larger payload budget than the other slots: its normalized JSON may be up to 64KB, against 8KB for every other slot (status-bar, row-badge, row-column, card, detail-badge), so a plugin can push a full comment list in one pane entry without truncating to fit.

Block parsing is forward-compatible by design. The host stores blocks as opaque JSON (Vec<Value>); it validates only that the payload envelope is well-formed, not the block kinds. The web renderer draws the kinds it knows and silently ignores any unknown kind or unknown field within a block. So a plugin can add a field to an existing kind, or push a brand new kind, without any host change: an older host simply renders what it understands and drops the rest. This is deliberate, the GitHub plugin’s pane keeps growing (PR state today, review/CI/timelines later) and must not require lockstep host releases.

Pane actions (host to worker). An action block is a button. When clicked, the dashboard POSTs /api/plugins/{id}/action { method, params? }; the host writes that JSON-RPC method to the worker’s stdin as a notification (no id, so no reply) via PluginHost::notify_worker. The worker runs the method (e.g. github.refresh) and re-pushes its UI state, which the next ui-state poll renders. The plugin names the method in its own block, and the worker is the trust boundary: it acts only on methods it implements and ignores the rest (the honest-plugin model). The endpoint is gated on read-write mode only, not on passphrase elevation: a pane action mutates no host-managed state (config, registry, grants, lockfile) and grants no new host capability, so it does not warrant the step-up the way enable/disable does (the worker’s own behavior may still have plugin-defined side effects). If an action ever needs elevation, make it opt-in per action rather than blanket-gating every action.

Store and lifecycle (src/plugin/ui_state.rs)

State is in-memory and dies with the daemon, like the rest of the Tier 1 host. Each worker spawn takes a generation; a plugin’s entries are cleared when its worker exits, guarded by the generation so a late write or an instant respawn cannot resurrect or clobber the live worker’s state. Notifications ride a separate bounded ring and survive a worker exit (a plugin that posts then crashes still reaches the browser). Per-plugin quotas bound memory.

Delivery

GET /api/plugins/ui-state returns the full snapshot (entries grouped nowhere, plus the notification ring); it is small and bounded, so there is no incremental cursor. The dashboard polls it on the same cadence as /api/sessions and renders per-session entries only for sessions present in the live list. Notifications surface as toasts, deduped by seq.

sort-key and filter-facet render in the dashboard sidebar (#2401): each global sort-key is an extra option in the sort picker that orders rows by the referenced row-column’s sort_value (best value per direction at the workspace and group level, unvalued rows sink), and each filter-facet is a facet control that filters rows by the referenced row-column’s filter_values (AND across facets, OR within one). Both selections are client-side and ephemeral: they read the already-fetched scalars, run no plugin code, and are not persisted, so a daemon restart falls back to the built-in sort.

The native structured-view TUI (aoe acp attach, the remote-home picker) polls the same endpoint on a 3-second cadence and renders the slots a terminal can show: global status-bar segments and the open session’s detail-badge entries, tone-colored, in its status line, plus notifications as toasts (deduped by seq, queued so a burst shows one at a time). It renders text and tone only; icon, tooltip, and href are dropped, and card, pane, row-badge, row-column, sort-key, and filter-facet have no structured-view surface. The standalone home screen reads local session storage and has no daemon link, so it renders no plugin slots; rendering there is a follow-up (#2402).

Discovery and update checks (#2365)

Discovery and update checks are explicit actions, never background work (the one exception is the opt-in auto-update sweep below). Both are repo/source level and reuse the existing install trust model rather than weakening it.

Discovery

plugin::discover::discover(query) runs one GitHub search over the aoe-plugin topic (topic:aoe-plugin fork:false archived:false, plus an optional free-text term) and badges each result by matching the repo slug against the featured index source slugs and the installed plugins’ sources (case-insensitive). It does not fetch each repo’s aoe-plugin.toml: cloning N search results to read a manifest would be an N+1 blowup against the unauthenticated search rate limit. So a result is “a GitHub repository tagged aoe-plugin”, and a featured badge means “a curated source slug”, not “the current tree matches the pin”. Results rank featured-first then by stars (#2105 will add popularity ranking). Install stays the trust boundary: aoe plugin install fetches the manifest, prompts for capabilities, and enforces the featured pin.

Surfaces: aoe plugin discover [query], the TUI plugin manager d key, and the dashboard “Search GitHub” button (GET /api/plugins/discover?q=). The dashboard has no install path (capability approval needs a terminal), so each result shows a copyable aoe plugin install gh:owner/repo command instead of an install button.

Unauthenticated GitHub search is rate limited (about 10 requests/minute/IP); the client maps a 403/429 to a RateLimited error so each surface reports it plainly rather than as a generic failure.

GET /api/plugins/details?source=gh:owner/repo backs the dashboard’s detail modal (opened from a discovery result or an installed-plugin row). It reads the plugin’s aoe-plugin.toml via the GitHub contents API (no clone) and lists the repo’s release tags as the available versions. The manifest is parsed leniently (unknown and future keys ignored, api_version not range-checked), so a plugin targeting a newer host than the one installed still renders; a missing or unparseable manifest is reported in manifest_error while the release tags still load.

Update checks

plugin::update_check::outdated() checks every installed external plugin against its plugins.lock entry. A GitHub source compares the locked resolved_commit to git ls-remote <clone_url> <ref|HEAD> (no clone, no REST rate limit; honors AOE_GITHUB_CLONE_BASE, and an annotated tag’s peeled ^{} target wins). A local source re-hashes its source directory with integrity::tree_hash and compares to the locked tree_hash. Builtins are skipped; a missing lock entry, absent git, or dead remote is reported per-plugin, never silently treated as up to date. A commit-pinned install is never “outdated”. Limitation: a release-binary plugin whose release asset is replaced without a source-commit change is not detected (ls-remote only sees the source tree).

Surfaces: aoe plugin outdated, the TUI plugin manager c key, and GET /api/plugins/updates. The web endpoint is separate from the always-on GET /api/plugins list so a settings render never blocks on git or the network; the dashboard paints update-available badges only after the user clicks “Check for updates”.

Auto-update sweep

The opt-in updates.auto_update_plugins setting (off by default) runs a sweep at TUI and aoe serve startup (plugin::auto_update::spawn_if_enabled), spawned non-blocking so a slow remote never delays startup. It applies only clean updates, those that need no new consent; any version that changes the capability set, build steps, or UI slots is skipped and left for a manual aoe plugin update so the new grant is reviewed (install::ConsentMode::CleanOnlyNonInteractive). A background sweep therefore never grants new capabilities, runs a changed build step unattended, or deactivates a working plugin. Applied updates take effect on the next launch / daemon restart.

What comes next

Each deferred piece returns as its own PR once the core is proven: the Tier 0 contribution registries (issue 2094), the UI extension points (issue 2366, above), the builtin worker self-exec path and worker SDK (with the first builtin worker that needs them); the integrity-hashing / featured supply-chain layer landed in #2364 and the discovery / update-check layer in #2365 (both above). Rendering plugin slots in the standalone (non-daemon) home screen is still a follow-up (the structured-view TUI already renders the terminal-applicable subset, #2402). Pinning a featured plugin’s release-binary asset hash in featured.toml (so a featured worker is attested, not just its source) is a follow-up; today a release-binary plugin cannot be featured. Popularity-based discovery ranking and a release-binary asset-drift update check are tracked in #2105 and remain out of scope here.