[BUG] Remote Control (macOS/launchd): headless session workers freeze process-wide mid-turn at first tool_use while the Mac is user-idle; all unfreeze on first HID input
What's Wrong?
Remote Control (macOS): launchd-spawned headless session workers freeze process-wide mid-turn while the Mac is user-idle; all unfreeze simultaneously on first HID input
Closest existing issues: #51267 (Remote Control hangs, no remote unstick), #52860, #53328 (tool_use lost in streaming receive), #78966 (headless -p hang after idle keep-alive reuse). This report adds a forensically-pinned trigger for at least one large sub-class of these: macOS user-idle suspension of the headless worker processes, with a timestamp-level correlation between the unfreeze and the user's first HID input.
Summary
claude remote-control runs as a launchd LaunchAgent on an always-on Mac desktop; the Claude iOS app connects to it. Sessions spawned from the phone wedge on "Running…" at the first tool call of a turn — plain chat streams fine, any tool use hangs. The wedged worker is not stuck on one HTTP stream: the whole child process freezes (stops publishing to the bridge, stops answering client initialize control_requests, never executes the local tool — even trivial Read/ls under bypassPermissions). Frozen workers stay wedged for 60–70+ minutes and then all unfreeze within the same second — five seconds after the user's first physical input at the Mac. The user is, by definition, away from the Mac whenever they use Remote Control from the phone, so this presents as "Remote Control always hangs on tool use."
Environment
- macOS Darwin 25.4.0, Apple Silicon desktop (always on, Studio Display, never system-sleeps — sleep ruled out via
pmset -g log: no sleep events, display-on assertions held) - Claude Code 2.1.217 and 2.1.218 — both affected (see version A/B below)
- Daemon:
claude remote-control --permission-mode bypassPermissions --name <name>as a launchd LaunchAgent (RunAtLoad+KeepAlive, noProcessTypekey initially) - Workers it spawns per session:
claude --print --sdk-url https://api.anthropic.com/v1/code/sessions/cse_… --session-id cse_… --input-format stream-json --output-format stream-json --replay-user-messages --permission-mode bypassPermissions - Subscription OAuth; client is the Claude iOS app (reproduced across an app update, so not app-version-specific)
Forensic evidence (9/9 wedges on 2026-07-24, all identical)
Local transcript signature (~/.claude/projects/<dir>/<uuid>.jsonl), every wedged session:
- Assistant streams
thinking→text→ a completetool_useblock (input fully received — the tool later runs correctly with that exact input), all within ~6–10 s of the turn starting. - Then nothing. No
tool_result, no further events. - On reset (user returns / process killed), a synthetic
assistantevent is written:API Error: Response stalled mid-stream. The response above may be incomplete., followed by an immediate retry that succeeds in sub-second time.
Example: a turn issued Bash: ls -la <dir> at 11:59:17 local; the tool_result landed at 13:08:59 — 69m 41s for ls -la — then the next three tool calls in the same session completed in < 1 s each.
Server-side event log (GET /v1/code/sessions/{id}/events) for the same wedge — proves the freeze is process-wide, not a stalled receive:
08:59:17Z worker assistant tool_use ← worker received AND republished the complete tool_use
09:02:01Z client control_request (initialize) ← iOS app reconnected; worker NEVER answers
… 67 minutes of nothing from the worker …
10:09:00Z worker assistant text ("API Error: Response stalled mid-stream…")
10:09:00Z worker user tool_result ← tool finally executed, turn resumes normally
A worker that received the tool_use, is running bypassPermissions, and doesn't execute a local ls for 67 minutes — while also not answering bridge control traffic — is a suspended process, not a broken stream.
The unfreeze correlation (the smoking gun): three independently-wedged workers (separate processes, wedged at 11:59:17, 11:59:30, 12:02:28 local) all emitted their stall-error-and-recover sequence within the same second, 13:08:58–59 — and pmset -g log shows a UserIsActive "Bluetooth LE HID Activity" assertion created at 13:08:53, i.e. the user's first mouse/keyboard touch after being away. No client events, no network change, no process restarts at that time — just HID input.
Every wedge occurred while the Mac was user-idle. Every controlled repro with a user active at the Mac passed:
- warm worker, no client attached, tool turn → completes in ~5 s
- freshly-spawned worker (first turn after spawn, the exact wedge shape), no client attached → completes in ~39 s
- worker forced into the background band with
taskpolicy -b, tool turn → completes in ~13 s (so plain background-band throttling is NOT sufficient — genuine user-idle suspension is the missing ingredient)
Ruled out
- CLI version: wedges reproduced identically on 2.1.217 (pinned, auto-update disabled) and 2.1.218.
- Hooks: wedges include bare
Readcalls that have no PreToolUse hook in this repo; the SessionStart hooks visibly completed seconds earlier. - Shell-snapshot / #78903: first tokens and full text turns stream fine from the same workers.
- Network path / API incident: interactive TUI sessions on the same machine, account, and network were unaffected throughout; status page clean.
- System sleep: machine holds display-on/
PreventUserIdleSystemSleepassertions continuously;pmset -g logshows no sleep entries. - iOS app version: reproduced before and after an app update mid-incident.
Reproduction
Deterministic repro requires genuine user-idle (synthetic taskpolicy -b is not enough, see above), so it's a "leave the desk" repro:
- Run
claude remote-controlas a launchd LaunchAgent (noProcessTypekey) on a Mac desktop. - Walk away from the Mac (display asleep / no HID input for ~15+ min).
- From the iOS app (or by POSTing a
userevent to/v1/code/sessions/{id}/eventswith no client attached — same result), send any prompt that triggers a tool call. - Observe: text/thinking stream, then permanent "Running…" at the first tool_use. The worker stays frozen until physical input at the Mac (or SIGKILL — note a suspended worker holds SIGTERM pending until resumed, so remote supervision needs TERM → CONT → KILL).
Workarounds that hold up so far
ProcessType=Interactiveon the daemon's LaunchAgent plist (job properties require bootout+bootstrap, not kickstart). Validation window still short, but this is the documented launchd lever for exempting a job (and its children) from user-idle throttling.- A user-side watchdog that polls
GET /v1/code/sessionsfor sessions withstatus_bucket == "working"whoselast_event_atis stale, and kills just the matching local worker (TERM → CONT+TERM → KILL); the daemon respawns it on demand and the server-side session state, including the queued turn, survives.
Asks
- Remote-control workers (and the daemon) should take an explicit stay-active assertion while a turn is in flight (
NSActivity/IOPMAssertion/RunningBoard equivalent), so macOS user-idle management cannot suspend a worker mid-turn on an unattended machine — unattended is Remote Control's core use case. - Worker-side liveness: the stall detector clearly exists (it fires the moment the process resumes) — it should also arm across suspension, or the worker should self-check turn-age on resume instead of sitting on a 60+ min dead receive.
- Surface a "worker unresponsive" state to the client instead of an indefinite "Running…" spinner (relates #51267's "no remote unstick mechanism").
- Document
ProcessType=Interactive(and/orSHELL=/bin/sh, cf. #78903) as deployment guidance for runningclaude remote-controlunder launchd.
Happy to provide full sanitized transcripts, the server-side event dumps, and pmset logs on request.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗