[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

Status Open
Reported on v2.1.217
Maintainer reply None cached
Activity 1 comment · opened Jul 24, 2026

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, no ProcessType key 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:

  1. Assistant streams thinkingtext → a complete tool_use block (input fully received — the tool later runs correctly with that exact input), all within ~6–10 s of the turn starting.
  2. Then nothing. No tool_result, no further events.
  3. On reset (user returns / process killed), a synthetic assistant event 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:5969m 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 Read calls 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/PreventUserIdleSystemSleep assertions continuously; pmset -g log shows 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:

  1. Run claude remote-control as a launchd LaunchAgent (no ProcessType key) on a Mac desktop.
  2. Walk away from the Mac (display asleep / no HID input for ~15+ min).
  3. From the iOS app (or by POSTing a user event to /v1/code/sessions/{id}/events with no client attached — same result), send any prompt that triggers a tool call.
  4. 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 = Interactive on 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/sessions for sessions with status_bucket == "working" whose last_event_at is 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

  1. 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.
  2. 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.
  3. Surface a "worker unresponsive" state to the client instead of an indefinite "Running…" spinner (relates #51267's "no remote unstick mechanism").
  4. Document ProcessType=Interactive (and/or SHELL=/bin/sh, cf. #78903) as deployment guidance for running claude remote-control under launchd.

Happy to provide full sanitized transcripts, the server-side event dumps, and pmset logs on request.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗