Desktop remote (SSH): relaunching the app replaces the running ccd-cli (SIGTERM + --resume) and silently kills in-flight background work

Status Open
Reported on v2.1.260
Maintainer reply None cached
Activity 4 comments · opened Sep 7, 2026

Environment

  • Claude Desktop (Code tab), remote session over SSH to a Linux VM (Ubuntu 26.04 ARM64)
  • Remote CLI: ccd-cli/2.1.255 and 2.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-cli is a child of the long-lived daemon, not of the bridge process.
  • The desktop app connects over SSH to a server --bridge --socket .../rpc.sock process, which relays to the daemon, which pipes stdio to ccd-cli.
  • ccd-cli holds 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.sock at all; they reach the session through ccd-cli's own cloud connection.

Steps to reproduce

  1. On a remote session, start a background subagent that runs for ~45 minutes and appends a heartbeat line to a file every 15 seconds.
  2. Quit the desktop app completely (not just close the lid / window).
  3. Wait several minutes. The heartbeat keeps running — confirmed for 9m42s of full disconnection, and separately for 42m30s.
  4. Relaunch the desktop app, open the same session, and submit any prompt.
  5. The heartbeat stops. The remote server log shows the old ccd-cli exiting 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 connection event in the remote server log at all (mobile bypasses rpc.sock)
  • No SIGTERM; ccd-cli survived; 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

  1. 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-cli when the session id matches and the CLI version is compatible.
  2. 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.
  3. 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).

View original on GitHub ↗

4 Comments

osanha · 1 month ago

Two corrections and some interface-level evidence, from a re-test and from inspecting the server binary.

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 connection and the SIGTERM. That inference was wrong. On re-test, clicking the session in the desktop app is enough — the old ccd-cli is 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 server binary shows it directly:

internal/handlers.(*ProcessHandler).spawn
internal/handlers.(*ProcessHandler).reattach
internal/handlers.(*ProcessHandler).kill
internal/handlers.(*ProcessHandler).killAndWait
internal/handlers.(*ProcessHandler).stdin

These are client-invoked RPCs. The daemon does not decide to terminate anything — the app calls kill/killAndWait and then spawn. reattach is right there in the same namespace.

The request payloads carry both processId and sessionKey, and the daemon keeps a process.cliSessionKey mapping. 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.

opening a session
 └─ is the session's process still alive?
     ├─ yes → is it compatible? (against the client's minimum protocol version)
     │        ├─ yes → reattach                              ← the normal path
     │        └─ no  → is it idle?
     │                 ├─ yes → kill + spawn(latest), and say so
     │                 └─ no  → ask; let the user choose to wait
     └─ no  → spawn(latest) with --resume

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>/exe simply reads … (deleted). And whenever a spawn is required, the right version to spawn is always the latest: --resume restores a transcript, not process state, so there is no version continuity to preserve. -cli-keep 3 can 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-keep would 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-cli knows. So this policy needs ccd-cli to 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

--resume restores the transcript, but a session owns process-local state that the transcript does not carry. stdio MCP servers are children of ccd-cli:

ccd-cli 1739243 ─┬─ npm exec @upstash/context7-mcp    (alive 7d)
                 └─ npm exec @playwright/mcp@latest   (alive 7d)
ccd-cli 1743321 ─── npm exec @playwright/mcp@latest   (alive 7d)

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:

~/.claude/remote/run/<id>/remote-server.log        (and .log.old)

[process.Manager] … exited with code 143 is the SIGTERM; the [Server] New connection line above it is the reconnect. Idle-timeout closes (closing connection idle 5m …) are a useful control — they are never followed by a 143.

localseomax-ben · 20 days ago

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:

2026/09/24 06:58:57 [Server] Connection closed: @
2026/09/24 09:28:05 [Server] New connection from: @
2026/09/24 09:28:11 [process.Manager] Process b8722a1b-… exited with code 143, signalled at client request
2026/09/24 09:28:22 [process.Manager] Process 20e14b6a-… started, PID=…, command=~/.claude/remote/ccd-cli/2.1.280
2026/09/24 09:28:23 [process.Manager] Process 0911fe18-… exited with code 143, signalled at client request
2026/09/24 09:28:27 [process.Manager] Process fc225ef3-… started, PID=…, command=~/.claude/remote/ccd-cli/2.1.280
2026/09/24 09:29:12 [process.Manager] Process 5e0e7a97-… exited with code 143, signalled at client request
2026/09/24 09:29:16 [process.Manager] Process a520ecea-… started, PID=…, command=~/.claude/remote/ccd-cli/2.1.280

Over 2026-09-21 to 09-24, 10 of 29 New connection events were followed within 3 minutes by one or more exited with code 143, signalled at client request: 20 session processes in total. The replacement --resume process 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_background tasks. 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: loginctl shows 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 --user with a setsid fallback, running claude -p headless), 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.

cocomodo · 13 days ago

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 SIGTERM trap. 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.

  1. Start the task in session A, wait for step 2 to complete.
  2. Switch the app to a different session B, then fully quit the Desktop app (tray → Exit). Wait ~3 min.
  3. Relaunch the app. Do not open session A. Open session B and read the logs from there.
  4. Then click session A in the sidebar. Read the logs again.

Timeline (all from ~/disconnect-test/progress.log and ~/.claude/remote/run/<id>/remote-server.log)

| Time | Event |
|---|---|
| 18:26:14 | Session A ccd-cli started (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 request and 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 SIGTERM in the same second |
| 18:32:51 | Process 0bc3a27e… exited with code 143, signalled at client request → new ccd-cli PID 640963 started with --resume |
| after | No step 8/9/10 ever runs. The resumed session shows the transcript and sits idle. |

2026/10/01 18:28:29 [Server] Connection closed: @
2026/10/01 18:31:23 [Server] New connection from: @
2026/10/01 18:32:51 [process.Manager] Process 0bc3a27e-6828-472a-a98f-58fcd65d216e exited with code 143, signalled at client request
2026/10/01 18:32:51 [process.Manager] Process c870db40-186e-4823-88fc-07ffe74c4c67 started, PID=640963, command=~/.claude/remote/ccd-cli/2.1.284
2026-10-01 18:32:13 [agent-step 7/10 pid=640642] 시작 (55s 가짜 작업)      # step 7 started
2026-10-01 18:32:49 [heartbeat pid=637063] 경과 380s                        # heartbeat still alive
2026-10-01 18:32:50 [agent-step 7/10 pid=640642] SIGTERM 수신 -> 종료       # step 7 got SIGTERM
2026-10-01 18:32:50 [heartbeat pid=637063] SIGTERM 수신 -> 종료            # heartbeat got SIGTERM

What this shows

  • The remote daemon and the session's ccd-cli survive 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.
  • The kill is triggered by opening the session in the app (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.
  • The respawned --resume process 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.
  • In-flight background work (run_in_background Bash, 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

#!/usr/bin/env bash
set -u
LOG="$HOME/disconnect-test/progress.log"
N="$1"; TOTAL="$2"; SECS="${3:-55}"
log() { printf '%s [agent-step %s/%s pid=%s ppid=%s] %s\n' "$(date '+%F %T')" "$N" "$TOTAL" "$$" "$PPID" "$*" >> "$LOG"; }
trap 'log "SIGTERM"; exit 143' TERM
trap 'log "SIGHUP"; exit 129' HUP
log "start (${SECS}s)"
end=$(( $(date +%s) + SECS )); i=0
while [ "$(date +%s)" -lt "$end" ]; do
  i=$(( i + 1 )); head -c 1048576 /dev/urandom | sha256sum > /dev/null; sleep 1
done
log "done ($i iterations)"
echo "step $N/$TOTAL done at $(date '+%F %T')"

~/disconnect-test/heartbeat.sh

#!/usr/bin/env bash
set -u
LOG="$HOME/disconnect-test/progress.log"
DURATION="${1:-720}"; INTERVAL="${2:-10}"
log() { printf '%s [heartbeat pid=%s ppid=%s] %s\n' "$(date '+%F %T')" "$$" "$PPID" "$*" >> "$LOG"; }
trap 'log "SIGHUP"; exit 129' HUP
trap 'log "SIGTERM"; exit 143' TERM
start=$(date +%s)
log "start (${DURATION}s, every ${INTERVAL}s)"
while :; do
  elapsed=$(( $(date +%s) - start ))
  [ "$elapsed" -ge "$DURATION" ] && break
  log "elapsed ${elapsed}s"
  sleep "$INTERVAL" & wait $!
done
log "finished"

Prompt given to session A (Auto mode):

Run rm -f ~/disconnect-test/progress.log. Start bash ~/disconnect-test/heartbeat.sh 720 10 in the background. Then run bash ~/disconnect-test/step.sh N 10 55 for N=1..10, strictly sequentially, each as its own foreground Bash tool call (no batching, no background, no parallel). After each step print only "step N done" and continue. Do not ask questions.

</details>

localseomax-ben · 13 days ago

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 a run_in_background Bash task running (a long dry run). The remote helper log shows B's process being replaced 2 seconds later:

2026/10/01 10:50:13 [process.Manager] Process d0115539-... exited with code 143, signalled at client request
2026/10/01 10:50:13 [process.Manager] Process d090bbc7-... started, PID=3279940, command=/home/ben/.claude/remote/ccd-cli/2.1.284

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 second send_message to another session 10 seconds earlier did the same (10:50:03, code 143, new process at 10:50:04).

2. Archiving a session. archive_session gives the same exited with code 143, signalled at client request line (10:41:23).

Why this is easy to hit. Before nudging, session A checked the target with the session tools. The target's lastActivityAt was 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's ccd-cli process (found by CLAUDE_CODE_HOST_SESSION_ID in /proc/<pid>/environ). If any exist, leave the session alone. Long work runs as systemd-run --user units 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-cli over 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.