Point launchd at a script that just runs tmux new-session -d, flip on KeepAlive, and you get a job that restarts every few seconds instead of a durable background agent. The mismatch is subtle, and it comes down to which process launchd is actually watching.
What launchd sees when tmux detaches

The respawn loop happens because launchd is supervising the wrong PID. tmux new-session -d deliberately spawns a detached tmux server and session, then the tmux client that issued the command exits immediately with status 0 . launchd tracked that client, not the long-lived server, so from its point of view the program it launched has just terminated cleanly.
Under KeepAlive=true, any exit of the tracked PID triggers an immediate relaunch. So the sequence becomes: launchd starts the wrapper, the tmux client detaches and exits 0, launchd sees the exit and relaunches, the client exits again — an unbounded cycle. It only stops looking pathological once launchd applies its own exponential-backoff throttling, which slows the restarts but never resolves the underlying design error .
Apple's launchd documentation is explicit about the constraint the tmux launcher violates: a launchd-managed process must not daemonize, fork-and-exit, call setsid(), change its working directory internally, or redirect stdio itself. Any of these makes launchd believe the process died, and it will relaunch it . A detached-session launcher does exactly the prohibited thing — it forks the real work into the background and exits.
The architectural point is that KeepAlive was built for crash-restart of a long-lived foreground process: it assumes a clean exit means the job stopped doing its job. A detach launcher exits by design, not by crash, so KeepAlive reads normal behavior as failure. The fix, covered in the next section, is to stop asking launchd to keep-alive a launcher and instead let it fire once at load — or supervise the real foreground process directly.
The idempotent plist: RunAtLoad, no KeepAlive

Swap KeepAlive for RunAtLoad. A plist with RunAtLoad=true and no KeepAlive key fires the command exactly once — at login or boot — and does not relaunch when the process exits . That is the whole point: restart semantics move off launchd and onto the layer that should own them — the tmux server, which keeps sessions alive after a client detaches , or Claude Code's own supervisor daemon. launchd becomes a bootstrapper, not a babysitter.
Make the launcher idempotent with a tmux has-session guard as the first step in ProgramArguments. A launchctl unload/load cycle — routine after editing the plist — would otherwise fire the command again and spawn a duplicate session. The guard makes re-runs a no-op:
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>tmux has-session -t claude 2>/dev/null || tmux new-session -d -s claude 'claude'</string>
</array>Keep the plist minimal. Six keys carry the whole job, and stdio is owned by launchd through StandardOutPath/StandardErrorPath — not by shell redirects buried in ProgramArguments, which is one of the daemonizing behaviors launchd penalizes :
| Key | Value | Role |
|---|---|---|
Label | com.you.claude | Unique job identifier |
ProgramArguments | has-session guard + new-session | Idempotent launcher |
RunAtLoad | true | Fire once at load — no respawn |
WorkingDirectory | Project root | Set by launchd, not cd inside the process |
StandardOutPath | Log file path | launchd owns stdout |
StandardErrorPath | Log file path | launchd owns stderr |
One more requirement lives inside the session, not the plist. Claude Code run unattended must pre-approve its tools, or it blocks indefinitely on an interactive permission prompt that no one is there to answer . Declare them upfront — claude --allowedTools "Bash,Read,Edit" — or set an explicit permission mode. Omitting this is the silent failure that looks like a hang: the tmux session is alive, the plist loaded cleanly, and the agent is simply waiting for a keystroke.
The logic reduces to which process launchd watches. The illustrative snippet below (not executed as your daemon, just a model of the control flow) shows a detached launcher drawing three respawns under a keep-alive supervisor, versus zero when the supervisor tracks a real foreground process:
#!/usr/bin/env python3
"""launchd KeepAlive + `tmux new -d ...` exits fast, so launchd respawns it.
Fix: make the launchd Program stay in the foreground (or let launchd manage the
real daemon directly), instead of starting a detached tmux session and exiting.
"""
def launchd(program, keepalive=True, limit=4):
starts = 0
while True:
starts += 1
code = program()
print(f"start #{starts}: exit={code}")
if not keepalive or code == "still-running" or starts >= limit:
return starts
def tmux_detach():
print(" tmux new-session -d 'worker' # parent exits immediately")
return 0
def foreground_worker():
print(" exec worker # foreground process stays alive")
return "still-running"
print("bad: KeepAlive watches a detached tmux parent")
bad = launchd(tmux_detach)
print("\nfix: launchd watches the real foreground process")
good = launchd(foreground_worker)
print(f"\nrespawns: bad={bad - 1}, fixed={good - 1}")Daemonize, setsid, and stdio: how launchd interprets each

Three POSIX habits that make a program "well-behaved" as a classic Unix daemon are exactly the ones that break under launchd. Apple's own guidance is explicit: a launchd-managed process must not daemonize, fork-and-exit, call setsid, change its working directory internally, or redirect its own stdio, or launchd may conclude the job died and relaunch it . Here is what launchd actually sees for each:
- Daemonize / fork-and-exit: the PID launchd spawned exits immediately after forking its "real" worker. launchd cannot distinguish an intentional detach from a crash, so it treats the fast exit as failure — and with
KeepAliveset, restarts it. This is the same trap astmux new-session -d: the parent returns 0 while the work continues elsewhere. setsid(): creates a new process session, detaching the child from launchd's process group. launchd loses the ability to track, signal, or account for the resource usage of that child tree. A shutdown or restart no longer reaches the worker you actually care about.- Self-redirecting stdio: if your
ProgramArgumentspipes output internally, the file handles behindStandardOutPathandStandardErrorPathpoint at nothing and log capture silently breaks. Always route logs through the plist keys and let the program write to stdout/stderr normally.
The rule that ties these together: keep the process launchd started in the foreground, and let the plist — not the program — own detachment, session boundaries, and log routing.
The supervisor daemon: persistence after plist
Once the plist owns detachment, Claude Code's own supervisor can own the agent lifecycle — removing tmux as the process owner entirely. claude --bg starts an agent as a detached session and returns immediately, while claude agents, claude attach <id>, claude logs <id>, and claude stop <id> give you full lifecycle control without a multiplexer supervising anything . A separate supervisor process hosts these sessions, keeps their state on disk through auto-updates and supervisor restarts, preserves sessions across machine sleep, and reconnects on wake — documented reconnection behavior requires v2.1.200 or later .
What the supervisor does not survive is a full shutdown: running sessions stop and may need recovery. That is exactly where a RunAtLoad plist keeps earning its place — not as a KeepAlive respawner, but as a login/boot bootstrapper that re-launches or re-attaches the supervisor once on next login, then steps out of the way.
If you want a dashboard on top, Codeman v1.3.2 (MIT, released July 13 2026, ~461 GitHub stars) supervises up to 20 tmux sessions with a Respawn Controller that does idle detection and cycles context via /clear and /init at threshold . The takeaway: let the plist launch once, let the supervisor persist state, and keep tmux for inspection — never for the respawn logic itself.
Frequently asked questions
Why does KeepAlive=true cause a respawn loop with tmux new-session -d?
Because launchd tracks the PID of the process it launched, and the -d flag makes the tmux client exit with code 0 immediately after creating the detached session. With KeepAlive=true, launchd treats any exit as a failed job and relaunches it at once, so it never supervises the actual worker — only the launcher that keeps exiting. The result is a tight relaunch loop until launchd's exponential-backoff throttling kicks in. Apple's own guidance warns that launchd-managed processes must not fork-and-exit or detach, or launchd may conclude the process died and relaunch it .
Is KeepAlive=true ever safe to use with a terminal multiplexer?
Yes. KeepAlive=true is correct when ProgramArguments runs a single long-lived foreground process that does not fork-and-exit — then launchd is watching the real worker and gives you genuine crash-restart behavior. The respawn problem is specific to detach-and-exit launchers like tmux new-session -d, whose parent returns as soon as the session is created. A foreground exec wrapper (for example, exec-ing the agent directly, or a script that stays in the foreground) keeps launchd's PID assumption valid and is safe to pair with KeepAlive .
Which permission mode works for unattended Claude Code operation?
For tightly scoped jobs, run headless mode with an explicit allow-list — --allowedTools "Bash,Read,Edit" — so the run never blocks on an interactive approval prompt . For broader autonomy, use auto or plan mode combined with PreToolUse hooks that gate high-risk commands such as rm -rf before execution . Auto mode uses a separate classifier and still blocks categories like production deploys, data exfiltration, and force pushes, falling back after 3 consecutive or 20 total blocks . bypassPermissions is documented only for isolated containers and VMs — do not use it on your primary machine.
Does claude --bg make tmux redundant for long-running agent operation?
For lifecycle concerns — persistence across auto-updates, reconnect on wake (requires v2.1.200+), and respawn — the native supervisor daemon largely replaces tmux . claude --bg starts a session as a background agent and returns immediately, and claude attach <id>, claude logs <id>, claude stop <id>, and claude respawn <id> manage sessions through that supervisor . tmux still earns its place as an inspection layer: attach to watch live output, tail logs, and check git state next to claude attach <id> — just don't hand it the respawn logic.
Enjoyed this article? Subscribe to get new stories by email whenever they're published.