Skip to main content

Module daemon_plugins

Module daemon_plugins 

Source
Expand description

§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). The examples heartbeat_daemon.rs (a daemon of its own) and together_companion.rs (a companion) show both.

§Where it runs

The manifest’s daemon section says where (manifest); 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.
  • 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.
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).

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.

InterfaceSDKGrant
daemon-processdaemon::process::{spawn, Command}process.exec:<program> naming the program exactly as spawned (process.exec:* for any), or machine.full
daemon-watchdaemon::watch::{watch, watch_with}fs.read:<path> covering the path, or machine.full
daemon-panesdaemon::panespanes.read to list, subscribe and wait; panes.write to create (NewPane::title names the pane), type and close
WASI files (std::fs)the wasi featureexactly 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 featurenetwork.full or machine.full
A WebSocket the host holds (daemon::net::WebSocket)alwayssocket.connect:<host>:<port>
wasi:httpdaemon::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).
  • 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).