Files
xy/docs/superpowers/specs/2026-07-31-xy-start-on-login-design.md
T
logaritmiskandClaude Opus 5 5b088f119c docs(spec): correct launchd behaviour after reading crate source
Reading service-manager-0.11.0/src/launchd.rs invalidated three claims:

- install() always writes Disabled:true alongside KeepAlive, so a freshly
  installed agent does NOT start at login. install must be followed by
  start(), which strips the key and reloads.
- The crate's stop() runs `launchctl stop`, which a KeepAlive service
  survives. start/stop are now implemented as launchctl load/unload on the
  plist path; the crate handles install/uninstall/status only.
- status() returns Stopped(None) unconditionally on macOS, so the reason
  string the spec planned to surface does not exist.

Also corrects the RestartPolicy mapping (OnFailure emits a KeepAlive
dictionary, not a boolean) and records the launchctl-print risk to verify.

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

14 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. This is worse than first assumed: LaunchdServiceManager:: status() returns ServiceStatus::Stopped(None) unconditionally (launchd.rs:288), so the Option<String> 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", <snapshot>)] 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/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.

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 <label> (launchd.rs:208), which a KeepAlive: true service simply survives — the crate's own doc comment says to call uninstall instead. Since uninstalling would discard the PATH snapshot, xy service start and xy service stop are implemented directly against launchctl on the plist path:

  • stoplaunchctl unload <plist>
  • startlaunchctl load <plist>

This pair is symmetric, and start works because install already stripped Disabled. The crate is used for install, uninstall and status only.

install()/uninstall() use the legacy verbs, launchctl load and launchctl remove — not bootstrap/bootout. CLI output says "loaded" rather than "bootstrapped" to match what actually happens.

Risk to verify during implementation

status() calls launchctl print <bare-label>, but user agents normally require the gui/$UID/<label> form. The crate compensates with a two-pass trick: on exit code 64 it scans stderr for a suggested fully-qualified label and retries (launchd.rs:235-276). This is fragile and version-sensitive. Task 3 verifies it empirically against a real installed agent; if it proves unreliable, the fallback is to run launchctl print gui/$UID/<label> ourselves and parse the state = running line, which is what the crate is approximating anyway.

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 calls install() then start() — see launchd mechanics — leaving the plist free of Disabled and the daemon running.
  • uninstall — delegates to the crate, which runs launchctl remove and deletes the plist. Not-installed is not an error: prints not installed, exits 0, matching how xy stop already reports not running.
  • start / stoplaunchctl load and launchctl unload on the plist path, leaving the plist in place. Both exit 1 if the agent is not installed. stop on an already-stopped agent exits 0. Note that stop lasts only until the next login, since RunAtLoad remains set; to disable start-on-login permanently, use uninstall.
  • 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. There is deliberately no separate "loaded" line, because the crate's API cannot distinguish a loaded-but-stopped agent from an unloaded one, and no reason string, because macOS always yields Stopped(None). 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.