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

11 KiB
Raw Blame History

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 AgentSpecServiceInstallCtx 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.