# Plugin System Animus plugins are standalone executables that communicate with the host over JSON-RPC 2.0 on stdin/stdout. The canonical framing is one JSON value per line (NDJSON), and current host-side readers also accept pretty-printed multi-line frames from plugins. The host never loads third-party code into the daemon process as a dynamic library. ## Source Files | Area | Source | |---|---| | Wire protocol types | [`crates/animus-plugin-protocol/src/lib.rs`](../../crates/animus-plugin-protocol/src/lib.rs) | | External provider/session protocol crates | `animus-provider-protocol`, `animus-session-backend` from `launchapp-dev/animus-protocol` declared in the workspace `Cargo.toml` | | Plugin discovery | [`crates/orchestrator-plugin-host/src/discovery.rs`](../../crates/orchestrator-plugin-host/src/discovery.rs) | | Plugin host/router | [`crates/orchestrator-plugin-host/src/host.rs`](../../crates/orchestrator-plugin-host/src/host.rs) | | Subject router | [`crates/orchestrator-plugin-host/src/subject_router.rs`](../../crates/orchestrator-plugin-host/src/subject_router.rs) | | Lockfile and signature checks | [`crates/orchestrator-plugin-host/src/lockfile.rs`](../../crates/orchestrator-plugin-host/src/lockfile.rs), [`crates/orchestrator-plugin-host/src/signature_verifier.rs`](../../crates/orchestrator-plugin-host/src/signature_verifier.rs) | | Provider session bridge | [`crates/orchestrator-plugin-host/src/session/`](../../crates/orchestrator-plugin-host/src/session/) | | Curated default plugins | [`crates/orchestrator-core/src/plugin_registry.rs`](../../crates/orchestrator-core/src/plugin_registry.rs) | | Web plugin resolution | [`crates/orchestrator-cli/src/services/operations/ops_web.rs`](../../crates/orchestrator-cli/src/services/operations/ops_web.rs) | The daemon supervises each plugin as an out-of-process child and talks to it only over stdin/stdout JSON-RPC; no third-party code is linked into the daemon: ```mermaid graph LR subgraph DaemonProc["daemon process"] HOST["PluginHost
(single stdout reader + pending-response map)"] end subgraph PluginProc["plugin process (env_clear'd)"] PLUGIN["plugin binary"] end HOST -- "JSON-RPC request (stdin)" --> PLUGIN PLUGIN -- "response / notification (stdout)" --> HOST ``` ## Design Rules - Compatibility is defined by the wire protocol, not Rust crate linkage. - Discovery uses `--manifest`; runtime use starts with `initialize`. - Plugin processes start with `env_clear()`. - The host forwards only a small base env allowlist, manifest-declared env vars, and request-local extras. - Daemon-managed subject, provider, trigger, log-storage, and health-probe spawns pin plugin cwd to `project_root` so cwd-relative state resolves predictably under `.animus/`. - Manifest probe failures become discovery warnings so operators can see why a binary was skipped. - Install state records enough metadata to explain where a plugin came from and which binary hash was approved. ## Plugin Kinds | Kind | Role | Required method family | |---|---|---| | `provider` | Drives an AI provider or CLI | `agent/run`, `agent/resume`, `agent/cancel`, `health/check` | | `subject_backend` | Lists, reads, updates, and optionally watches work subjects | Host routes kind-scoped calls such as `/list`, `/get`, `/update`; control surfaces expose `subject/*` | | `trigger_backend` | Watches external event sources and emits dispatchable trigger events | `trigger/watch`, `trigger/event`, `trigger/ack` | | `transport_backend` | Hosts an inbound control transport such as HTTP or GraphQL | `transport/start`, `transport/info` or initialize metadata, `transport/shutdown`, `health/check` | | `web_ui` | Legacy/compat UI wrapper kind | Treated as a browser-facing transport by `animus web` | | `log_storage_backend` | Receives and serves structured log entries | `log/entry`, `log_storage/tail` | | `custom` | Plugin-owned behavior, usually bridged to MCP | Any plugin-defined method plus lifecycle methods | | `task_backend` | Legacy alias | Reserved; new plugins should use `subject_backend` | `transport_backend` plugins that serve a browser UI advertise the `$ui/web` capability. `animus web open` uses that marker to prefer a UI URL over a raw API URL. ## Wire Protocol Runtime communication uses JSON-RPC 2.0 over stdio. Canonical host writes stay newline-delimited; host readers also accept pretty-printed multi-line frames from plugins: 1. Host spawns plugin. 2. Host sends `initialize`. 3. Plugin returns `InitializeResult` with protocol version, plugin info, and capabilities. 4. Host sends `initialized`. 5. Host sends plugin-specific requests. 6. Plugin sends responses and may emit notifications. `PluginHost` owns a single reader task for plugin stdout. Responses are matched to pending JSON-RPC ids and notifications are broadcast to subscribers. This is documented in [Plugin Host Concurrency](plugin-host-concurrency.md). When the host binds a plugin to a project-scoped runtime, the `initialize` params include `init_extensions.project_binding` and may also include `init_extensions.memory_mcp_stdio_command.command`. The latter is an optional host hint for plugins that need to launch the memory MCP sidecar using the same `animus` binary path as the parent process. Plugins that do not use it should ignore it. ## Discovery Order `discover_plugins(project_root)` uses this order: 1. Registry entries from `~/.animus/plugins.yaml`. - Legacy fallback: `~/.config/animus/plugins.yaml` is read only when the new registry is absent and `ANIMUS_CONFIG_DIR` is not set. 2. Project-local plugin binaries in `/.animus/plugins/`. 3. The global install dir: `$ANIMUS_PLUGIN_DIR` when explicitly set, otherwise `~/.animus/plugins/`. 4. `$ANIMUS_PLUGIN_PATH`. 5. `$PATH`, only when the caller opts into `--include-system-path`. Directory scans consider executable names beginning with `animus-plugin-` or `animus-provider-`. Registry entries may point to binaries with any name. Plugin names are deduplicated by first match in the precedence chain, and a broken higher-precedence entry still reserves its name so a lower-precedence copy cannot silently shadow it. Manifest probes are hardened: - stdin is closed - stdout and stderr are capped at 1 MiB - probe timeout is 5 seconds - plugin env is scrubbed to the base allowlist - failures become `DiscoveryWarning` rows ## Installation State `animus plugin install` writes three forms of state: | State | Path | Purpose | |---|---|---| | Binary | `~/.animus/plugins/` or `--plugin-dir` | Executable plugin artifact | | Registry | `~/.animus/plugins.yaml` | Logical name, binary path, install metadata, signature status | | Lockfile | `/.animus/plugins.lock` or `~/.animus/plugins.lock` | Approved version, artifact sha256, optional signature-bundle sha256 | The lockfile prevents silent binary replacement during later upgrades. `animus plugin lock verify` re-hashes installed binaries and reports mismatches. Supported install sources: - public GitHub release: `animus plugin install owner/repo[@tag]` - local binary: `animus plugin install --path ./target/release/my-plugin` - direct HTTPS URL: `animus plugin install --url --sha256 ` Direct URL installs require an expected SHA256. Release installs resolve the platform-specific asset, verify SHA256 and signature policy, probe `--manifest`, then copy the binary into the install directory. ## Default Plugin Sets The curated launchapp-dev defaults are defined in `orchestrator-core::plugin_registry` so daemon preflight and `animus plugin install-defaults` cannot drift. | Flag | Installs | |---|---| | no extra flag | provider plugins: Claude, Codex, Gemini, OpenCode, OAI | | `--include-oai-agent` | optional OAI agent provider | | `--include-subjects` | default task, requirements, Linear, SQLite, and markdown subject plugins | | `--include-transports` | HTTP transport, GraphQL transport, and web UI | Daemon default preflight requires: - at least one provider plugin - at least one subject backend plugin - a `workflow_runner` plugin - a `queue` plugin Every required role must resolve to an installed plugin before the daemon will start autonomous work; any unsatisfied role aborts startup with the exact install command: ```mermaid flowchart TD START["daemon start / run"] --> PF{"preflight
(unless --skip-preflight)"} PF --> R1{"at least one provider?"} PF --> R2{"at least one subject_backend?"} PF --> R3{"workflow_runner present?"} PF --> R4{"queue present?"} R1 -->|no| FAIL["abort + print
animus plugin install ..."] R2 -->|no| FAIL R3 -->|no| FAIL R4 -->|no| FAIL R1 -->|yes| OK R2 -->|yes| OK R3 -->|yes| OK R4 -->|yes| OK OK["all roles satisfied"] --> BOOT["boot daemon loop"] FAIL -.->|--auto-install| BOOT ``` `--auto-install` installs the curated defaults for unsatisfied roles. ## Security Boundary Plugin processes start with `env_clear()`. The host forwards: - base variables: `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `TMPDIR`, `LANG`, `LC_ALL`, `RUST_LOG`, `RUST_BACKTRACE`, `TZ` - variables declared in `PluginManifest.env_required` - request-local extras explicitly supplied by the provider session request If a required declared variable is missing, the host logs a warning but still spawns the plugin. The plugin decides whether startup should fail. Release-source installs support cosign keyless verification. Policy modes: | Mode | Behavior | |---|---| | `strict` | Missing, invalid, or untrusted signatures fail the install | | `warn` | Verification failures are recorded and warned, but install continues | | `disabled` | Signature verification is skipped | Additional install guards: - manifest name should match the repository basename unless `--force` is used - unknown GitHub owners trigger trust-on-first-use confirmation - first-party provider tool names are reserved; installing a plugin that claims them requires `--allow-shadow-builtin` ## Runtime Hosting `PluginHost` owns one live plugin process: - a single reader task consumes stdout - requests are written with JSON-RPC ids - pending responses are resolved by id - notifications go to a broadcast channel - shutdown resolves outstanding requests The broadcast channel capacity defaults to 256 and can be overridden with: ```bash ANIMUS_PLUGIN_BROADCAST_CAPACITY=512 ``` When `PluginSpawnOptions.working_dir` is set, the host calls `Command::current_dir(...)` before spawn. Daemon-owned runtime paths set this to the resolved `project_root`; ad-hoc plugin commands such as `animus plugin ping` and `animus plugin call` still inherit the caller's cwd unless the command itself exposes a project-root override. ## Provider Path Provider plugins are driven by `orchestrator-plugin-host::session`. 1. Resolve the requested provider tool. 2. Discover `provider` plugins. 3. Spawn and initialize the chosen plugin. 4. Send `agent/run` or `agent/resume`. 5. Forward provider notifications as runner events. 6. Keep the active session host so `agent/cancel` reaches the same process. Provider dispatch binds the plugin cwd to the resolved `project_root`, so provider-owned state and any child CLI cwd-relative lookups stay anchored to the repository even when the daemon was started from some other shell directory. When a provider request fails, the dispatcher routes the `HostError` through `classify(&HostError) -> RetryDecision` to decide whether to respawn or surface the error. Death-like failures (`ConnectionLost`, `Timeout`, `ProcessExited`, and unclassified RPC errors) trigger a supervised retry; structured plugin-side JSON-RPC errors are surfaced to the caller without spending restart budget, and exceeding the supervisor's restart budget fails with `TooManyRestarts`: ```mermaid flowchart TD REQ["plugin request"] --> RESULT{"result?"} RESULT -->|Ok| DONE["return result"] RESULT -->|"Err(HostError)"| CLASSIFY["classify(&HostError)"] CLASSIFY -->|StructuredError| SURFACE["surface to caller
(no restart spent)"] CLASSIFY -->|"DeathLike
(ConnectionLost / Timeout / ProcessExited)"| BUDGET{"restart budget left?"} BUDGET -->|yes| RESPAWN["respawn + retry once"] BUDGET -->|no| TOOMANY["SupervisorError::TooManyRestarts"] RESPAWN --> REQ ``` There is no in-tree provider fallback. Missing providers return a hard error with the install command. The resolver canonicalizes `oai-runner` and `animus-oai-runner` to `oai`. Reserved provider names are `claude`, `codex`, `gemini`, `opencode`, `oai`, and `oai-runner`. ## Subject Path Subject backends expose normalized work items through the `animus-subject-protocol` schema. The operator and daemon control surfaces use generic verbs such as `subject/list` and `subject/get`; the control dispatcher adapts those calls to the kind-scoped plugin methods used by the router. The host-side router maps subject kinds to initialized plugin hosts: - exact kind registrations win over globs - glob registrations use `kind.*` - longest glob prefix wins - duplicate exact kinds and duplicate glob prefixes fail router setup The current CLI and daemon path relies on these kind-scoped plugin calls: | Plugin method | Purpose | |---|---| | `/list` | Return filtered subjects for dispatch or CLI listing | | `/get` | Fetch one subject | | `/create` | Create one subject when the backend supports mutation | | `/update` | Apply a merge-style patch | | `/next` | Return the next runnable subject for a kind | | `/status` | Change a subject's status | Protocol-level subject backends may also expose schema and watch capabilities, but current routing decisions are made from the initialized plugin manifest and `capabilities.subject_kinds`. Subject backend spawns also pin cwd to `project_root`. Backends that persist state via relative paths such as `.animus/subjects/tasks.db` therefore resolve those paths under the repository root instead of under the daemon launch shell. ## Trigger Path Trigger plugins are long-lived watchers. The daemon sends `trigger/watch` with optional cursor and config, then receives `trigger/event` notifications. Each event carries an event id, optional trigger id, optional subject id/kind, optional action hint, and plugin-owned payload. The host sends `trigger/ack` after accepting an event. The trigger supervisor pins cwd to `project_root` for the same reason: any plugin-relative checkpoints or repo-local config reads stay deterministic. Trigger supervision has a daemon kill switch: ```bash ANIMUS_DAEMON_DISABLE_TRIGGERS=1 animus daemon start ``` ## Transport and Web Path `animus web serve` and `animus web open` use normal plugin discovery, then partition discovered plugins into: - `transport_backend` - `web_ui` The web command starts transport plugins and opens the UI URL advertised by a plugin with `$ui/web` capability. There is no in-tree web server. Spawn uses the plugin manifest's `env_required` contract, so missing required vars fail fast before handshake the same way `animus plugin info`, `ping`, and `call` do. ## Operations Useful operator commands: ```bash animus plugin list animus plugin install-defaults --include-subjects --include-transports animus plugin info animus plugin call health/check --json '{}' animus plugin lock verify animus daemon preflight ``` ## Tests Use these focused checks when changing plugin behavior: ```bash cargo test -p orchestrator-plugin-host cargo test -p orchestrator-cli plugin cargo animus-bin-check ```