Files
xy/docs/superpowers/specs/2026-07-31-xy-start-on-login-design.md
T
logaritmiskandClaude Opus 5 3a434657bc docs(spec): start-on-login via launchd user agent (macOS)
Design for `xy service install|uninstall|start|stop|status`, backed by the
service-manager crate. Covers the launchd environment problem (PATH snapshot
at install time), the daemon's own rotating log file, and the accepted
blind spot for pre-logger launch failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-07-31 22:12:15 +02:00

241 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<String>)`.
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<String>` 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 `xy.log` nor
`xy service status` beyond a bare `Stopped`. 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", <snapshot>)]` | `EnvironmentVariables` |
| `working_directory` | `$HOME` | `WorkingDirectory` |
| `autostart` | `true` | `RunAtLoad` |
| `restart_policy` | `Always { delay_secs: Some(10) }` | `KeepAlive` |
| `username` | `None` — runs as the invoking user | — |
launchd collapses `RestartPolicy` to a `KeepAlive` boolean: `Never` maps to
false, `Always` and `OnFailure` both map to true, and `delay_secs` is ignored.
`Always` is chosen for the explicit `KeepAlive: true` it produces; the delay is
carried only so the value stays meaningful if this spec is extended to systemd.
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/xy.log` backed by the existing
`xy_supervisor::logs::RotatingLogWriter` (10 MB × 5, the same rotation used for
per-server logs). A small `MakeWriter` newtype wrapping
`Arc<Mutex<RotatingLogWriter>>` bridges the two.
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 — `xy.log` for the daemon,
`<server>.log` per supervised server, all rotated by the same code.
## CLI
xy service install [--force]
xy service uninstall
xy service start
xy service stop
xy service status
- **`install`** — exits 1 if the plist already exists at
`~/Library/LaunchAgents/se.aceofba.xy.plist`, directing the user to
`--force`. Presence of that file is the definition of "installed" throughout;
the crate's `ServiceStatus::NotInstalled` is treated as corroborating, not
authoritative, because it cannot distinguish a missing plist from an
unloadable one. With `--force`, uninstalls first, which re-snapshots `PATH` and
re-resolves `current_exe()`; this is also the upgrade path after installing a
new binary or adding a toolchain. Warns and proceeds on a build-tree program
path. On success it writes the plist *and* bootstraps, leaving a running
daemon.
- **`uninstall`** — stops, then removes the plist. Not-installed is not an
error: prints `not installed`, exits 0, matching how `xy stop` already reports
`not running`.
- **`start`** / **`stop`** — bootstrap and bootout, leaving the plist in place.
Both exit 1 if the agent is not installed. `stop` on an already-stopped agent
exits 0.
- **`status`** — reports label, plist path, state, program path, snapshotted
`PATH`, and log path. Never fails on state, only on an inability to query.
State is `running` / `stopped` / `not installed`, taken directly from
`ServiceStatus`; the reason string from `Stopped` is surfaced verbatim, being
the main window into launch failures. There is deliberately no separate
"loaded" line, because the crate's API cannot distinguish a loaded-but-stopped
agent from an unloaded one. The pid is read from the existing
`paths.pidfile`; the crate does not expose one. The snapshot date is the
plist's mtime, not a value stored inside it.
Sample output:
$ xy service status
agent: se.aceofba.xy (user)
plist: ~/Library/LaunchAgents/se.aceofba.xy.plist
state: running (pid 4821)
program: /Users/olsson/.cargo/bin/xy
path: /opt/homebrew/bin:… (snapshotted 2026-07-31)
log: ~/.local/state/xy/logs/xy.log
### Exit codes
Reuses the established scheme, minus the codes that cannot apply. `0` success,
`1` operational error (launchctl failed, agent missing, permission denied).
Code `2` (daemon unreachable) is structurally impossible because these commands
never open the socket; `3` (config invalid) does not arise.
## Testing
TDD throughout — failing test first, then minimal implementation.
- Unit tests in `service.rs` for the `AgentSpec``ServiceInstallCtx` mapping:
label parses, args are `["daemon"]`, the `PATH` snapshot is captured, `$HOME`
becomes the working directory. No launchd involved.
- Unit tests for the dev-build path predicate against a table of sample paths.
- Formatting tests in `cli/service.rs` rendering an `AgentStatus` to expected
text, mirroring the existing `cli/format.rs` tests.
- A test for `impl io::Write for RotatingLogWriter` covering byte accounting and
the rotation threshold. The rotation logic is currently exercised only through
`write_line`.
- `tests/service.rs` — a real install → status → stop → start → uninstall cycle,
marked `#[ignore]` and using the label `se.aceofba.xy-test` so that a stray
`cargo nextest run` can never install a live agent. Run manually with
`--ignored`.
## Documentation
`README.md` gains the five `xy service` verbs, a note that the agent snapshots
`PATH` at install time and that `--force` re-snapshots, and the
`launchctl print` recourse for pre-logger failures.