Managed install (Leo.app)¶
Leo.app, the macOS app, can bundle the leo daemon and tmux and run them as its own LaunchAgent. When it does, leo is managed: the app owns the daemon's registration, binary path and tmux binary, and leo defers to it. This page describes the contract between leo and the app. Nothing here applies to a plain CLI install (brew or curl), which behaves as it always has.
A managed install is macOS-only. On Linux a marker is ignored with a warning.
The marker¶
The app writes ~/.leo/state/managed.json (under the resolved leo home's state/, so isolated homes work too), atomically. Every leo invocation, CLI and daemon, reads it.
{
"version": 1,
"manager": "Leo.app",
"bundle_id": "studio.blackpaw.leo.macos",
"launchd_label": "studio.blackpaw.leo.daemon",
"self_path": "~/.leo/app/bin/leo",
"tmux_path": "~/.leo/app/tmux/3.6a/tmux",
"env": { "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" }
}
| Field | Meaning |
|---|---|
version | Must be 1. |
manager, bundle_id | Who owns the install. manager is shown in leo status and leo doctor. |
launchd_label | The app's LaunchAgent. Reverse-DNS, and never com.blackpaw.leo (the CLI install's own label). |
self_path | The stable path leo persists wherever it records its own binary (see below). ~ is expanded. |
tmux_path | The pinned tmux, laid out <root>/<version>/tmux. ~ is expanded. |
env | Optional string-to-string map the daemon applies to its own environment at startup, before it launches anything. Agents inherit it, and it overrides inherited values. |
Validation¶
A marker that fails any check is ignored: leo behaves as unmanaged, prints one warning per process to stderr (and leo doctor repeats it), and never refuses to run.
- The file is valid JSON with
version1; unknown fields are ignored. self_pathandtmux_pathare absolute after~expansion, and both exist.tmux_pathlooks like<root>/<version>/tmux.launchd_labelis reverse-DNS and is notcom.blackpaw.leo.envkeys are non-empty with no=or NUL; values have no NUL. One bad entry invalidates the whole marker.- The file is not group- or world-writable: it controls a path that ends up in hooks agents execute.
- The platform is macOS.
What changes when managed¶
| Command or place | Managed behaviour |
|---|---|
Persisted leo paths (leo-mcp.json, codex hooks.json and config.toml, the tmux prefix+L binding, LEO_BRIDGE_BIN) | Use self_path instead of the running executable, so an app update never leaves a stale path behind. |
| tmux | tmux_path is used for the daemon and the CLI (see the tmux guard). |
leo update | Refuses: leo is managed by Leo.app; update the app (Leo ▸ Check for Updates). This includes --check. Independently of the marker, a leo binary inside *.app/Contents/ never replaces itself, since editing a signed bundle breaks its code signature. |
leo service start, stop (and --daemon) | Refuse, naming Leo.app and the launchd label. The app owns registration, and its KeepAlive would undo a CLI stop. |
leo service restart | Runs launchctl kickstart -k gui/<uid>/<launchd_label>, never touching com.blackpaw.leo. The web UI's restart button and leo web enable go through the same path. |
leo setup | Skips the daemon install with an info line. |
leo status | Reports the manager and the label, and the status of that job. |
leo doctor | Reports the managed state, the job, self_path and where its symlink points, tmux_path against the version its directory claims, the tmux resolution (including a pending upgrade), and a loaded com.blackpaw.leo job that would race the managed daemon. |
The managed LaunchAgent runs exactly <bundle>/Contents/Helpers/leo service --supervised, with no --config: the daemon resolves ~/.leo itself and logs to state/service.log.
First start¶
Leo.app registers its LaunchAgent on a clean account before anything has run leo setup or installed the Claude CLI, and the app's Claude onboarding needs a live daemon. The managed daemon therefore starts without either:
- No config. When the marker is valid and the default config (
~/.leo/leo.yaml) does not exist, the daemon writes the setup wizard's default config (anassistanttemplate and the default model and turn limit, andweb: {enabled: true, bind: 127.0.0.1}so agents'leo_*tools and hooks can reach the daemon on port 8370) before loading it, and logsmanaged install: no leo.yaml found; created default config at …. The file is0600and a missing~/.leois created0700. The write is atomic and never replaces a file: an existing config, or one that appears concurrently, is used as is. Unmanaged installs keep exiting withno leo.yaml found — run 'leo setup' first, and an explicit--configis never created. - No
claude. A missing Claude CLI never stops the daemon (managed or not). It is looked up again each time something needs it, so installing it needs no restart. Until then: GET /healthon the socket reports"claude": {"found": false}, and{"found": true, "path": "/…/claude"}once it resolves. The lookup uses the marker'senv.PATHfirst, like every other binary.- Spawning a claude-harness agent fails with
claude CLI not found; install it from https://claude.com/claude-code. Where a JSON surface exists the error code isclaude_not_found(HTTP 424 on/api/agent/spawnand the socket's/agents/spawn). Agents on other harnesses are unaffected, and a claude agent restored at boot is kept as stopped (leo agent restartonce claude is installed). - Scheduled and persistent tasks that need claude fail per run with the same message; the daemon keeps running.
leo doctorreportsClaude Code: not found.
The tmux version guard¶
tmux clients and servers must be the same version. The app pins one tmux per directory, <root>/<version>/tmux, and never removes a version directory a running server may still use.
If a -L leo server is running with version V and tmux_path is a different version, leo uses <root>/V/tmux for every command (daemon and CLI) and logs tmux upgrade pending; tmux_path is used only when a new server starts, for example after a reboot. leo never kill-servers to resolve a mismatch. When no copy matching the server's version exists, leo logs an error and falls back to tmux_path; attaching may fail, and leo doctor reports it. The CLI stays silent about a pending upgrade.
One daemon per home¶
The daemon takes an exclusive flock on state/daemon.lock before it touches leo.sock, the log or tmux. A second daemon for the same home exits non-zero with another leo daemon owns <home> (pid N). This holds for managed and unmanaged installs alike.
While both the CLI job and the managed job are registered, the loser respawns about every 10 seconds under KeepAlive. It is noise, not harm; leo doctor flags it.
Handover¶
leo service handover moves the daemon between the CLI install and the app. The app calls it; it is not meant for hand use. Both directions drain busy agents first (the graceful restart drain), then change registration while the tmux server and its sessions stay up (launchctl bootout of a leo job leaves the foreground tmux server running), so the new daemon adopts the agents.
leo service handover --to-app --label studio.blackpaw.leo.daemon --json
leo service handover --to-cli --json
| Flag | Description |
|---|---|
--to-app | Hand the daemon to Leo.app. Needs --label, the launchd label the app registers. |
--to-cli | Release the daemon back to the CLI install. |
--json | One JSON object on stdout; progress on stderr; failure exits non-zero. |
--dry-run | Run the read-only checks and print the plan. Changes nothing. |
--drain-timeout, --force | As for leo service restart. |
--to-app checks that there is no marker, that the label is valid and differs from the CLI install's own, and that the CLI plist exists. It records the plist's EnvironmentVariables (the app's LaunchAgent can carry them), drains, boots out the CLI job, moves the plist to <plist>.handed-over, and waits until daemon.lock is free. The app then writes managed.json and registers its LaunchAgent. A CLI daemon whose plist exists but whose job is not loaded skips the drain and bootout; only the plist moves. A daemon running with no launchd job at all fails no_launchd_job; "running" means it holds daemon.lock, not that its socket answers, so a daemon with broken IPC is never mistaken for a stopped one.
--to-cli checks the marker, the .handed-over backup, and that the backup's ProgramArguments[0] exists and is executable. It drains through the managed daemon, boots out the marker's job, waits for the lock, moves managed.json to managed.json.releasing, restores the plist, bootstraps it, waits until the CLI daemon is ready, and deletes managed.json.releasing. The marker moves aside before the CLI daemon starts: with it live, the new daemon would persist the app's self_path everywhere. If the restored binary is older than the one doing the handover, the result carries a warning and the release continues.
--to-cli without a marker (recovery). If --to-app succeeded but the app then failed to write managed.json, the CLI job is already booted out and its plist sits at <plist>.handed-over, with no managed job to release. With no marker (absent, not merely invalid) and that backup present, --to-cli needs --label <the app's launchd label> (leo cannot know it without the marker; missing is invalid_args, and it is validated like --to-app's). It then skips the drain and the bootout and fails managed_job_loaded if launchctl print gui/<uid>/<label> finds the app's job still registered (a crashed or restarting app job would otherwise run beside the restored CLI job; the app must unregister it first). It fails daemon_running if a daemon holds daemon.lock, validates the backup's ProgramArguments[0] as above, moves the backup back to the plist, bootstraps it and waits until the daemon is ready. The result is ok: true with managed_stopped: false and restored_without_marker: true. A failure moves the plist back to .handed-over (after a best-effort bootout of the CLI job, in case launchd registered it in part) and reports rolled_back. With no marker and no backup the error is still not_managed; an invalid marker is never treated as absent. --dry-run prints the plan for this path too.
Result¶
{
"ok": true,
"previous_label": "com.blackpaw.leo",
"env": { "PATH": "/opt/homebrew/bin:/usr/bin:/bin" },
"plist_backup": "/Users/me/Library/LaunchAgents/com.blackpaw.leo.plist.handed-over"
}
previous_label is the job that was stopped: the CLI label for --to-app, the marker's label for --to-cli. env is only set by --to-app. A failure is {"ok": false, "step": …, "error": …, "message": …} plus whatever applies of:
| Field | Meaning |
|---|---|
rolled_back | Present only when an undo was attempted. true: the files and job were restored. false: the undo failed too, so the previous state was not restored. |
managed_stopped | Always present (false unless set). --to-cli only. The managed job was booted out and leo cannot re-register an app-owned job, so the app must register its LaunchAgent again. |
restored_without_marker | --to-cli recovery only: the restore ran with no marker, so no managed job was stopped. |
warnings | Non-fatal notes (an older restored binary, a leftover .releasing file). |
plan | With --dry-run: the steps that would run. |
Steps: preflight, env, drain, bootout, plist, lock, marker, bootstrap, ready. Error codes, all part of the contract: unsupported, already_managed, invalid_label, label_conflict, no_launchd_job, plist_missing, env_unreadable, drain_timeout, drain_failed, bootout_failed, plist_move_failed, lock_timeout, not_managed, backup_missing, backup_unusable, marker_move_failed, plist_restore_failed, bootstrap_failed, not_ready, daemon_running, managed_job_loaded, config_error, plus invalid_args and not_permitted for bad flags and denied agents.
The JSON rule¶
Every --json command (service restart, service handover, pair, web enable) follows one rule:
- stdout carries only the JSON object; logs and progress go to stderr;
- a failure is
{"error": "<stable code>", "message": "…"}(message optional) with a non-zero exit. That includes bad flags and arguments, which answerinvalid_args.
leo service restart --json returns exit 0 for a daemon that is down but whose job is loaded (drained: false), not_registered (exit 1) when the job is not loaded, and drain_timeout (exit 1) when busy agents do not pause in time and --force is not set. Other codes: restart_failed, drain_failed, config_error.
Repair¶
| Symptom | Fix |
|---|---|
warning: managed.json ignored: … | Fix the named field, or let the app rewrite it (the app shows "repair"). leo runs unmanaged in the meantime. |
leo update / service start refuse but you removed the app | Delete ~/.leo/state/managed.json. |
Two daemons fighting (another leo daemon owns … in the log, respawning) | launchctl bootout gui/$(id -u)/com.blackpaw.leo after a handover, or bootout the app's label. leo doctor names the one still loaded. |
tmux upgrade pending | Nothing to do; the next server start uses the pinned version. |
doctor reports tmux_path … sits in directory … | The app copied a different tmux into that directory; have the app re-copy it. |
| Agents lose LAN access after a takeover | The adopted tmux server's creator is gone; leo service reparent repairs it, bouncing agents. |
A failed --to-cli left the app's agent stopped (managed_stopped: true) | Have the app register its LaunchAgent again. |
Moving Leo.app out of /Applications | The ~/.leo/app/bin/leo symlink goes stale. Move it back; the daemon keeps running from the old path until then. |
| Remote attach to a managed Mac | The SSH paths run host.tmux_path (default tmux), which is not the bundled one. Set tmux_path on the host entry. |