Desktop remote (SSH): relaunching the app replaces the running ccd-cli (SIGTERM + --resume) and silently kills in-flight background work
Environment
- Claude Desktop (Code tab), remote session over SSH to a Linux VM (Ubuntu 26.04 ARM64)
- Remote CLI:
ccd-cli/2.1.255and2.1.260(both reproduce) - Remote daemon:
.../remote/srv/<hash>/server --serve --socket .../rpc.sock - Observed 2026-09-06 / 2026-09-07 UTC
What's wrong
When a Claude Code session runs on a remote host (Claude Desktop → SSH → remote daemon), fully quitting and relaunching the desktop app causes the remote session process (ccd-cli) to be terminated with SIGTERM and re-spawned via --resume. Any background subagent or background Bash task still running at that moment dies with it, silently.
The daemon is demonstrably capable of re-attaching to the existing process instead — it does exactly that when the laptop lid is closed and reopened. So the termination appears to be a policy choice rather than a technical constraint.
What should happen
A new app instance should adopt the existing ccd-cli for a session whose id matches (the lid-close/reopen path already does this), leaving in-flight background work running. If replacement is unavoidable (e.g. the CLI binary was updated), the user should be warned before it happens and the loss should be surfaced to the user, not only to the model.
Observed architecture
ccd-cliis a child of the long-lived daemon, not of the bridge process.- The desktop app connects over SSH to a
server --bridge --socket .../rpc.sockprocess, which relays to the daemon, which pipes stdio toccd-cli. ccd-cliholds its own direct outbound TLS connections to the Anthropic API. API access does not depend on the frontend being connected.- Mobile clients do not use the local
rpc.sockat all; they reach the session throughccd-cli's own cloud connection.
Steps to reproduce
- On a remote session, start a background subagent that runs for ~45 minutes and appends a heartbeat line to a file every 15 seconds.
- Quit the desktop app completely (not just close the lid / window).
- Wait several minutes. The heartbeat keeps running — confirmed for 9m42s of full disconnection, and separately for 42m30s.
- Relaunch the desktop app, open the same session, and submit any prompt.
- The heartbeat stops. The remote server log shows the old
ccd-cliexiting with code 143 (SIGTERM) and a new one starting with--resume=<sessionId>.
Evidence
Four independent occurrences, all following an app restart or re-authentication:
| New connection | exited with code 143 | gap |
|---|---|---|
| 23:54:18 | 23:54:41 | 23 s |
| 00:25:15 | 00:25:36 | 21 s |
| 01:58:01 | 01:58:04 | 3 s |
| 03:36:57 | 03:37:38 | 41 s |
The gaps track how long the user took to open the session in the app. Corrected 2026-09-09 — see the comment below: the replacement happens on opening the session, before any prompt is submitted.
Contrast — lid close and reopen (same app instance, no restart):
| New connection | SIGTERM | result |
|---|---|---|
| 00:50:00 | none | process re-attached, background work survived |
Contrast — mobile client after a full desktop quit:
- No
New connectionevent in the remote server log at all (mobile bypassesrpc.sock) - No SIGTERM;
ccd-clisurvived; the in-flight subagent completed all 180 heartbeat ticks
Impact
A user who steps away, then relaunches the desktop app and types a message, silently loses whatever background work was in flight. There is no warning, and the loss is easy to misread as the agent having stalled — the conversation resumes normally because --resume restores the transcript, but the background task is simply gone.
This is most damaging for long-running delegated work (background subagents doing builds, reviews, or multi-step implementation), which is exactly the workload the feature exists for.
Suggested improvements, in order of preference
- Re-attach instead of replacing. The daemon already re-attaches on lid-close/reopen. Extend that path so a new app instance adopts the existing
ccd-cliwhen the session id matches and the CLI version is compatible. - If replacement is required (e.g. the CLI binary was updated), warn before doing it: "This session has background work in progress. Reconnecting will restart it." — and let the user wait instead.
- At minimum, surface the loss. Today the killed subagent produces a notification saying no completion record was found and it "may have been running when the previous Claude Code process exited". That text reaches the model, not the user. The user should see it.
Note
Mobile behaves correctly today — it neither requires nor performs process replacement. The gap is specific to the desktop SSH/local-pipe path.
Related: #49790 (older state of this path, where the session died on disconnect — that half is fixed; the process now survives disconnect and dies on reconnect instead), #89344 (bridge-end unrecoverability under Remote Control), #87658 (a second local session SIGTERMing the first).
4 Comments
Two corrections and some interface-level evidence, from a re-test and from inspecting the
serverbinary.Correction: the trigger is opening the session, not submitting a prompt
The body says the replacement appears to be triggered by prompt submission, inferred from the gap between
New connectionand the SIGTERM. That inference was wrong. On re-test, clicking the session in the desktop app is enough — the oldccd-cliis replaced before any prompt is submitted. The gaps in the table track how long it took to open the session, not to type.This makes the impact worse than described: merely revisiting a session after relaunching the app destroys its in-flight background work.
The replacement is a client-side decision, not a daemon policy
The body argues from behaviour that re-attaching is possible. The RPC surface of the
serverbinary shows it directly:These are client-invoked RPCs. The daemon does not decide to terminate anything — the app calls
kill/killAndWaitand thenspawn.reattachis right there in the same namespace.The request payloads carry both
processIdandsessionKey, and the daemon keeps aprocess.cliSessionKeymapping. So a client that has forgotten which process served a session can still ask for it by session key. Adopting the existing process does not require the app to persist a pid across restarts.A CLI update is not the cause
To rule out the "the binary was updated, so a restart was required" reading: in two of the four occurrences the process was replaced 2.1.260 → 2.1.260. Separately, on another host two CLI versions are running side by side under a single daemon right now — 2.1.255 across 8 processes and 2.1.260 across 10, with a third build still on disk. A version bump does not by itself cause a replacement.
A concrete replacement policy
Refining "What should happen": the body concedes that replacement is unavoidable when the binary was updated, and only asks for a warning. It does not have to concede that much.
Three points behind it.
Re-attach should be the only normal path, not one of two co-equal branches. Written as an either/or, an implementation can reasonably treat "a newer build exists" as the common case and churn every idle session on every app update.
Binary retention is not a constraint here. A re-attach needs no binary at all: an exec'd process holds its own inode, so pruning old builds cannot break it —
/proc/<pid>/exesimply reads… (deleted). And whenever a spawn is required, the right version to spawn is always the latest:--resumerestores a transcript, not process state, so there is no version continuity to preserve.-cli-keep 3can stay exactly as it is; it is a download cache, not a session-continuity mechanism.Don't derive compatibility from the retention count. "Older than the three builds we kept" is tempting but wrong. Retention is a disk-space knob, so changing
-cli-keepwould silently change which sessions get replaced. Three generations is also shorter than it sounds — on the host here the three builds were installed on 8/30, 9/2 and 9/7, a window of about a week. The test should be an explicit minimum protocol version that the client declares.Prerequisite: busy/idle has to be observable
Neither the app nor the daemon can see whether a background subagent or a background Bash task is still running — only
ccd-cliknows. So this policy needsccd-clito report a busy/idle state over the protocol. Worth naming explicitly, so the change isn't scoped as client-side only.Replacement is not free even when idle
--resumerestores the transcript, but a session owns process-local state that the transcript does not carry. stdio MCP servers are children ofccd-cli:Killing the CLI kills those along with whatever they hold — a Playwright browser context with live logins, for instance — and takes the persistent Bash shell's cwd and environment with it. So even an idle replacement is worth announcing, or deferring to a natural boundary, rather than doing silently on session open.
Log location, for anyone reproducing
The daemon log referenced in the body is at:
[process.Manager] … exited with code 143is the SIGTERM; the[Server] New connectionline above it is the reconnect. Idle-timeout closes (closing connection idle 5m …) are a useful control — they are never followed by a 143.Another reproduction: desktop app (Code tab) on a laptop, sessions on a Linux VPS over SSH, ccd-cli 2.1.280.
Sleep does not kill anything. On lid close the remote helper only logs
Connection closed/[frameSink] write failed, detaching, and the sessions keep working. On 2026-09-24 the laptop slept from 06:58 to 09:28 UTC and the busy sessions kept running the whole time. Three of them finished their work and merged PRs with the lid shut.Reconnect is the kill. From
~/.claude/remote/run/<id>/remote-server.log:Over 2026-09-21 to 09-24, 10 of 29
New connectionevents were followed within 3 minutes by one or moreexited with code 143, signalled at client request: 20 session processes in total. The replacement--resumeprocess gets "Continue from where you left off" and then sits idle.What is lost: anything in the old process tree. That covers Agent-tool subagents and Bash
run_in_backgroundtasks. At 09:28 one session lost six in-flight subagents that were each partway through a long judging task. The box side is not involved:loginctlshows Linger=yes, KillUserProcesses is off, and the helper itself stayed up. Only the processes the app told it to signal died.Workaround we are using: launch long helpers outside the session's process tree (
systemd-run --userwith asetsidfallback, runningclaude -pheadless), then poll for their results. We confirmed that a helper launched this way finishes after its parent session is SIGTERMed. This only protects work that was deliberately detached. Anything the model starts with the built-in Agent or background-Bash tools still dies on reconnect.The fix we'd want: a reconnect should re-attach to the running ccd-cli rather than replace it. Failing that, a setting that stops the app from signalling sessions it did not start in this connection.
Reproduced on Windows 11 client → Linux host, on the current Desktop build, with second-resolution logs that isolate the kill to the reopen step rather than the disconnect.
Environment
| | |
|---|---|
| Client | Claude for Windows 2.16120.0 (801c07), 2026-09-29 build |
| Remote host | Ubuntu 24.04.5 LTS, kernel 7.0.0-34-generic, x86_64, OpenSSH 9.6p1 |
| Remote CLI |
~/.claude/remote/ccd-cli/2.1.284|| Remote server |
~/.claude/remote/srv/89cb6289d0e434f573cfc7267dd0cbb8c56ca298/server --serve|| Permission mode | Auto |
Repro
Session A runs a 10-step fake task: a background heartbeat (logs every 10 s) plus ten sequential foreground Bash calls of ~55 s each, every step logged with start/complete timestamps and a
SIGTERMtrap. Each step is a separate tool call, so a step N→N+1 transition proves the agent loop itself advanced (tool result → model call → next tool call), not just that a child process survived.Timeline (all from
~/disconnect-test/progress.logand~/.claude/remote/run/<id>/remote-server.log)| Time | Event |
|---|---|
| 18:26:14 | Session A
ccd-clistarted (PID 636765) || 18:28:25 | step 3 started |
| 18:28:29 |
[Server] Connection closed— app quit || 18:29:20 → 18:29:22 | step 3 completed → step 4 started |
| 18:30:17 → 18:30:19 | step 4 completed → step 5 started |
| 18:31:14 → 18:31:16 | step 5 completed → step 6 started |
| 18:31:23 |
[Server] New connection— app relaunched || 18:31:37 | Session B (the one I reopened) gets
exited with code 143, signalled at client requestand is respawned with--resume. Fine — it was idle. || 18:32:11 → 18:32:13 | step 6 completed → step 7 started (session A still untouched, still advancing) |
| 18:32:50 | I click session A. Step 7 and the heartbeat both log
SIGTERMin the same second || 18:32:51 |
Process 0bc3a27e… exited with code 143, signalled at client request→ newccd-cliPID 640963 started with--resume|| after | No step 8/9/10 ever runs. The resumed session shows the transcript and sits idle. |
What this shows
ccd-clisurvive a full app quit, and the agent loop keeps advancing while the client is gone (three model→tool transitions during the 2 min 54 s offline window, at the same 2 s cadence as when connected). Server-side persistence is already there.signalled at client request), which happens even on an app that was relaunched minutes earlier; sessions that are not opened keep running. A first run of the same test, where the app auto-reopened the active session on relaunch, killed the task 1 s after reconnect — same mechanism.--resumeprocess does not continue the interrupted turn. The 2.16120.0 note "Fixed SSH sessions reopening idle instead of continuing the interrupted task when Claude Code on the remote host had stopped while the app was closed" does not cover this case, because the remote process had not stopped — the client stopped it.run_in_backgroundBash, and presumably subagents) is lost silently, as the original report says.Ask
Same as above: when the session ID matches a live tracked child of the daemon, reattach instead of SIGTERM +
--resume. If replacement is unavoidable (e.g. CLI version bump), prompt before killing a session with a running turn, or at least surface the loss in the UI.<details>
<summary>Repro scripts (no repo dependency)</summary>
~/disconnect-test/step.sh~/disconnect-test/heartbeat.shPrompt given to session A (Auto mode):
</details>
Two more triggers, same setup as my earlier comment (desktop app Code tab, sessions on a Linux VPS over SSH). Remote CLI
ccd-cli/2.1.284, observed 2026-10-01 UTC.1. A message sent from another session. Session A used the session-management tool (
send_message) to send a short "carry on" message to session B. B's turn had ended, but B still had arun_in_backgroundBash task running (a long dry run). The remote helper log shows B's process being replaced 2 seconds later:The new process resumed the same transcript (
--resume=<same uuid>). The background task was gone. The model in the resumed session got only this note: "Background shell command didn't finish before the previous session ended ... No completion record was found for it in the previous session." The person using the app got no warning. A secondsend_messageto another session 10 seconds earlier did the same (10:50:03, code 143, new process at 10:50:04).2. Archiving a session.
archive_sessiongives the sameexited with code 143, signalled at client requestline (10:41:23).Why this is easy to hit. Before nudging, session A checked the target with the session tools. The target's
lastActivityAtwas more than 2 hours old, although its transcript shows it was working 10 minutes earlier. So the stale timestamp made a busy session look idle, and the nudge killed its work.Workaround we use now. Before messaging, archiving or reopening a session, look for live
shell-snapshots/snapshot-bash-*children under that session'sccd-cliprocess (found byCLAUDE_CODE_HOST_SESSION_IDin/proc/<pid>/environ). If any exist, leave the session alone. Long work runs assystemd-run --userunits so it outlives the session process. This does not cover in-process subagents.What would fix it. Deliver a message to an existing idle
ccd-cliover stdin instead of replacing the process. If replacement is needed, refuse (or warn) while background tasks are running, and show the lost task to the user, not only to the model.