Control Plane & Persistent Daemon
This chapter covers the machinery that turned Intendant’s controller from a
single agent loop into a multi-session orchestration host: the single-writer
control plane, the long-lived session supervisor, the task
dispatcher, the file-watcher that powers rewind/redo, the headless
daemon that an idle --web launch becomes, Agenda scheduling, and cost
accounting.
For the system-wide picture and the EventBus that ties these together, start with Architecture.
Why a Single-Writer Control Plane
Intendant has three frontends (web dashboard, MCP server, control socket)
and they can all be live at once against the same daemon. If each frontend wrote
shared mutable state directly — “the dashboard sets autonomy to High, MCP
sets it to Low” — the truth would depend on event ordering and which handler
happened to run. Worse, some state (the active external-agent backend, Codex
sandbox/model config) also has to persist to intendant.toml, and you do not
want three frontends racing to rewrite the same file.
The fix is a hard rule, stated at the top of control_plane.rs:
Frontends remain display-only — they render state changes but never write to shared state from
ControlMsghandlers.
Frontends emit intents (ControlMsg, defined in event.rs) onto the
EventBus. Exactly one subscriber — the control plane — interprets the
state-mutating ones and applies them. Everyone else (including the frontend that
sent the intent) learns the result by observing the broadcast event the
control plane emits afterward (AutonomyChanged, ExternalAgentChanged,
CodexConfigChanged, …).
Stated honestly, the invariant covers the intent path: no frontend
mutates shared state from a ControlMsg handler. It is not a literal
single-writer over every piece of shared state — three documented paths
write from their own tasks: approval side effects (apply_user_approval
applies approve-all escalation and the first display-control grant to the
shared autonomy state, identically from every approval surface), the MCP
autonomy/display tools, and platform display activation. Those are
deliberate carve-outs with one owner each; new shared-state writers
belong in the control plane.
Web ─┐ emit ControlMsg ┌──────────────┐ write ┌──────────────┐
MCP ─┼───────────────▶ EventBus ─┤ Control Plane │──────────▶│ shared state │
Sock ─┘ (bus) │ (sole writer) │ │ + intendant │
◀───── observe ────────────┤ │◀──────────│ .toml │
AutonomyChanged etc. └──────────────┘ └──────────────┘
What the control plane owns
ControlPlaneState (in control_plane.rs) holds the shared, mutable runtime
state:
| Field | Type | Notes |
|---|---|---|
autonomy | SharedAutonomy | Global autonomy level + the user-display grant flag |
external_agent | Arc<RwLock<Option<AgentBackend>>> | Active backend: Codex / Claude Code / Kimi / Pi, or None (internal) |
codex_config | SharedCodexConfig | Runtime Codex config, including ordinary/managed commands, sandbox, approval policy, model/reasoning/service tier, web/network/write access, and managed-context/archive modes |
claude_config | SharedClaudeConfig | Runtime Claude Code config (model, permission mode, allowed tools) |
kimi_config | SharedKimiConfig | Runtime Kimi config (command, model, thinking, permission mode, plan mode, swarm mode) |
project_root | Option<PathBuf> | When set, state changes also persist to intendant.toml |
Pi is deliberately not another shared config field. Its global defaults live
in [agent.pi]; new sessions seed from that TOML, while reattached sessions
merge the current TOML base with their persisted launch overlay (the overlay
wins). Live model/thinking changes use Pi RPC and persist in that overlay;
command/tool changes require restart. This keeps a replaceable engine’s
configuration below the platform boundary instead of mirroring it throughout
the control plane.
It is spawned once during each controller startup path
(startup/daemon.rs or startup/headless.rs). spawn takes the state and
opens the bus’s lossless intent subscription internally:
#![allow(unused)]
fn main() {
let _control_plane_handle = control_plane::spawn(
control_plane::ControlPlaneState {
autonomy, external_agent, codex_config, claude_config, kimi_config,
bus, project_root
},
);
}
The “applies on the NEXT task” rule
A subtlety worth internalizing: external-agent launch settings latch at process
spawn. Codex locks its sandbox / approval / model / tool configuration at
thread/start; Claude Code likewise latches model, permission mode, and allowed
tools when the CLI process is launched. Kimi latches its command at process
start, but can apply model, thinking, permission, plan, and swarm changes live
through its session profile. Pi latches command and active tools but applies
model and thinking live through RPC. So when a frontend flips, say, the Codex
sandbox mode or the Claude Code permission mode, the control plane updates the
shared config and persists it, but an already-running external-agent thread keeps
its old values for the rest of its life. The change takes effect on the next
task — the daemon loop re-reads the shared config at the start of each task and,
for changes that cannot be applied mid-session, tears down the persistent agent
so the next launch picks them up. Each ControlMsg::SetCodex*,
ControlMsg::SetClaude*, and ControlMsg::SetKimi* variant documents this in
its doc comment. Pi’s global Settings save goes straight to TOML rather than
adding a fourth family of daemon-wide backend-specific setters.
Thread actions apply immediately rather than next-task when the selected
backend supports them: /new, /compact, /fast, /fork, /undo,
/review, goals, and the backend-neutral subsets exposed by Claude Code,
Kimi, and Pi. The
wire variant retains its historical name CodexThreadAction, but the
advertised operation catalog is universal. The control plane does not own the
persistent agent, so it merely re-broadcasts these as
CodexThreadActionRequested for the daemon-side watcher that does own it.
Session Supervisor
session_supervisor/ is the long-lived owner of every session launched from
the control plane at runtime. Where the control plane owns settings, the
supervisor owns sessions — their lifecycle, their per-session resources, and
the graph of how they relate. mod.rs holds the supervisor/state types and
shared helpers; the behavior is sliced into dispatch.rs (ControlMsg intake),
exec.rs (the off-intake per-session executor), launch.rs (new/resume flows
and the shared session spawner), sub_agents.rs (delegation), fork.rs
(anchor forks), routing.rs (follow-up/steer/edit/stop), agent_config.rs
(per-session agent config and identity), and registry.rs (lifecycle
observation and registration).
The intake is two-tier. The supervisor drains its lossless intent lane
strictly in order, but dispatch_control_msg runs only the fast,
ordering-critical work inline: validate, mint/reserve session identity, and
dedup repeat requests. Slow launch bodies — session create (including a
worktree checkout), resume, restart, fork, dashboard delegation — execute on
a per-session ordered executor (exec.rs) under a small global concurrency
bound, so one session’s multi-second checkout no longer head-of-line-blocks
another session’s approvals, steers, or interrupts. Commands for one session
still run in arrival order: while a session’s launch body (or anything
deferred behind it) is pending, later commands for that session defer onto
its queue, and the identity minted at intake keeps the session addressable —
and its peer-delegation id deduplicated — before the slow body completes.
It subscribes to the bus and handles the session-lifecycle ControlMsgs:
| ControlMsg | Behavior |
|---|---|
CreateSession | Explicitly create a new managed session and submit its first task (the forward-compatible primitive for parallel sessions). A task of exactly /fast is special-cased into a new idle Codex session with the fast service tier enabled. |
StartTask { session_id: None } | Start a new managed session (legacy clients). A task of exactly /fast follows the same idle fast Codex bootstrap path as CreateSession. |
StartTask { session_id: Some(id) } | Route the text as a follow-up turn into the named session |
ResumeSession | Re-attach an existing session by source (intendant/codex/claude-code/kimi/pi) + id, optionally with a prompt |
FollowUp | Route text to a session’s follow-up channel (target id, or the active session) |
EditUserMessage | Rewind a session to a user turn and submit replacement text (rollback-capable backends only) |
Interrupt / Steer | Mid-turn control of a running session. If a steer body is a supported Codex slash command, the supervisor converts it to a Codex thread action instead of injecting it as model text. |
Approve/Deny/Skip/ApproveAll | Resolve a pending approval against the right session’s ApprovalRegistry |
RenameSession | Rename via the cross-backend naming abstraction (see Session Logging) |
Per-session state
Internally the supervisor keeps a SupervisorState:
SupervisorState {
sessions: HashMap<String, ManagedSession>, // canonical id → session
session_aliases: HashMap<String, String>, // alias id → canonical id
related_sessions: HashMap<String, RelatedSession>, // child id → {parent, relationship}
active_session_id: Option<String>, // the "current" session for un-targeted commands
next_session_instance: u64, // rejects stale task completions
restart_dedupe / external_attach_dedupe: …,
known_external_sessions / advertised_thread_actions: …,
delegation_receipts: …, // at-least-once peer-task dedup
unmanaged_user_halts: …, // stop-vs-auto-attach race guard
}
Each ManagedSession carries its session_id, source (intendant or the
external backend’s short name), optional display name, phase, project_root,
session_dir, a follow_up_tx channel, its own ApprovalRegistry, an instance
id and finish receiver for lifecycle races, delegation depth, and (for native
sessions) the child registry shared with wait_sub_agents.
When start_new_session runs, it:
- resolves a fresh session log directory (
SessionLog::resolve_path(None)→<state-root>/logs/<uuid>/, honoringINTENDANT_HOME) and opens theSessionLog; - resolves the project root (per-session override or the daemon’s default) and
loads the
Project. On a projectless daemon (see below) there is no default: aCreateSessionwithout an explicitproject_rootfails withSessionEnded { error_kind: "no_project" }— a structured class the dashboard turns into the project picker — instead of silently rooting the session at the daemon’s launch directory; - writes
session_meta.jsonand activates the shared active-session handle; - resolves the backend (one-shot
agentoverride → configured default → internal) and applies the runtime external-agent config; - resolves any dashboard attachments (frames/uploads) into agent content;
- spawns the agent session loop and emits
SessionStarted.
The session graph
The supervisor tracks relationships between sessions, not just a flat list.
The live supervisor’s alias graph accepts side and subagent
(apply_session_relationship folds those through apply_related_session).
That map lets a follow-up addressed to such a child id resolve to its canonical
parent session, and removing a parent prunes those children. Forks are
independent sessions rather than supervisor aliases: their fork edge is
emitted and persisted for the log/catalog/lineage views, alongside specialized
edges such as anchor-fork, rewind-restore/rewind-backout, and
fission-branch/fission-detached/fission-imported. This lets the dashboard
render related work without redirecting a fork’s messages to its parent.
Identity and relationship updates arrive over the bus as SessionIdentity and
SessionRelationship events and are also persisted to session logs.
active_session_id is the fallback target: an un-targeted FollowUp or
Interrupt resolves to it, which is how single-session frontends keep working
unchanged while multi-session clients address sessions explicitly by id.
Task Dispatch
task_dispatch.rs decides which channel a task goes to (it was pulled out
of the TUI frontend back when that existed, as part of making frontends
display-only). The Dispatcher owns up to three senders — presence_tx,
task_tx, follow_up_tx — and routes a StartTask/FollowUp like this:
- If the task is not direct and
presence_txexists → send the text to the presence layer, which decides whether to forward it as a real task (via its ownsubmit_tasktool) or answer in-line. - Else if
task_txexists → wrap in aTaskEnvelopeand send (preserving attachments / frame refs / display target).direct(and legacyorchestrate == Some(false)) forces this path. - Else if
follow_up_txexists → send a follow-up message (metadata dropped; non-presence mode has no CU-first routing anyway). - Else → warn and drop.
A task carrying metadata (attachments, reference frames, a display target) is
always forced onto task_tx even when non-direct, because presence’s text
channel cannot carry that metadata.
The dispatcher and the session supervisor coexist: the dispatcher serves the
legacy single-session loop and routes into channels it already owns, while
CreateSession and targeted multi-session commands are left to the supervisor.
A targeted command for a session the dispatcher does not own is simply ignored
by it, so the supervisor picks it up.
File Watcher, Snapshots, and Rewind
file_watcher.rs is a live filesystem watcher rooted at the project directory
(a projectless daemon starts no watcher — there is nothing to watch).
It works for all agent types — internal, Codex, Claude Code, Kimi, Pi —
because it watches the filesystem directly rather than diffing git, so an
external CLI’s edits show up the same as Intendant’s own. It does two jobs:
- Live change events. It emits
AppEvent::FileChanged { Created / Modified / Deleted }so the dashboard’s activity view can show per-file diffs as the agent works. - Per-round content-addressed history for rewind / redo / branching.
On every AppEvent::RoundComplete that belongs to its root, the watcher
records a HistoryRound capturing the full restorable state — supported,
non-ignored text files no larger than 1 MB — as a path → sha256 map, plus the
subset of paths that changed. A broader display/count mirror can include
inspected files that are not restorable. Rounds are
routed by the event’s project_root: a round emitted by a session working
a different root (a worktree sub-agent, an external session supervised
elsewhere) is skipped, while a round with no resolvable root fails open
and records as before. Content blobs are stored once in a content-addressed
objects/ directory, so identical content across rounds costs no extra disk.
Each round also records turn_count and native_message_count, which
conversation rollback uses to truncate the native conversation correctly.
The snapshot store lives inside the session log dir:
<state-root>/logs/<uuid>/file_snapshots/
├── baseline/ # initial text-file snapshot at session start
├── baseline_manifest.json # baseline metadata/fingerprints
├── objects/ # content-addressed blobs (sha256-named)
├── rounds/round_<id>/
│ └── manifest.json # full round maps or a maps_from_round backreference
├── history.json # slim format-2 index, heads/rounds/branches
└── store.lock # advisory snapshot-store lock
The public API on FileWatcher:
rollback(target_round_id)— restores every tracked path to that round’s recorded state, movescurrent_head_idback without truncating history (so redo stays available), and emitsAppEvent::RolledBack { from_id, to_id, files_reverted }. A new action after a rollback branches off the abandoned path and stores it inabandoned_branchesfor later pruning.redo()— movescurrent_head_idforward along the linear path, restoring file state, emittingAppEvent::Redone { to_id }.prune_abandoned()— drops abandoned branches and garbage-collects orphaned blobs, emittingAppEvent::HistoryPruned { branches_removed, bytes_freed }.
A soft byte cap bounds total snapshot size; once exceeded, pruning kicks in. The
dashboard exposes this as the rewind/redo UI; on session resume or controller
restart, history.json is reloaded so history survives the restart.
Headless Daemon Mode (idle --web)
A bare --web launch with no task and no --task-file is special.
should_start_idle_web_daemon returns true when --web is set, it is not an
MCP-stdio run, no --task-file was supplied, and no inline task was supplied:
#![allow(unused)]
fn main() {
fn should_start_idle_web_daemon(use_web: bool, flags: &CliFlags) -> bool {
use_web
&& !flags.mcp
&& flags.task_file.is_none()
&& flags.task.as_ref().map(|t| t.trim().is_empty()).unwrap_or(true)
}
}
When that holds, the controller runs the daemon arm
(startup/daemon.rs), which constructs and runs the session supervisor:
run_daemon_loop(DaemonConfig { bus, project_root, autonomy,
shared_external_agent, shared_codex_config,
frame_registry, web_port, flags_direct,
shared_session })
└─▶ SessionSupervisor::new(..).run() // owns every launch
The daemon then sits idle waiting for CreateSession / StartTask /
ResumeSession / follow-up intents arriving over the dashboard WebSocket (or the
control socket). This is the persistent-daemon mode: one long-lived controller,
many sessions over its lifetime, driven entirely from frontends. Passing a task
on the command line instead runs it as the foreground session under the same
gateway (the headless arm), which falls through to this daemon loop when the
session ends.
Projectless daemons. The daemon path’s project_root is an
Option<PathBuf>. When the daemon starts in a directory with no project
marker — no .git (directory or worktree file) and no intendant.toml; the
single definition lives in project::root_has_project_marker, which the
boot-scan gate (file_watcher::root_is_snapshot_worthy) also uses — it runs
projectless instead of adopting cwd as an implicit project. Concretely: no
file-watcher baseline (nothing is watched at all), no cwd-derived sandbox
write scope (scratch + session logs + Unix toolchain caches + explicit
absolute grants only; never the daemon state root wholesale), no project-root
.env layer at startup, GET /api/project-root
returns project_root: null (the dashboard’s New Session pane requires a
project before submitting), and there is no default session project —
each CreateSession/resume carries its own project_root, and a create
without one gets the structured no_project failure. This is the normal
shape for installed-app and service deployments, whose launch cwd ($HOME,
/Applications, …) is an accident; CLI invocations keep cwd-as-project,
which is correct for intendant "task" inside a repo.
Daemon-scope state is exempt from that refusal. A projectless daemon loads
its config from the settings root (<state-root>/intendant.toml), and the
surfaces that write daemon-wide state resolve the same file through
project::daemon_config_root — peer pairing (invite join, access-request
completion) and manual peer persistence included — so pairing works and
[[peer]] entries persist on the bundled app exactly as on a rooted
daemon. Only project-scoped facilities (watcher, sandbox cwd scope,
default session project) stay off.
Ctrl+C in this mode is handled by the global signal handler installed in main
(it marks the session interrupted and exits 130); the daemon loop deliberately
does not also listen for it, to avoid racing two handlers.
Controller stdout/stderr tee.
daemon_log_tee::installtees the controller process’s stderr and stdout into its bootstrap session’s<state-root>/logs/<uuid>/daemon.log(with per-line timestamps) while still mirroring to the original terminal. This is Unix-only; on Windowsinstallis a no-op. It is what lets the dashboard’s “Download session report” bundle carry controller-sideeprintln!/panic/tracing output alongsidesession.jsonl.
Cost Accounting
app_state_pricing.rs provides server-side per-model USD cost estimation,
mirroring the pricing table in presence-web/app_state.rs so the native daemon
and the browser agree. Each entry gives per-token prices for input, cache-write,
cached, and output tokens; estimate_session_cost(...) combines those with a
session’s token usage, and estimate_live_usage_cost(...) covers live-audio
usage. The dashboard surfaces these as the running session cost.
Agenda Reminders and One-Shot Scheduled Sessions
One AgendaHandle lives under the daemon state root (<state-root>/agenda/),
not under a project. Its append-only JSONL operation log is the durable ledger
for parked notes, tasks, questions, due reminders, and scheduled-session
effects. Frontends and agents submit commands; the daemon serializes,
validates, appends, folds, and broadcasts the resulting state.
Due agenda items feed an owner-controlled reminder policy. The scheduler
honors enablement, quiet hours, per-item urgency ceilings, and a staleness
window; fresh reminders travel the existing UserNotification ladder, while
older missed reminders collapse into a digest. Reminder delivery does not
execute the item’s body as instructions.
Scheduled sessions are deliberately narrower than cron:
- An effect manifest binds a goal and one
fire_at_msinstant (plus execution shape intent). Any agenda writer may propose or revise it, but only a dashboard or local-process owner surface may approve/revoke the exact manifest digest; editing it invalidates the approval. - At the due instant the scheduler fsyncs a
preparedoccurrence, then dispatches a normal supervised session throughStartTask. The spawned session still has its ordinary agent-session principal, sandbox, autonomy, and approval gates — schedule approval is not permission to bypass them. - Occurrence identity and the delegation-receipt ledger make launch
idempotent. A session missed while the daemon was down is marked
missed; one prepared without launch confirmation or one that crossed a daemon restart resolvesunknown. These states are terminal and never auto-retry; the owner must revise/re-approve to schedule another occurrence.
There is still no recurring cadence or cron vocabulary. The separate
one-shot ScheduleControllerRestart (event.rs / mcp/) carries a goal and
handoff across a controller restart; it is a continuity mechanism, not an
Agenda session occurrence.
Graceful Daemon Handover (drain + takeover)
Co-homed daemons coordinate through the active-scheduler lease
(scheduler-lease/holder.lock, an advisory file lock; see the agenda
chapter for the firing rules). A running holder can hand its role to a
successor without kill-and-relaunch:
intendant --takeoverboots a successor that asks the current holder to drain (POST /api/daemon/takeover— owner-grade, loopback admission-token trust class;intendant ctl takeoveris the standalone verb). Drain is one-way and idempotent.- The draining daemon stops all standing automations (its scheduler
performs the ordered entry between passes, so a firing pass never
straddles the release), flips
lease.jsontodraining, frees the flock, and keeps serving in-flight work only: follow-ups, steers, interrupts, approvals, and ordinary agenda writes all serve; session-creating intents (create, untargetedstart_task, the resume family) refuse with a structureddaemon_drainingerror carrying the successor’s port, and the agenda immediacy verbs (start_now,request_occurrence) refuse the same way. The classification lives in one place (ControlMsg::creates_session) and every surface consults it. - When its last work-holding session finishes (sessions parked after
donedo not hold; parked conversations resume on the successor), the drainer records anexitedpresence state and the process exits. A limit-parked wrapper holds the drain for as long as its in-memory park runs — potentially hours, until the provider reset — which is why the wait set is named, never silent: the status payload carriesholdouts(each holding session’s id, name, backend, phase, and its durablelimit_parkmarker with the reset instant), the drainer’s presence record mirrors a capped row copy besidesession_count(the record crosses the handover wire and never grows; when the cap truncates it, a successor fetches the drainer’s own uncappedholdoutsthrough the sibling doorway — loopback + the per-port admission token — and widens its served drain map and status rows with them, floor-first and under the same provable-liveness gate, so no held session is invisible regardless of count), and the dashboard’s drain banner speaks two grades: grade 1 tells the product event plainly (“Updating Intendant — your work continues”, a portless “Open the updated dashboard →” doorway, “Waiting for the updated daemon to start…” while no successor serves), while the mechanics — the drain/lease story, the ported doorway, and the WHO holds / until WHEN wait-set rows — live verbatim under a sticky Advanced fold shared by every handover surface. Both banner kinds (this daemon draining; a predecessor draining) dock in a strip under the oversight bar — never over the tab chrome — and collapse to a one-line pill persisted per (kind, boot id): an unseen fact expands once, and no dismiss exists while the fact is true, mirroring the update chip’s standing-fact pattern. The drain-entry notification layers the same two grades into its one body — the grade-1 event, then the mechanics sentence after a line break. If the successor dies while the drainer still drains, ONE loud notification says standing automations are paused until someone relaunches or takes over — the drainer never reclaims the lease. - Sessions the drainer still held at its exit (interrupted mid-work, or
limit-parked with pending work) are RELEASED, not lost: the holder’s
predecessor-exit watch adjudicates, once per boot id, every
co-homed presence record whose boot is provably dead (per-boot lock
free) with drain lineage (
draining— killed mid-drain — orexited, the graceful terminal), and runs a scoped readopt pass over the released set — the boot pass’s mid-work classes, guard ladder (Resume-not-Revive, owner-stop tombstones, staleness, live-tip refusals), per-pass cap, dispatch stagger, and dispatches-are-not-outcomes verification, scoped to sessions whose story froze at-or-before the exit instant (the record’s last rewrite; anything a live co-homed daemon is still driving advances past that bound and is never touched). The trigger is edge-independent, so a drainer that exited while the successor was down, or crashed mid-drain, is still adjudicated. The summary notification names the predecessor, so a handover pickup reads apart from crash recovery. While the predecessor still drains, the successor-side banner tells the grade-1 story (“Finishing up (N tasks) on the previous version”, counts summed across however many daemons still finish); the mechanics — one section per draining co-homed daemon, its port, and its named holdout rows (“N sessions still finishing there”) — sit under the banner’s Advanced fold, which shares one sticky open-state key across every handover surface.
intendant ctl status shows the whole story under scheduler_lease
(role, drain state, and every co-homed boot with probed liveness).
The update-available surface
The daemon watches its own binary image on disk: a boot-time identity
stamp (length + mtime, plus dev/inode on Unix) beside the compiled-in
build provenance, a 60 s stat poll, and — when the image changes — one
bounded, environment-scrubbed <binary> --version probe that reads the
NEW build’s provenance. Detection never execs anything beyond that
probe; performing a swap is always a separate explicit gesture (the
app supervisor’s one-click, or the successor-exec click below). What
the watch produces:
- An
updateblock on the handover status payload (GET /api/daemon/handover, itsapi_daemon_handovertunnel twin, andctl statusunderscheduler_lease): the running and on-disk builds side by side (git_sha,built_at, versions), or an honestprobe_errorwhen the changed image’s provenance is unreadable. - One info-urgency notification per distinct on-disk commit sha (in-memory dedup; a restarted daemon states the fact once more).
- The dashboard’s update chip (bottom corner, suppressed while a drain banner is up). Inside the packaged macOS app the chip offers Update now: the app’s BackendSupervisor spawns the successor from the on-disk binary on a fresh port, waits for readiness, re-points the webview, and only then asks the predecessor to drain — the old child is never killed; it finishes its in-flight sessions and exits on its own. On a CLI-launched daemon the chip offers Hand off to :PORT when a live co-homed daemon is already running (draining this daemon toward it), and — ruled 2026-07-31 — Start the new daemon & hand off when none is: the successor-exec lane below.
- Successor exec (
POST /api/daemon/successor-exec; CLI-launched daemons only): on the owner’s explicit click — never automatically, never as a side effect of producing a build — the lease-holding daemon spawns the on-disk build as its own successor and drains toward it. The exec target is pinned by path and hash: the click carries the offered commit (expected_git_sha), the daemon re-probes the watched binary at click time and refuses if the artifact changed under the button or if the offered build IS the running one (a build-neutral swap is refused out loud, never performed silently). The successor boots as a plain secondary (never--takeover— it cannot race the incumbent for the lease), inherits the incumbent’s explicitly passed daemon-shaping flags (bind/TLS posture, autonomy, provider selection) but never its one-shot argv (the task,--continue, the old port), registers presence, must answer its own gateway, and only then does the incumbent drain. After the takeover the new lease holder’s recorded build is compared against the offered build and the verdict is surfaced either way — an honest “the swap did not land the offered build” beats a silent no-op. The route rides the same owner-grade loopback trust class as the other update rows and has deliberately no tunnel twin: remote surfaces observe progress through thesuccessor_execblock on the handover status payload; they cannot click a spawn onto the box. While a supervisor IS attached, the route refuses toward the app’s own one-click. A refused or failed spawn always leaves the running daemon untouched (a successor that never became ready is terminated — it acquired nothing); the spawned daemon’s output appends to<state root>/successor-exec.log. - On macOS, a non-Developer-ID on-disk build carries the keychain/TCC honesty line: item ACLs and TCC grants key on the signing identity, so the new build’s first custody or capture access may re-prompt.
The SPA also learns the daemon’s boot_id from every config lane and
the handover poll; a tab that hears a new daemon process answering
on its origin offers “The daemon updated — Reload” (auto-reloading only
when hidden and composer-safe), so a stale tab can no longer
misrepresent a replaced daemon.
Producing the update: the self-update lane
Everything above assumes a newer binary already ON disk. The self-update lane produces one, on the owner’s explicit click, from the Daemon update panel in Access → Daemons (beside the daemons list’s provenance rows). It classifies the install once at boot from the watched binary path:
- Source install — the binary is a checkout’s
target/releaseoutput, or the app bundle carries thesource-checkoutstampscripts/bundle-macos.shwrites (a stamp whose recorded path no longer exists, e.g. a release app built on CI, falls down to the consumer lane). The panel runs a bounded behind-origin/maincompare (a timeout-bounded fetch,rev-list --countcapped at 500, and a dirtiness probe) at boot, every 12 h, and on Check now. The click then runsgit pull --ff-onlypluscargo build --release(plain binary) orscripts/bundle-macos.sh(app shape — builds, signs with the stable local identity, installs to /Applications) as supervised child processes. The build rides the machine’s rustc governor (the child env never setsRUSTC_WRAPPER, so the box-wide cargo-config wrapper stays engaged) and is headroom-gated: under memory pressure (macOSkern.memorystatus_vm_pressure_level> 1, LinuxMemAvailableunder a 3 GiB floor) the job refuses to start instead of joining an OOM spiral. A dirty checkout or a non-fast-forwardable branch refuses honestly — the lane never stashes, resets, or merges over local work. - Consumer install — an installed release app with no reachable
checkout. The automatic cadence only runs when Connect is configured
(the tripwire’s posture — an unprompted check reaches the rendezvous
and GitHub); Check now always may. The check verifies the
latest logged release through
the transparency-log ritual (
hosted_verify: inclusion proof, signed tree head, append-only pin, the compiled-in PGP identity and signature-coverage checks) and compares versions. The click then downloads the platform’s app zip and its detached.asc, verifies sha256 against the log’s committed digests, verifies the signature withgpg --verifyin a throwaway GNUPGHOME that trusts only the compiled-in release signing key (gpg absent = the lane refuses; it never installs unverified bytes), probes the new app’s provenance, and swaps it in beside the running one (the old bundle stays asIntendant.app.previous). Fail closed everywhere: any verify failure deletes the staging bytes and reports the reason.
Both lanes end the same way: a newer binary sits at the watched path,
the update watch above announces it, and the swap is its own explicit
click (the app supervisor’s one-click, or the successor-exec lane on
CLI-launched daemons) — produce and swap stay two honest phases; the
produce lane never execs a build or fetch into its own process and
never spawns the successor itself. Progress and failure render live on
the panel (phase, a bounded child-process log tail, and the outcome)
served inside the update_lane block of GET /api/daemon/handover;
the two actions (POST /api/daemon/update-lane/{check,produce}) are
owner-grade and deliberately HTTP-only — remote tunnel-primary
surfaces watch progress but cannot click a build onto the box.
Checking is automatic and read-only; producing only ever happens on
the click — there is no auto-update.
Where to Go Next
- Architecture — the EventBus, the execution shapes, and the corrected frontend-parity model.
- Session Logging — the on-disk layout these components read and write, and the cross-backend session naming the supervisor uses.
- Web Dashboard — the primary consumer of these events (activity diffs, rewind UI, session graph, cost).
- Multi-Agent Orchestration — orchestration sessions and supervised sub-agents the supervisor launches.