# Daemon plugins
A daemon plugin runs inside `standardd`, the Standard Code daemon, never
renders, and reaches the machine through granted system interfaces. Kind
`daemon` for a plugin of its own, `companion` for the daemon half of a UI
plugin with the same id ([working together](working-together.md)). The
examples [`heartbeat_daemon.rs`](../examples/heartbeat_daemon.rs) (a
daemon of its own) and [`together_companion.rs`](../examples/together_companion.rs)
(a companion) show both.
## Where it runs
The manifest's `daemon` section says where
([manifest](manifest.md#where-a-daemon-runs)); `pack` carries it into the
account's install record, which the daemons follow:
- **fleet** (the default): on every daemon of the account.
- **singleton**: only on the machine holding the plugin's lease, pinned
when it is installed
(`standard plugin install --machine`) and moved from the Plugins view;
with `failover` the account moves it while that machine is offline. The daemon opens the plugin's account session
with the lease epoch and every write carries it; when the lease moves, the
old holder stops the plugin at once (the account tells it, and a write it
still makes is refused with `stale_epoch`, which also stops it), and the
new holder starts it under the new epoch.
- **`requires.os`** (in the account manifest's `daemon` entry) names the
operating systems it may run on; a daemon advertises `os.macos` or
`os.linux` as a machine capability at enrollment.
Each daemon reports to the account how its plugins run: ready once a
version runs, failed with the reason (a package without a daemon half, one
that does not verify, a plugin that kept trapping), and the plugin's own
health. The install progress names each machine as it starts the plugin,
fails, or stays silent (after 60 s, with a hint), and offers Restart when
one did not start.
## Health
`health::set(Health::Degraded, "codex: not signed in")` (or
`health::ok()`, `degraded(msg)`, `failed(msg)`; no grant, both worlds)
reports how the plugin is doing, beyond whether it runs. Each report
replaces the last; 200 characters of message are kept. A daemon plugin
that reported nothing (or `ok`) while one of its file watches stopped
shows `degraded` with the watcher's error. `standard plugin health`
prints the state and health of every daemon plugin on its machine, and the
Plugins view lists each half's report under its plugin (the UI half's from that viewer, the daemon half's
from its machine).
## The run loop
- `DaemonPlugin::run` is an async loop on the SDK's executor. The host calls
`activate`, then the component's `drive(now-ms)`, and parks the plugin's
thread until the instant `drive` asked for, an event, a call or a stop.
An idle plugin costs no wakes. Example:
[`examples/heartbeat_daemon.rs`](../examples/heartbeat_daemon.rs).
- Events (`Context::next_event`): account events (`Event::Live`,
`Event::Plugin`, `Event::ValueChanged`, `Event::AccountChanged`) and
machine events (`Event::FileChanged`, `Event::WatchFailed`,
`Event::ProcessOutput`, `Event::ProcessExited`, `Event::PaneChanged`), in
order. The component receives machine events through its exported
`daemon-events` interface; the SDK merges both into one queue.
- Calls from the UI half arrive at `DaemonPlugin::call`;
`Context::caller()` says who asked (machine, plugin, kind, and the
account's controller lease at call time: `Caller::from_controller` is
true when the call came from the machine the user was controlling). A
call for another plugin never reaches this one.
- Budgets: 5 s of CPU per `activate`, `drive`, event and call; time blocked
in a host call (a child's exit, a request) is not CPU time. A call over
budget is interrupted.
- Traps: a trap restarts the plugin after 500 ms, then 1 s, then 2 s; a
fourth trap leaves it `Failed` (reported to the account). A stop
(uninstall, a moved lease, the daemon shutting down) interrupts a running
call and kills the plugin's children.
- Memory: 128 MiB per plugin; `memoryMib` in the manifest may ask for less.
- Priority: every plugin thread runs at background priority.
## Reading only while someone looks
A daemon half that reads an outside source for its UI half (a coordinator,
an API, a CPU sampler) reads it only while some viewer shows that UI. The
host tells it which of the plugin's UI surfaces the account's viewers show
now: each viewer reports its own shown surfaces to the account when that
set changes (never on a timer), the account keeps the union over every
viewer and pushes it to the plugin's daemon sessions when the union
changes, and a viewer that closes or loses its connection stops counting at
once. An instance counts as its surface (`workers@<machine>` is `workers`).
- `Context::interest()` is the current `Interest`: `any()`,
`shows("card")`, `surfaces()`. Changes arrive as `Event::Interest`.
- Until the host has said anything (an account service without the
signal), the interest is unknown and `any()`/`shows()` answer `true`, so
the plugin keeps working there.
- `Context::next_event_until(deadline)` waits for the next event or the
deadline (`None` for no deadline), whichever comes first, and leaves no
timer behind when the event wins: the one wait a read-on-a-schedule loop
needs.
```rust
async fn run(&self, cx: Context) {
let mut due = 0;
loop {
let reading = cx.interest().shows("card");
if reading && cx.now_ms() >= due {
self.read(&cx).await; // publish only what changed
due = cx.now_ms() + 30_000;
}
// Nobody looking: no deadline, no wakes until the interest changes.
let _ = cx.next_event_until(reading.then_some(due)).await;
}
}
```
A UI half never keeps its daemon reading with a timer, a heartbeat or a
repeated `calls.send`: it sends a call only for what the user did (open a
build, load more, reconnect) and otherwise reacts to what the daemon
publishes. Writes the plugin repeats unchanged never leave the host
(`live.publish` of what the key already carries, a watched `values.set` of
what the key holds), but publish only on change anyway: the host still pays
the serialization.
The interest travels as the plugin event `system.plugin.interest`
(`{ "surfaces": [...] }`); the SDK turns it into `Event::Interest`, and a
component built without it ignores the name.
## Identity and environment
`daemon-context` (no grant) says where the plugin runs:
- `Context::machine_id()`: the account's id for this machine, the id a UI
half targets with `Target::Machine` and finds in `account::state()`.
Nothing needs to tell a daemon which machine it is.
- `Context::account_id()`: the account the daemon is enrolled in, once the
daemon has read its install set.
- `Context::lease_epoch()`: the lease epoch a singleton runs under (every
write of its account session carries it, and a later one means the lease
moved and this instance is stopping); `None` for a fleet plugin.
- `Context::release()`: the channel, version and commit of the Standard
build this daemon runs (`None` from a host that does not know them).
`Release::git_ref()` names the branch that channel follows, as
`refs/heads/<branch>` for a `branch:` channel and `refs/heads/main`
otherwise.
- `daemon::env::{vars, var, home_dir}`: the plugin's environment. It is the
daemon user's login environment (what a new terminal's shell sees, `PATH`
with its version managers) reduced to `HOME`, `USER`, `LOGNAME`, `PATH`,
`LANG`, every `LC_*`, `TMPDIR` and `SHELL`; under `machine.full`, all of
it. The daemon's own environment never reaches a plugin. When mise's
shims directory exists (under `MISE_DATA_DIR`, `$XDG_DATA_HOME/mise` or
`~/.local/share/mise`), `PATH` drops mise's install directories and ends
with the shims directory. Without mise's activation variables, `mise x`
would otherwise find a `~/.local/bin` stub that runs `mise x <tool>`
ahead of the tool and run the stub again forever.
WASI sees the same variables, and `PWD` and the initial directory are the
home directory (a relative path still resolves only inside a preopen).
Children inherit the plugin's environment and run in the home directory
unless `Command::cwd` names another; `Command::env` adds variables,
`Command::env_remove` removes inherited ones (`GIT_DIR`, say), and
`Command::clear_env` starts from nothing but `env`.
Children get no shell activation: no rc files, aliases, or the variables
a version manager (mise, asdf, direnv) sets when a shell starts. A tool
that works in a terminal can fail or loop under the daemon. On Omarchy,
`~/.local/bin` stubs that run `mise x` resolve back to themselves and loop.
Extend
`PATH` with the usual install folders after the inherited ones, and give
every child a timeout ([agent guide](agent-guide.md#5-daemon-children-get-a-reduced-environment)).
Clocks: WASI's wall clock (`std::time::SystemTime` with the `wasi`
feature, or `wasi:clocks/wall-clock`) is the machine's; the monotonic
clock is the one `drive(now-ms)` and `Context::now_ms` count on.
## System interfaces
Every call is checked against the approved grants; a call no grant covers
returns `Error::GrantDenied { grant }` naming the grant.
| `daemon-process` | `daemon::process::{spawn, Command}` | `process.exec:<program>` naming the program exactly as spawned (`process.exec:*` for any), or `machine.full` |
| `daemon-watch` | `daemon::watch::{watch, watch_with}` | `fs.read:<path>` covering the path, or `machine.full` |
| `daemon-panes` | `daemon::panes` | `panes.read` to list, subscribe and wait; `panes.write` to create (`NewPane::title` names the pane), type and close |
| WASI files (`std::fs`) | the `wasi` feature | exactly the directories `fs.read:` (read-only) and `fs.write:` (read-write) grants name are preopened; `/` and the home directory under `machine.full` |
| WASI sockets (`std::net`) | the `wasi` feature | `network.full` or `machine.full` |
| A WebSocket the host holds (`daemon::net::WebSocket`) | always | `socket.connect:<host>:<port>` |
| `wasi:http` | `daemon::http::{Request, get}` | `fetch:<host>` (or `fetch:<scheme>://<host>[:<port>]`), or `network.full`/`machine.full` |
- **Processes.** Each child leads its own process group. `Command` sets
arguments, environment (on top of the plugin's, see above), the
directory (the home directory by default) and each stream: `Stdio::Null`, `Stdio::Piped` (output arrives
as `Event::ProcessOutput`, input through `Child::write`) or `Stdio::Log`
(the daemon's log, prefixed with the plugin id). By default stdin is
`Stdio::Null`, so a tool that prompts reads end of file; stdout and
stderr are `Stdio::Log`. `Event::ProcessExited`
follows the last output. Every running group is killed when the plugin
stops or restarts.
A completed child releases its input pipe before its exit event.
- **Watches.** The platform's native notifier (FSEvents, inotify), never a
polling watcher; changes within 100 ms arrive as one
`Event::FileChanged { watch, paths }`. `watch::watch(path)` covers a
directory's whole tree; `watch::watch_with(path, &Options)` chooses:
`recursive: false` hears only the directory's own entries, and
`exclude` globs (relative to the watched path: `*` within a component,
`?` one character, `**` any components; a bare name such as `target`,
`node_modules` or `*.log` matches at any depth, one with `/` is anchored,
such as `.git/objects`, and a leading `/` anchors a bare name at the
watched path as `.gitignore` does: `/target` drops the root's `target`
but not `crates/x/target`) drop changes under what they match before they
become events. The notifier still watches the whole tree (FSEvents
watches a tree as one stream; inotify adds a watch per directory,
excluded ones too). A plugin holds at most 256 watches. A notifier error
arrives as `Event::WatchFailed` and is kept as the plugin's health. Paths
are checked by whole components after resolving symlinks: a link inside
a granted directory cannot lead out of it.
- **Panes.** This machine's panes with their title, size, project, current
directory and agent state. A pane's `cwd` is the directory its shell or
agent works in, else its project root while the daemon has not learned
one. The daemon learns it only when an event already fires for the pane:
its creation or restart, a new foreground program (tmux's
`pane_current_path` then), or an agent hook whose payload names a `cwd`.
A plain `cd` at a shell prompt shows once the next such event fires.
`panes::subscribe()` delivers `Event::PaneChanged`
(created, changed, exited, closed; a new directory is `changed`);
`panes::wait(pane, timeout_ms)`
blocks until one ends (30 s at most). Creating, typing and closing go
through the daemon's own pane owner with the controller fence it holds:
they succeed only while this machine controls the account. A new pane
opens in a project root of this machine or any directory inside one
(whole components after symlinks; the deepest root names its project),
runs its command through the user's login shell (or the shell itself),
with variables on top of its login environment, and answers its id and
generation: `NewPane::new(cwd).command("pnpm dev").env("PORT", "4000")
.create()` gives `Created { id, generation }` (`key()` is
`<id>@<generation>`); `panes::create(cwd, command)` is the short form.
- **HTTP.** `daemon::http::Request::new(method, url)` (or `get`/`post`)
takes request headers, a body (`json(&value)` sets both), a timeout for
the whole exchange (`timeout_ms`, 30 s by default; past it
`Error::Unavailable("timeout")`) and a cap on the response body
(`max_body`, 4 MiB by default; a longer `Content-Length` or stream is
`Error::Invalid`); `send().await` answers the status, the headers and the
body. A body goes with its `Content-Length`. The host refuses a host no
`fetch:` grant names before connecting, and the plugin sees
`Error::GrantDenied { grant: "fetch:<host>" }`. Under `cargo test` the
same client answers from responses the test queued
([testing](testing.md)).
- **WASI.** stdout and stderr go to the daemon's log with the plugin id;
there is no stdin and no argument but the plugin id; the environment is
the plugin's (above). Clocks and random are WASI's.
## Account interfaces
`values`, `live`, `events`, `calls`, `claims`, `config`, `secrets` and
`account` work as in a UI plugin, over the daemon's own account
connection. A
fleet plugin runs on every daemon, so an effect that must happen once
(a notification, a request another daemon would repeat) is claimed first:
`cx.claims().once(key, ttl_ms, || ...)`, exactly as a UI plugin does across
viewers; a singleton's claims carry its lease epoch. A fleet plugin
listening to `system.panes.*` or `system.agents.*` hears this machine's
events; a singleton hears the whole account's. `account.state()` is the
daemon's view of the account's machines, projects and panes.
A daemon plugin starts before its account connection opens, so its first
writes can fail. Keep what you last wrote successfully and write again on
later events ([working together](working-together.md#writes-before-the-account-connection-opens)).