# ADR-0039: Extensible omni-dev Daemon Hosting Pluggable Services over a Unix Control Socket

## Status

✅ Accepted (2026-06-18; amended 2026-06-29 to adopt launchd socket activation as
the macOS spawn model, #1081; amended 2026-07-05 to add a mirrored Linux systemd
user unit, #1174; amended 2026-07-07 to formalize the service engine/adapter
split, #1218)

## Context

Several omni-dev capabilities want to be **long-lived** rather than
per-invocation. The browser bridge ([ADR-0036](adr-0036.md)) is the clearest
example: it must keep its WebSocket and HTTP control planes bound for as long as
the operator's authenticated tab is open, yet it was reachable only by running
`omni-dev browser bridge serve` in a foreground terminal — one process, one
capability, no shared lifecycle, no menu-bar surface, and a session token that
existed only on that terminal's stdout.

As more capabilities grow this shape (the browser bridge today; session-scoped
SSO services and others later), running each as its own foreground process does
not scale: every one re-implements single-instance locking, signal handling,
graceful shutdown, status reporting, and (on macOS) a menu-bar presence. We want
**one** supervised host that owns those concerns once and lets capabilities plug
in as **services**.

The constraints that shaped the design:

- A capability's own network surface (the bridge's loopback TCP planes) must stay
  exactly as ADR-0036 specifies — the daemon supervises lifecycle; it must **not**
  proxy or weaken that trust boundary.
- The operator/tray control channel is a different trust domain (local-user
  control: stop, restart, status, menu clicks) and should be local-only by
  filesystem permission, not by a network token.
- On macOS a native menu bar needs the platform run loop on the **main thread**,
  which conflicts with `#[tokio::main]` owning it.

## Decision

Introduce an extensible daemon under [`src/daemon/`](../../src/daemon/): a
long-lived process that hosts pluggable **services** behind a Unix-domain control
socket, with an optional macOS menu-bar shell. The browser bridge is the first
service migrated onto it (see [ADR-0036](adr-0036.md)); a trivial in-tree `echo`
service exercises the framework in tests.

### The `DaemonService` abstraction

A service implements one trait ([`src/daemon/service.rs`](../../src/daemon/service.rs)):

```rust
#[async_trait]
pub trait DaemonService: Send + Sync {
    fn name(&self) -> &'static str;
    async fn handle(&self, op: &str, payload: Value) -> Result<Value>;
    fn menu(&self) -> MenuSnapshot;                 // cheap snapshot for the tray
    async fn menu_action(&self, action_id: &str) -> Result<()>;
    async fn status(&self) -> ServiceStatus;        // structured, for `daemon status`
    async fn shutdown(&self);
}
```

`MenuSnapshot { title, items: Vec<MenuItem> }` where a `MenuItem` is a
`Label(String)`, a `Separator`, or an `Action(MenuAction { id, label, enabled })`.
`ServiceStatus { name, healthy, summary, detail: Value }` is the structured shape
`daemon status` aggregates.

The `ServiceRegistry` ([`registry.rs`](../../src/daemon/registry.rs)) holds the
services and routes by the request envelope's `service` field; it also iterates
them for `status`, menu snapshots, and shutdown.

### Service structure: engine vs. adapter

A non-trivial service is factored into **two** parts, and the `DaemonService`
implementation is only the thinner one:

- an **engine** — a standalone module at the crate root
  ([`src/browser/`](../../src/browser/), [`src/snowflake/`](../../src/snowflake/),
  [`src/worktrees.rs`](../../src/worktrees.rs)) that owns the domain logic and
  the in-memory state (the bridge's dual planes and correlator, the Snowflake
  session pools, the worktrees registry). It knows nothing of the control socket
  or the tray, so it is unit-testable **without a running daemon** and reusable
  outside it.
- an **adapter** — the `DaemonService` impl under
  [`src/daemon/services/`](../../src/daemon/services/) (`bridge.rs`,
  `snowflake.rs`, `worktrees.rs`). It holds no domain logic: it deserializes the
  op payload, calls into the engine, renders the `menu()`/`status()` snapshots the
  tray and `daemon status` consume, and drives the side-effects that are the
  daemon's job rather than the engine's (persisting the bridge session token,
  spawning `code` for a worktree `focus`).

The trivial in-tree `echo` service is the deliberate exception — it is the
framework's test fixture and has no engine. Worktrees started fused and had its
engine split out explicitly in #1154, aligning it with the bridge and Snowflake;
this two-part shape is now the prescribed way to add a service.

Because the engine owns the state, the **load-bearing concurrency invariant lives
at the engine**: any `std::sync::Mutex` guarding that state is **never held across
an `.await`** — locked only for pure-CPU work, then dropped before any await
point. This is the rule [ADR-0040](adr-0040.md) previously called "the Snowflake
rule"; naming it here makes this ADR its single owner, since the invariant now
spans the engine module and its adapter.

### Unix-domain control socket, NDJSON framing

Operator/tray traffic flows over a Unix-domain socket framed as
newline-delimited JSON (`tokio_util::codec::LinesCodec`). The wire types
([`protocol.rs`](../../src/daemon/protocol.rs)):

```rust
struct DaemonEnvelope { service: Option<String>, op: String, payload: Value }
struct DaemonReply    { ok: bool, payload: Value, error: Option<String> }
```

A `service` of `None` or the reserved name `"daemon"` targets **built-in ops**:
`ping` → `{ "pong": true }`, `status` → `StatusReport { services: [...] }`,
`shutdown` → `{ "stopping": true }`. Any other `service` is dispatched to the
matching registered service's `handle`.

Each NDJSON line is capped at 1 MiB (`protocol::MAX_LINE_BYTES`) via
`LinesCodec::new_with_max_length` on both ends, so a peer that streams bytes
without a newline can't grow a connection's read buffer without bound (the
codec's `usize::MAX` default is, in its own docs, "a potential security risk").
An over-limit request gets a single error reply and the connection is closed,
rather than looping in the codec's post-overflow discard mode ([#989](https://github.com/rust-works/omni-dev/issues/989)).

This control channel is **not** a network trust boundary: it is a Unix socket
created `0600` inside a `0700` directory, so access is gated by local-user
filesystem permission. A loopback **TCP** control plane was rejected for this
channel precisely because it would re-introduce a network trust surface —
reachable by every local process, and by any browser page the operator visits via
CSRF / DNS-rebinding — that the bridge already has to defend against
([ADR-0036](adr-0036.md)); a Unix socket sidesteps that whole threat class with
filesystem permissions alone. It deliberately does not carry the bridge's bearer
token — the bridge keeps its own ADR-0036 authentication on its own loopback TCP
planes.

### Paths, permissions, single-instance

The runtime directory is resolved via `dirs::data_dir()` —
`~/Library/Application Support/omni-dev/` on macOS,
`~/.local/share/omni-dev/` on Linux ([`paths.rs`](../../src/daemon/paths.rs)).
This **deliberately diverges** from the `~/.omni-dev` config-file directory: the
socket and token are volatile runtime state, not user configuration. Defaults:

- control socket → `<runtime_dir>/daemon.sock`
- bridge token file → `<runtime_dir>/bridge.token`

The directory is created/tightened to `0700`, the token file to `0600`, and the
socket path is length-checked against the 104-byte `sockaddr_un` limit before
binding. The socket itself is bound owner-private (`0600`) *from birth*: the bind
runs under a `0o177` umask so the inode is never created group/world-accessible,
rather than relying on a post-bind `chmod` (which would leave a brief
group/world-readable window) or on the `0700` parent. A redundant `set_file_0600`
follows as defense-in-depth (issue #995).

Single-instance supervision ([`single_instance.rs`](../../src/daemon/single_instance.rs))
needs no lockfile: the **exclusive socket bind is the lock**. On `EADDRINUSE` the
daemon probes the existing socket with a `ping`; a live answer means another
daemon is running (fail), no answer means a stale socket left by a crash (warn,
unlink, rebind). On the socket-activation paths this is moot — the service manager
(launchd on macOS, systemd on Linux) guarantees at most one spawn per socket and
creates the inode itself, so the daemon adopts the inherited fd and skips
bind-or-reclaim entirely (see *Lifecycle* below); the self-bound path above governs
manual/dev/CI runs and the detached-spawn fallback.

### Lifecycle: the `run` / launcher split

The framework resolves "what literally launches the daemon" with a
foreground/launcher split that mirrors the bridge's existing `serve` (server) vs
`request` (thin client) shape:

| Command | Role | Blocks? |
|---|---|---|
| `omni-dev daemon run` | **Becomes** the daemon: acquires the control socket — the service-manager-activated fd when socket-activated (launchd on macOS, systemd on Linux), else a self-bound socket (the bind is the single-instance lock) — starts every registered service, serves until `SIGTERM`/`SIGINT`/`SIGHUP` (Ctrl-C off-Unix) or a `shutdown` op. This is what the service manager demand-spawns (or a launcher execs). | yes |
| `omni-dev daemon start` | Launcher: on macOS installs + bootstraps a launchd LaunchAgent, and on Linux installs + enables a systemd **user** socket unit, that **owns** the demand socket (the first connect spawns the daemon, and it activates at login); without a service manager (other Unix, or Linux with no user manager) it spawns `daemon run` detached — its own session via a `setsid` `pre_exec` hook, stdin from `/dev/null`, stdout/stderr appended to a `0600` `daemon.log` beside the socket, with no auto-start at login. Warms with one `ping` and polls until the socket answers. | no |
| `omni-dev daemon stop` | Thin client: sends the built-in `shutdown` op; on macOS also boots out the LaunchAgent, and on Linux stops + disables the systemd socket unit — removing/disarming the demand socket — so it is not re-activated on the next connect or login. | no |
| `omni-dev daemon restart` | `stop` (wait until down; skipped on Linux, where pinging the still-armed socket would re-activate the daemon) then `start` (wait until ready). | no |
| `omni-dev daemon status` | Thin client: requests the aggregated `StatusReport` and prints a per-service table (`--json` for machines). | no |

Graceful shutdown ([`lifecycle.rs`](../../src/daemon/lifecycle.rs),
[`server.rs`](../../src/daemon/server.rs)) installs `SIGTERM`/`SIGINT`/`SIGHUP`
handlers that cancel a shared `tokio_util::sync::CancellationToken`; the accept
loop selects on it, then closes and unlinks the control socket *before* draining
— so a stray `connect`+`ping` arriving during a slow drain fails fast
(ECONNREFUSED) instead of blocking unaccepted until process exit (#993) — and
finally each service's `shutdown().await` runs. (`SIGHUP` is defense-in-depth:
the detached daemon sits in its own session and never sees a terminal hangup,
but a manual `kill -HUP` now drains instead of hard-killing, #1112.)

When no service manager provides auto-start — other Unix platforms, or Linux
without a running user manager (containers, non-systemd distros, or the
`OMNI_DEV_DAEMON_DISABLE_SYSTEMD` escape hatch) — the `daemon start` launcher
detaches the spawned `daemon run` properly (#1112): a `pre_exec` hook calls
`setsid(2)` so the daemon leaves the launcher's session and process group
(surviving terminal exit and its `SIGHUP`), stdin is `/dev/null`, and
stdout/stderr append to the `0600` `daemon.log` beside the socket
([`paths::log_path_for_socket`](../../src/daemon/paths.rs)). This is the crate's
second scoped `#[allow(unsafe_code)]` (after `launchd_listener` below):
`Command::pre_exec` is inherently `unsafe` because the hook runs between `fork`
and `exec` where only async-signal-safe calls are permitted — `setsid` is
async-signal-safe and allocation-free, and a freshly forked child is never a
process-group leader, so the call cannot fail with `EPERM`. This detached spawn
has no auto-start at login; on Linux with a user manager, `daemon start` instead
installs a systemd user unit that mirrors the LaunchAgent (see *Linux socket
activation* below), and macOS always uses the LaunchAgent.

On macOS, `daemon start` installs a **socket-activated** LaunchAgent labelled
`com.github.rust-works.omni-dev.daemon` ([`launchd.rs`](../../src/daemon/launchd.rs)).
Its `Sockets` → `Listener` dict makes **launchd own the control socket**: launchd
creates the `0o600` Unix-stream socket at `SockPathName` when it bootstraps the
job and demand-spawns `omni-dev daemon run` the first time a client connects,
handing the daemon the listening file descriptor. `daemon run` adopts that fd via
the `launch_activate_socket(3)` FFI (`launchd::launchd_listener`) instead of
binding; when the lookup reports no inherited socket — a manual `daemon run` from
a shell, CI, or the detached-spawn fallback — it falls back to the self-bound
`bind_or_reclaim` path, which still doubles as the single-instance lock.

This **inverts the original dependency** and retires the `RunAtLoad`/`kickstart`
arrangement (issue #1078). There is no `RunAtLoad` and no `KeepAlive`:

- **The on-demand parking bug cannot occur.** `RunAtLoad` was best-effort —
  launchd could silently downgrade it to on-demand activation, parking a
  freshly-bootstrapped job at `state = not running` until `start`'s readiness poll
  timed out (#1078). The tactical fix issued an explicit `launchctl kickstart -p`
  after `bootstrap`. With socket activation there is nothing for `RunAtLoad` to
  fail to do — on-demand *is* the model — so both the bug and its workaround are
  gone. `start` simply warms the socket with one `ping`, which triggers the first
  spawn, and the socket-ping readiness poll remains the authoritative gate.
- **Crash recovery is free.** A crashed daemon is re-activated on the next client
  connect, with no `KeepAlive` racing the post-crash teardown.
- **Cold-start latency is ~0** for the first CLI invocation after login: launchd
  is already listening, so the first connect kicks off the spawn and the client's
  `ping` retry loop catches it within one poll interval.

Two consequences follow for the daemon process. First, launchd creates the socket
*before* `daemon run` executes and does not create missing parent directories, so
`install_and_load` ensures the `0o700` runtime directory exists *before*
bootstrapping. Second, launchd — not the daemon — owns the socket inode, so on
shutdown the daemon closes its listener but **must not unlink the path** (doing so
would make the next `connect` hit ENOENT and never re-activate); the unlink is
gated to the self-bound fallback path only. `SockPathMode` `0o600` makes launchd
create the inode owner-private from birth, the same privacy `bind_private`
enforces via umask on the fallback path. A clean `daemon stop` boots the agent
out, removing the demand socket so the daemon stays down until the next `start` or
login; consequently `daemon status` reporting "not running" means "no process is
resident right now", not "unavailable" — any client connect re-activates it.

### Linux socket activation — a systemd user unit

Linux mirrors the macOS model with a **socket-activated systemd user unit**
([`systemd.rs`](../../src/daemon/systemd.rs), #1174). `daemon start` writes an
`omni-dev-daemon.socket` + `omni-dev-daemon.service` pair under
`~/.config/systemd/user/` and runs `systemctl --user enable --now
omni-dev-daemon.socket`. The `.socket` unit's `ListenStream` makes **systemd own
the control socket** (created `0600` via `SocketMode`, the analogue of launchd's
`SockPathMode`), and `WantedBy=sockets.target` is what enables auto-start at login.
The `.service` carries no `[Install]` and no `Restart=` — activation is purely
on-demand, the exact analogue of the launchd no-`KeepAlive` model — and `daemon
run` adopts the inherited fd via the `sd_listen_fds` protocol
(`systemd::systemd_listener`, reading `LISTEN_FDS`/`LISTEN_PID`; reimplemented
directly so there is no libsystemd dependency, and setting `FD_CLOEXEC` so the
socket is not leaked into child processes) instead of binding. The
socket-owned-by-the-manager invariant is shared with macOS: shutdown leaves the
inode in place, gated by the same `socket_activated` flag in
[`server.rs`](../../src/daemon/server.rs). `daemon stop` runs `systemctl --user
stop … && disable`, the analogue of `bootout`. When no user manager is available —
containers, non-systemd distros, or the `OMNI_DEV_DAEMON_DISABLE_SYSTEMD` escape
hatch — `systemd::is_available()` returns false and `start` falls back to the
detached spawn above.

One deliberate divergence from macOS: `restart` cannot poll the daemon *down* by
pinging, because systemd re-arms the `.socket` the instant the `.service` goes
inactive, so a probe `connect` would re-activate it. On Linux `restart` therefore
shuts the daemon down and lets the relaunch + readiness `ping` drive a clean
handoff (systemd serializes at most one service instance), rather than the macOS
`wait_until_down` poll.

### macOS menu bar — feature-gated, main-thread run loop

The tray ([`tray.rs`](../../src/daemon/tray.rs)) is compiled only under
`cfg(all(target_os = "macos", feature = "menu-bar"))`. The `menu-bar` Cargo
feature is **off by default** and pulls in macOS-only `tray-icon`, `tao`, and
`arboard` (clipboard). Headless `daemon run` (non-macOS, feature off, or
`--no-menu`) is a plain async path and is what launchd/CI use.

Because a native menu bar requires the platform run loop on the main thread, the
tray path does **not** use the `#[tokio::main]` macro: it builds a multi-thread
`tokio::runtime::Runtime`, spawns the socket server and services onto it, and
hands the main thread to the `tao` event loop, which rebuilds the menu from
`registry → menu()` snapshots ~1 Hz and routes clicks to `menu_action`. Clipboard
menu actions (copy key/snippet/request command) are handled in the tray via
`arboard`.

## Consequences

- One supervised host owns single-instance locking, signal handling, graceful
  shutdown, status aggregation, and the macOS menu bar **once**; a new capability
  becomes a long-lived service by implementing a single trait and registering it,
  rather than re-deriving a foreground-process harness.
- Splitting each service into a crate-root **engine** and a thin `DaemonService`
  **adapter** keeps domain logic testable and reusable without a running daemon,
  keeps adapters uniform and thin, and makes this ADR the single source of truth
  for how to add a service. The cost is an extra module boundary and re-export
  surface per service, and the never-hold-a-lock-across-`.await` invariant now
  spans two modules — so it is restated at the engine (above) rather than left
  implicit in the adapter.
- The trust boundaries stay cleanly separated: the operator/tray control plane is
  a `0600` Unix socket gated by filesystem permission, while each service keeps
  its own network authentication. The browser bridge's ADR-0036 model — loopback
  TCP planes, bearer auth on both, default-closed, server-side `validate_outbound_url`
  — is **untouched**; the daemon supervises it, it does not proxy or relax it.
- The bridge's session token, previously visible only on the foreground `serve`
  stdout, is now also persisted to a `0600` token file under the runtime
  directory so thin clients (`browser bridge request` / `harvest`) can discover it
  with no foreground process. This is the only change to the bridge's externally
  observable surface (recorded as an amendment in [ADR-0036](adr-0036.md)).
- `omni-dev browser bridge serve` is **preserved unchanged** as a standalone
  foreground path; daemon hosting is additive, not a replacement, and no CLI
  surface was removed or deprecated.
- New dependencies are confined to the optional, macOS-only `menu-bar` feature
  (`tray-icon`, `tao`, `arboard`); default and non-macOS builds are unaffected.
  `tokio` (`UnixListener`), `tokio-util` (`CancellationToken` + `LinesCodec`), and
  `async-trait` were already in the tree.
- The runtime directory deliberately differs from the `~/.omni-dev` config
  directory; operators looking for the socket or token file should look under the
  platform data directory (`~/Library/Application Support/omni-dev/` on macOS).
- The daemon is the framework the issue tracker's "extensible daemon" work calls
  for; the browser bridge is its first migrated service, and the in-tree `echo`
  service plus the routing/single-instance/lifecycle unit tests guard the
  framework independently of any one service.

See [ADR-0036](adr-0036.md) for the bridge's own security model and
[docs/browser-bridge.md](../browser-bridge.md) for the operator-facing guide,
including the "Running under the daemon" section.
