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; withfailoverthe 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 withstale_epoch, which also stops it), and the new holder starts it under the new epoch. requires.os(in the account manifest’sdaemonentry) names the operating systems it may run on; a daemon advertisesos.macosoros.linuxas 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::runis an async loop on the SDK’s executor. The host callsactivate, then the component’sdrive(now-ms), and parks the plugin’s thread until the instantdriveasked 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 exporteddaemon-eventsinterface; 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_controlleris 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;
memoryMibin 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 currentInterest:any(),shows("card"),surfaces(). Changes arrive asEvent::Interest.- Until the host has said anything (an account service without the
signal), the interest is unknown and
any()/shows()answertrue, so the plugin keeps working there. Context::next_event_until(deadline)waits for the next event or the deadline (Nonefor 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 withTarget::Machineand finds inaccount::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);Nonefor a fleet plugin.Context::release(): the channel, version and commit of the Standard build this daemon runs (Nonefrom a host that does not know them).Release::git_ref()names the branch that channel follows, asrefs/heads/<branch>for abranch:channel andrefs/heads/mainotherwise.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,PATHwith its version managers) reduced toHOME,USER,LOGNAME,PATH,LANG, everyLC_*,TMPDIRandSHELL; undermachine.full, all of it. The daemon’s own environment never reaches a plugin. When mise’s shims directory exists (underMISE_DATA_DIR,$XDG_DATA_HOME/miseor~/.local/share/mise),PATHdrops mise’s install directories and ends with the shims directory. Without mise’s activation variables,mise xwould otherwise find a~/.local/binstub that runsmise 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.
| Interface | SDK | 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.
Commandsets 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 asEvent::ProcessOutput, input throughChild::write) orStdio::Log(the daemon’s log, prefixed with the plugin id). By default stdin isStdio::Null, so a tool that prompts reads end of file; stdout and stderr areStdio::Log.Event::ProcessExitedfollows 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: falsehears only the directory’s own entries, andexcludeglobs (relative to the watched path:*within a component,?one character,**any components; a bare name such astarget,node_modulesor*.logmatches at any depth, one with/is anchored, such as.git/objects, and a leading/anchors a bare name at the watched path as.gitignoredoes:/targetdrops the root’stargetbut notcrates/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 asEvent::WatchFailedand 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
cwdis 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’spane_current_paththen), or an agent hook whose payload names acwd. A plaincdat a shell prompt shows once the next such event fires.panes::subscribe()deliversEvent::PaneChanged(created, changed, exited, closed; a new directory ischanged);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()givesCreated { id, generation }(key()is<id>@<generation>);panes::create(cwd, command)is the short form. - HTTP.
daemon::http::Request::new(method, url)(orget/post) takes request headers, a body (json(&value)sets both), a timeout for the whole exchange (timeout_ms, 30 s by default; past itError::Unavailable("timeout")) and a cap on the response body (max_body, 4 MiB by default; a longerContent-Lengthor stream isError::Invalid);send().awaitanswers the status, the headers and the body. A body goes with itsContent-Length. The host refuses a host nofetch:grant names before connecting, and the plugin seesError::GrantDenied { grant: "fetch:<host>" }. Undercargo testthe 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).