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 optionaldescription.
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.
BUILTINSis a static slice ofBuiltinPlugin, each embedding its manifest TOML viainclude_str!. Theaoe.webmarker is gated on theservecargo 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-featuresleg (.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>"]inconfig.toml(default enabled), and collects any parse errors as non-fatalload_errors.LoadedPlugin { manifest, enabled }exposesid(),active(), andview().
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 opaquetoml::Tablepersisted as[plugins."<id>".settings]inconfig.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).enabledis declared beforesettingsso 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 aBTreeMap<String, serde_json::Value>keyed by plugin id, persisted per session insessions.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 theConnectionand owns its payload type and replay semantics.acp::event_store::EventStoreis the first consumer (Schema::new("acp")keeps the existingacp_eventstables, 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.
Integrity hashing and the featured index (#2364)
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 onPATHviawhichwhen it is a bare name (an interpreter or system tool likepython3/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:
| Method | Capability |
|---|---|
events.publish / events.subscribe | runtime.worker |
session.meta.get | session.read |
session.meta.set / session.meta.cas | session.write |
sessions.list | session.read |
config.get | runtime.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 }andui.state.remove { slot, id, session_id? }. Gated byruntime.workerand the(slot, id)being declared in the manifest: no dedicateduicapability is introduced. Thepayloadis 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 asession_id; global slots must not carry one. The text-based slots (status-bar,row-badge,detail-badge) accept optionalicon(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) andhref(when set, the badge renders as a link that opens in a new tab; onlyhttp/httpsURLs are followed).ui.notify { tone, title, body?, session_id? }. Gated by the existingnotificationscapability (not a slot declaration). Returns a monotonicseq.
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-badgealso acceptsitems: BadgeItem[]whereBadgeItem = { text?, icon?, tone?, href?, tooltip? }. Each item renders as a compact, tone-tinted icon (falling back totext), linked whenhrefis a safe URL. The single{ text, tone, tooltip, icon, href }form still works. An emptyitems: []clears the row. -
panealso acceptsblocks: 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;collapsiblewraps the section in a native<details>the user can fold, andcollapsedstarts 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), andaction { label, method, icon? }(a button that forwardsmethodto the plugin’s worker, see below). A block’s optionalcoloris a validated hex literal (#rgb/#rrggbb, normalized; no CSS names,rgb(),var(), orurl(), so it can never carry arbitrary CSS) that tints the block’s icon/value where a semantictonecannot name the hue, e.g. a merged PR’s purple. The simple{ title, body }form still works whenblocksis absent. Apanealso takes an optionaldefault_location(right|bottom) choosing the dock it first opens in; the user can move it between docks afterward, and an optionalicon(any lucide icon name, kebab-case) for its activity-bar button, falling back to a generic plugin icon. The host renders eachpaneas a dockable tool-window (activity-bar toggle, move, close) alongside the built-in diff and terminal panes. Each pane’s body scrolls, and a longcommentbody 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.