Regression (2.1.195 → 2.1.196): background daemon transient-spawn fails over SSH on macOS — "Could not switch to audit session: Operation not permitted"
Regression (2.1.195 → 2.1.196): background daemon transient-spawn fails over SSH on macOS — "Could not switch to audit session: Operation not permitted"
Environment
- macOS, Apple Silicon
- Running over SSH (VS Code Remote-SSH;
sshd … @notty,SSH_CONNECTIONset) - Regression introduced in 2.1.196; still present in 2.1.197. Last-good: 2.1.195.
Symptom
With no daemon already running, the REPL session switcher / background agents cannot cold-spawn the background daemon from an SSH session — it hangs, then reports:
Couldn't reach the background service (not running) — run 'claude daemon status'
claude -r and foreground interactive sessions are unaffected (they never spawn the daemon). The bug is masked whenever a daemon is already running, because SSH clients just attach to the existing one instead of cold-spawning. It only surfaces on the first cold spawn from an SSH session after the daemon idle-exits.
Root cause
The fatal error is only visible in the client stderr (the daemon dies before it can open ~/.claude/daemon.log), so it must be captured with --debug-file:
[ERROR] daemon: transient spawn stderr:
Could not switch to audit session 0x…: 1: Operation not permitted
[WARN] background spawn failed: Couldn't reach the background service (not running)
macOS denies the audit-session switch in an SSH notty context. The client-side daemon-spawn code appears to have changed between 2.1.195 and 2.1.196 to require this audit-session join.
Deterministic A/B repro
Over SSH. Force a transient spawn with --bg, running claude daemon stop --any before each run so there is no pre-existing daemon to attach to. Invoke specific versions directly (the version entry under ~/.local/share/claude/versions/<v> is the executable itself, not a directory):
claude daemon stop --any
~/.local/share/claude/versions/<version> --bg -p "hi" --debug-file /tmp/x.debug
Results, repeated A/B/A/B, every run starting from daemon status = not running:
| Client version | audit-session error | result |
| -------------- | ------------------- | -------------------------------------------------------------------- |
| 2.1.195 | none | ✓ backgrounded; daemon log shows daemon start … origin=transient |
| 2.1.196 | yes | ✗ Couldn't reach the background service |
| 2.1.197 | yes | ✗ Couldn't reach the background service |
The daemon binary launched is identical (the 2.1.197 default) in all three cases — only the spawning client version differs. This isolates the regression to the client-side daemon-spawn path between 2.1.195 → 2.1.196.
Workarounds
- Keep a daemon alive so SSH clients attach instead of cold-spawning:
claude daemon runin a spare terminal/tmux (foreground origin works fine over SSH), or seed the daemon once from a GUI-session launch / the Desktop app. - Pin to 2.1.195.
Asks
- Restore ≤2.1.195 spawn behavior over SSH — don't require an audit-session switch for the transient daemon spawn, or fall back gracefully when it's denied.
- Fail fast with an accurate message instead of a long hang followed by the misleading "background service (not running)"; surface the underlying
Operation not permittedto the user, not just to--debug-file.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Confirming on 2.1.197 over SSH (Termius, non–VS Code,
SSH_CONNECTIONset, no tmux) — identicalCould not switch to audit session 0x…: 1: Operation not permitted, captured in~/.claude/debug/*.txtas[ERROR] daemon: transient spawn stderr:. Rolling the client back to 2.1.196 still reproduces, matching the 195→196 regression window.One extra data point for the Workarounds section: seeding the daemon through the launchd / GUI-session path also fails with the same audit-session EPERM:
So a LaunchAgent can't be used to keep the daemon alive either — the audit-session switch is denied even in the launchd context. The only path that avoids the switch is a plain shell-child
claude daemon run(foreground origin), as noted.For SSH-only users, automating that per login works well — in
~/.zprofile:+1 on Ask #1 — SSH-only workflows have no GUI/aqua session to seed from, so a graceful fallback when the audit-session join is denied is the real fix.
Adding a root-cause detail from decompiling 2.1.198 that I haven't seen in the thread yet — it pins why the transient spawn suddenly requires an audit-session switch, and why the failure is a silent indefinite hang rather than an error.
**The daemon bootstrap wraps the launch in
launchctl asuserwhenever a GUI (Aqua) session exists — it never checks whether the current process can actually join it.** Deminified from the 2.1.198 binary:launchctl asusercallsaudit_session_join(<GUI-login-asid>). From any process that is not a member of the GUI login audit session — a plain SSH login session, or a tmux server that was started over SSH (its panes inherit the server's session) — a non-rootasusercannot join that session:macOS is behaving correctly here (a non-root process may not join an arbitrary audit session). Two things turn that expected EPERM into the indefinite silent hang everyone in this thread is seeing:
detached+.unref()'d and its exit code is never checked, so theasuserEPERM is never observed.launchctlexits without ever execingclaude daemon run; the control socket is never bound; the client then loopsconnect()on the missing socket effectively forever.asusernon-zero exit, so this particular failure has no recovery path.The daemon itself does not need the Aqua session — foreground
claude daemon runbinds the socket fine because it execs the daemon directly, with noasuser. So the wrap is an optimization, not a hard requirement.Confirmed warm/cold behavior (consistent with the reports here): the failure is cold-start-only.
claudeonce at the physical GUI console → it bootstraps a daemon in the Aqua session → SSH clients then justconnect()the live socket and work.sshin andclaude --bg 'ping'hangs; run the same thing from the console and it succeeds.A commenter above (
etinpres) independently hit the same EPERM runninglaunchctl asuser "$(id -u)" … daemon runby hand — that's the exact primitive.Environment: macOS 26.5.2 (25F84), reproduced on Intel (x86_64); the mechanism is audit-session-based, not architecture-specific.
reattach-to-user-namespacedoes not help.Suggested fixes, cheapest first:
launchctl asuserexits non-zero — the same path foregrounddaemon runalready uses successfully, and consistent with the ENOENT/EACCES fallbacks already in the bootstrap.connect()retry (e.g. 30–60 s) and surface the bootstrapstderr.log(it already contains the exact error) instead of hanging.asuserwrap on joinability, not just GUI-session existence.**Minimal repro of just the failing primitive:
Workaround in the meantime: start a
claudesession at the physical console first (seeds the daemon in the Aqua session), then work over SSH; or downgrade to 2.1.195, which predates the regression.Fixed in 2.1.199 ✅
Confirmed over SSH (Termius,
SSH_CONNECTIONset, non–VS Code, no tmux). Where 2.1.196–2.1.198 hung indefinitely on a coldclaude --bg, 2.1.199 spawns the background daemon fine.Changelog: "Fixed background agents failing to cold-start over SSH on macOS with 'Could not switch to audit session' (regression in 2.1.196)." Matches @jhamlet's root-cause exactly — i.e. suggested fix #1: fall back to a direct spawn when
launchctl asuserexits non-zero.Removing my
~/.zprofileclaude daemon runseeding workaround now that the on-demand path is fixed. Thanks for the quick turnaround 🙏