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"

Status Fixed / completed
Reported on v2.1.195
Maintainer reply None cached
Activity 4 comments · opened Jul 1, 2026 · closed Aug 19, 2026

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_CONNECTION set)
  • 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 run in 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

  1. 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.
  2. Fail fast with an accurate message instead of a long hang followed by the misleading "background service (not running)"; surface the underlying Operation not permitted to the user, not just to --debug-file.

View original on GitHub ↗

3 Comments

etinpres · 2 months ago

Confirming on 2.1.197 over SSH (Termius, non–VS Code, SSH_CONNECTION set, no tmux) — identical Could not switch to audit session 0x…: 1: Operation not permitted, captured in ~/.claude/debug/*.txt as [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:

launchctl asuser "$(id -u)" "$(command -v claude)" daemon run
# → Could not switch to audit session 0x…: 1: Operation not permitted

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:

if command -v claude >/dev/null 2>&1; then
  if ! claude daemon status 2>/dev/null | grep -q '^pid:'; then
    nohup claude daemon run </dev/null >/dev/null 2>&1 & disown
  fi
fi

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

jhamlet · 1 month ago

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 asuser whenever a GUI (Aqua) session exists — it never checks whether the current process can actually join it.** Deminified from the 2.1.198 binary:

async function vrm() {                                        // builds the macOS wrap prefix
  let uid = process.getuid();
  let hasGui = <`/bin/launchctl print gui/${uid}` exits 0>;   // GUI session EXISTS?
  telemetry("tengu_bg_daemon_macos_aqua_wrap", { has_gui: hasGui });
  return hasGui ? ["/bin/launchctl", "asuser", String(uid)]   // wrap iff a GUI session exists
                : [];
}
// spawn: launchctl asuser <uid> claude daemon run …, { detached: true, stdio: [.., stderrLogFd] }.unref()

launchctl asuser calls audit_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-root asuser cannot join that session:

Could not switch to audit session 0x186a4: 1: Operation not permitted

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:

  1. The spawn is detached + .unref()'d and its exit code is never checked, so the asuser EPERM is never observed. launchctl exits without ever execing claude daemon run; the control socket is never bound; the client then loops connect() on the missing socket effectively forever.
  2. There are fallbacks for adjacent spawn errors (ENOENT/EACCES on execpath / versions) but none for the asuser non-zero exit, so this particular failure has no recovery path.

The daemon itself does not need the Aqua session — foreground claude daemon run binds the socket fine because it execs the daemon directly, with no asuser. 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.

  • Start claude once at the physical GUI console → it bootstraps a daemon in the Aqua session → SSH clients then just connect() the live socket and work.
  • Let the daemon idle-exit (~5 s with no clients) → the next cold SSH invocation hangs again.
  • Reproduced directly: with nothing kept warm, ssh in and claude --bg 'ping' hangs; run the same thing from the console and it succeeds.

A commenter above (etinpres) independently hit the same EPERM running launchctl asuser "$(id -u)" … daemon run by 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-namespace does not help.

Suggested fixes, cheapest first:

  1. Fall back to a direct spawn when launchctl asuser exits non-zero — the same path foreground daemon run already uses successfully, and consistent with the ENOENT/EACCES fallbacks already in the bootstrap.
  2. Bound the client connect() retry (e.g. 30–60 s) and surface the bootstrap stderr.log (it already contains the exact error) instead of hanging.
  3. **Gate the asuser wrap on joinability, not just GUI-session existence.**

Minimal repro of just the failing primitive:

launchctl asuser "$(id -u)" true   # -> EPERM over SSH / inside a tmux server; exit 0 from the GUI console

Workaround in the meantime: start a claude session 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.

etinpres · 1 month ago

Fixed in 2.1.199 ✅

Confirmed over SSH (Termius, SSH_CONNECTION set, non–VS Code, no tmux). Where 2.1.196–2.1.198 hung indefinitely on a cold claude --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 asuser exits non-zero.

Removing my ~/.zprofile claude daemon run seeding workaround now that the on-demand path is fixed. Thanks for the quick turnaround 🙏

Showing cached comments. Read the full discussion on GitHub ↗