# xy — Start on Login (macOS) **Date:** 2026-07-31 **Status:** Approved — ready for implementation planning ## Problem The daemon must be launched by hand (`xy daemon`) and dies with the terminal that started it. The MVP spec (2026-05-25) listed "auto-start at login via launchd" as an explicit non-goal; this design delivers it. ## Goals - Install a user-level launchd agent that starts `xy daemon` at login and restarts it if it dies. - Manage that agent from `xy` itself — install, uninstall, start, stop, status. - Survive the environment launchd hands a login agent, which is far poorer than an interactive shell's. - Give the daemon a log file, so "it didn't come up at login" is diagnosable. ## Non-goals (deferred) - Linux (systemd) and Windows support. The CLI shape is chosen so these are a change inside one module, not a change to the command surface. - System-level (root) agents. User agents only. - Managing the agent over IPC. `xy service *` deliberately bypasses the daemon. ## Decisions ### launchd owns the daemon's lifecycle Once installed, the LaunchAgent is the sanctioned way to run the daemon on macOS: `RunAtLoad` starts it at login and `KeepAlive` restarts it on crash. `xy daemon` remains available for foreground and development use; the existing pidfile keeps the two from colliding. ### Environment: snapshot `PATH` at install time A LaunchAgent inherits `PATH=/usr/bin:/bin:/usr/sbin:/sbin` and nothing from the user's shell. Supervised MCP servers inherit whatever the daemon has, and servers launched through toolchain shims (`pnpx`, `bunx`) will not find their interpreter under that PATH. `xy service install` therefore captures the invoking shell's `PATH` and writes it into the plist's `EnvironmentVariables`. The snapshot is deterministic and visible in the plist, at the cost of going stale when a new toolchain is added — `xy service install --force` re-snapshots. Rejected: wrapping the daemon in `/bin/zsh -lc`, which makes login-time startup depend on shell rc being fast and non-interactive-safe, and adds a process to the tree for no gain here. Verified 2026-07-31: `XDG_CONFIG_HOME`, `XDG_STATE_HOME` and `XDG_RUNTIME_DIR` are all unset on this machine, so `etcetera`'s `Xdg` strategy resolves to `~/.config` and `~/.local/state` identically for the CLI and for a launchd-spawned daemon. There is no path-mismatch risk to design around. ### Third-party crate: `service-manager` `service-manager = "0.11"` (469K downloads, 29 reverse deps, updated 2026-02-18) provides `LaunchdServiceManager::user()` targeting `~/Library/LaunchAgents`, an install context carrying `environment`, `autostart` and `restart_policy`, and a `status()` returning `NotInstalled` / `Running` / `Stopped(Option)`. Rejected alternatives: - **`auto-launch`** (4.6M downloads) is built for GUI applications at login. It has no notion of `KeepAlive`, restart policy, or service status — the wrong abstraction for a supervised daemon. - **Hand-rolled plist + `launchctl`** would re-implement the crate and leave us owning modern-vs-legacy `launchctl` compatibility. ### Use the crate's generated plist; log from inside the daemon `LaunchdInstallConfig` exposes only `keep_alive` — there is no way to set `StandardOutPath` or `StandardErrorPath` through the typed API. Rather than bypass it with the `contents: Option` escape hatch and hand-author plist XML, the daemon gains its own log file (see below). **Accepted limitation.** Failures occurring before the daemon's logger exists — missing binary, dyld error, malformed plist — appear in neither `daemon.log` nor `xy service status`. This is worse than first assumed: `LaunchdServiceManager:: status()` returns `ServiceStatus::Stopped(None)` unconditionally (`launchd.rs:288`), so the `Option` reason carried by the enum is always `None` on macOS and cannot be surfaced. Documented recourse: launchctl print gui/$UID/se.aceofba.xy ### Program path: `current_exe()`, with a warning `xy service install` resolves and canonicalizes `std::env::current_exe()`. If the result contains a `target/debug` or `target/release` path component, it prints a warning that the agent will break on `cargo clean` and proceeds. ## Architecture `service-manager` is added to `[workspace.dependencies]` and consumed by the `xy` crate alone. `xy-protocol`, `xy-supervisor` and `xy-ipc` are unchanged except for one addition to `logs.rs` (below). Nothing crosses the IPC boundary: `xy service *` manipulates launchd directly, which is what makes it work when the daemon is dead. Two new modules in `crates/xy`, Rust-2018 layout (`foo.rs` + `foo/`): - **`src/service.rs`** — the OS-facing unit. Owns `AgentSpec` and thin `install` / `uninstall` / `start` / `stop` / `status` functions that translate it into `ServiceInstallCtx` and back out into an `AgentStatus`. Knows about launchd; knows nothing about clap or printing. Returns data, never prints, so it is testable without a terminal. - **`src/cli/service.rs`** — the presentation unit. Parses the verbs, calls into `service.rs`, formats human output, maps outcomes to exit codes. Mirrors the existing `cli/mod.rs` / `cli/format.rs` split. `main.rs` gains a `Service { #[command(subcommand)] verb: ServiceCmd }` arm. `service.rs` wires `LaunchdServiceManager::user()` under `cfg(target_os = "macos")`. On other targets the verbs return `start-on-login is macOS-only for now` and exit 1. ## The agent | `AgentSpec` field | Value | Plist key | |---|---|---| | `label` | `se.aceofba.xy` | `Label` | | `program` | canonicalized `current_exe()` | `ProgramArguments[0]` | | `args` | `["daemon"]` | `ProgramArguments[1..]` | | `environment` | `[("PATH", )]` | `EnvironmentVariables` | | `working_directory` | `$HOME` | `WorkingDirectory` | | `autostart` | `true` | `RunAtLoad` | | `restart_policy` | `Always { delay_secs: Some(10) }` | `KeepAlive` | | `username` | `None` — runs as the invoking user | — | Verified against `service-manager-0.11.0/src/launchd.rs`. `Always` emits `KeepAlive: true` (a plain boolean); `OnFailure` instead emits a `KeepAlive` *dictionary* with `SuccessfulExit: false`; `Never` omits the key. `delay_secs` has no launchd equivalent and is discarded with a `log::warn!`. `Always` is chosen deliberately: the daemon should come back regardless of how it exited. `delay_secs` is set to `None`, since passing a value only produces a warning. The label is the reverse-DNS form of the `git.aceofba.se` remote. It lives on `AgentSpec` rather than being a constant so tests can install under a distinct label. `working_directory` is `$HOME` rather than launchd's default `/`, so that a server config using a relative `working_dir` resolves somewhere predictable. ## Daemon logging `main.rs` is reordered so `Paths::resolve()` and `ensure_dirs()` run *before* `tracing` is initialised. Today `ensure_dirs()` is called inside `daemon::run`, which is too late to open a log file for the logger itself. For the `Cmd::Daemon` arm only, the subscriber writes to `stderr.and(file)` via `MakeWriterExt`, where the file half is `log_dir/daemon.log` backed by the existing `xy_supervisor::logs::RotatingLogWriter` (10 MB × 5, the same rotation used for per-server logs). No adapter type is needed: `tracing-subscriber` 0.3 implements `MakeWriter` for `Mutex where W: io::Write` (`fmt/writer.rs:808`), so a plain `Mutex` satisfies `with_writer` once the `io::Write` impl lands. **Corrected 2026-08-01.** An earlier draft of this section claimed `Arc>` also satisfied the bound, via `impl MakeWriter for Arc`. That is false: the `Arc` impl (`fmt/writer.rs:694`) requires `&'a W: io::Write`, and `&Mutex` does not implement `io::Write`. The claim survived into the implementation plan and cost a fix round before being caught. The shipped code uses a bare `Mutex`. Every other subcommand keeps stderr-only logging; CLI output does not belong in the daemon's log. This requires one targeted addition to existing code: `impl std::io::Write for RotatingLogWriter` in `xy-supervisor/src/logs.rs`. The type already tracks `written` and rotates, but today exposes only `write_line(tag, line)`, which prefixes a tag the daemon's own log does not want. Result: `~/.local/state/xy/logs/` becomes uniform — `daemon.log` for the daemon, `.log` per supervised server, all rotated by the same code. ## launchd mechanics Verified by reading `service-manager-0.11.0/src/launchd.rs`. Three behaviours constrain the CLI and are not obvious from the crate's public documentation. **`install()` deliberately produces a disabled agent.** Whenever `KeepAlive` is present, `make_plist` also writes `Disabled: true` (`launchd.rs:440`) so that `install()` never auto-starts, for cross-platform consistency. A `Disabled` LaunchAgent does not start at login either, so **install alone does not deliver start-on-login**. The crate's `start()` is what removes the `Disabled` key, rewrites the plist, and reloads (`launchd.rs:179-194`). `xy service install` therefore always calls `install()` *then* `start()`; after that the on-disk plist is permanently free of `Disabled` and `RunAtLoad` works at next login. There is no way to opt out: setting `LaunchdInstallConfig::keep_alive = Some(true)` still takes the `has_keep_alive` branch that writes `Disabled`. **The crate's `stop()` is unusable for our agent.** It runs `launchctl stop