[BUG] Starting claude inside a sandbox deletes the session records of every other running session

Open 💬 0 comments Opened Jul 5, 2026 by connorads

TL;DR

Run one claude inside a sandbox and it quietly wipes the session records of every other claude you have running. Those sessions are fine - but claude cannot check they're alive from inside the sandbox, and it reads the resulting error as "that session crashed". From then on they cannot be listed, messaged or forked, and the only way back is to restart each one. The fix looks like one line: only "no such process" means dead, not "not allowed to check".

Detail

Claude Code keeps a record of each running session in ~/.claude/sessions/<pid>.json. The claude agents list, cross-session messaging, and tooling that forks or resumes the session behind a pane all rely on these records.

At startup, claude sweeps the records and checks each process is still alive with process.kill(pid, 0), treating any error as "process is dead". Inside a sandbox that denies signalling, that call fails with EPERM (not permitted) instead of ESRCH (no such process) - so every other session looks dead and its record is deleted. Records are written once at startup and never recreated, so nothing recovers on its own, and nothing tells you it happened.

Verified end to end on v2.1.200 with the self-contained reproduction below.

Environment

  • Claude Code v2.1.200 (same logic present in v2.1.198 and v2.1.181)
  • macOS (Darwin 25.5.0, arm64), native binary installed via mise

<details>
<summary>Reproduction - self-contained, never touches your real ~/.claude</summary>

The script uses a throwaway CLAUDE_CONFIG_DIR and a sleep process as the "live session". It plants 2 fake records: one for the live sleep, one for a dead pid. It then runs claude -p twice - once normally, once under a sandbox profile that denies signalling.

#!/bin/sh
set -eu
T="$(mktemp -d)/claude-config"
mkdir -p "$T/sessions"
sleep 600 & LIVE=$!

write_fakes() {
  cat > "$T/sessions/$LIVE.json" <<EOF
{"pid":$LIVE,"sessionId":"fake-live","cwd":"/tmp","startedAt":1783000000000,"version":"2.1.200","kind":"interactive"}
EOF
  cat > "$T/sessions/99999.json" <<EOF
{"pid":99999,"sessionId":"fake-dead","cwd":"/tmp","startedAt":1783000000000,"version":"2.1.200","kind":"interactive"}
EOF
}

write_fakes
# control: no sandbox
CLAUDE_CONFIG_DIR="$T" claude -p 'ok' >/dev/null 2>&1 || true
ls "$T/sessions"

write_fakes
# bug: sandbox denies signal
CLAUDE_CONFIG_DIR="$T" sandbox-exec -p '(version 1)(allow default)(deny signal)' \
  claude -p --debug 'ok' >/dev/null 2>&1 || true
ls "$T/sessions"
grep -ih 'uncleanly' "$T"/debug/*

Expected: both runs keep the live pid's record and remove only the dead pid's record.

Actual (verified on v2.1.200):

  • the control run behaves correctly - live record kept, dead record removed
  • the sandboxed run also deletes the live pid's record, while the sleep is still running
  • claude's own debug log (<configDir>/debug/) shows it misdiagnosed the live process as a crashed session:
[DEBUG] Prior session exited uncleanly: fake-live (v2.1.200)

The syscall behaviour on its own, without claude:

$ sandbox-exec -p '(version 1)(allow default)(deny signal)' python3 -c 'import os; os.kill(<live_pid>, 0)'
PermissionError: [Errno 1] Operation not permitted   # EPERM - the process is alive

</details>

<details>
<summary>Root cause in the bundled code</summary>

From the JavaScript bundled in the v2.1.200 binary (names are minified).

The liveness check treats any exception as dead:

function aE(e){if(e<=1)return!1;try{return process.kill(e,0),!0}catch{return!1}}

The startup sweep deletes the record when that check fails:

if(o===process.pid){n++;continue}
if(aE(o))n++;
else if(Gt()!=="wsl"){ /* unlink <pid>.json, log "Prior session exited uncleanly", emit tengu_unclean_exit */ }

kill(pid, 0) raising EPERM means the process exists but cannot be signalled. Only ESRCH means it has gone. The catch-all makes every other session look dead from inside a signal-denying sandbox.

There is already a WSL carve-out (Gt()!=="wsl") because liveness checks are unreliable there. This is the same class of problem on macOS sandboxes.

Records are written once, at startup. Later updates read the existing file and rewrite it, so a deleted record is never recreated while the session runs.

</details>

<details>
<summary>How this happens in real use</summary>

Any claude -p run from inside a sandboxed context that can still write ~/.claude/sessions wipes the user's real records.

Claude Code's own sandboxed Bash tool on macOS is one such context: it denies signalling other processes but allows writes under the session's working directory. So an agent whose working directory is $HOME that runs claude -p ... silently unregisters every other live session on the machine.

This is hard to diagnose because the wipe is a single event in the past. Affected sessions keep working, sessions started later register normally, and nothing links "this pane cannot be forked or messaged" to a sandboxed invocation hours earlier.

</details>

<details>
<summary>Suggested fix</summary>

Treat EPERM as alive in the liveness check:

try { process.kill(pid, 0); return true }
catch (err) { return err.code === "EPERM" }

Alternatively, or as well: skip the sweep when the current process detects it cannot signal a known-live pid (for example its own parent), as already done for WSL.

</details>

View original on GitHub ↗