Summary
Every script that iterates $BRIDGE_DIR/sessions/*/ instead of scoping to $BRIDGE_DIR/sessions/$SELF/ assumes the bridge directory belongs to a single host. That assumption is not stated anywhere, and nothing enforces it.
Point two machines at one $BRIDGE_DIR — a network export, a synced folder, a container bind-mount, or simply two accounts on one box — and the plugin works well enough to look correct: registration, send-message.sh, and bridge-listen.sh all behave. Then one session exits and silently deletes the other machine's live sessions and their unread mail.
Sharing the directory is the natural way to bridge sessions across machines, and every script header documents Env: BRIDGE_DIR (default: ...) as configuration, so nothing signals that it is unsupported.
Four defects follow from the one assumption. The first destroys data.
1. cleanup.sh — cross-host rm -rf on every SessionEnd (data loss)
hooks.json wires exactly one hook: SessionEnd → cleanup.sh. After removing its own session, cleanup.sh sweeps every session directory under $BRIDGE_DIR/sessions/ and rm -rfs any whose lastHeartbeat predates a 30-minute cutoff.
The sweep is not scoped to the exiting session. On a shared $BRIDGE_DIR, one machine ending any session deletes the other machine's session directories, including unread messages sitting in their inboxes.
This is not a rare race — it fires on essentially every exit, because of defect 2.
2. heartbeat.sh ships but is never invoked
scripts/heartbeat.sh exists and works correctly. No hook calls it. hooks.json contains only the SessionEnd entry, and no other script invokes it either.
lastHeartbeat is therefore written once by register.sh and never updated. Every session crosses the 30-minute staleness threshold while perfectly alive, and stays there for its whole lifetime.
Combined with defect 1: every session is a valid deletion target, permanently. The first SessionEnd after 30 minutes of uptime wipes the shared registry.
It also means list-peers.sh / /bridge peers reports every peer as stale, so the one liveness signal the plugin exposes is unusable.
Controlled A/B confirming the mechanism is sound
Two live hosts sharing one $BRIDGE_DIR, differing only in whether heartbeat.sh is wired:
| host |
heartbeat.sh on UserPromptSubmit |
STATUS column |
| A |
wired manually |
accurate |
| B |
not wired (plugin default) |
every peer reads "stale" |
The mechanism is correct and needs no repair. It was simply never connected.
Trap for anyone wiring it
heartbeat.sh runs set -euo pipefail. If $BRIDGE_DIR is a network mount, a soft NFS mount returns I/O errors when the peer is unreachable, so the script exits non-zero. A non-zero UserPromptSubmit hook can block prompt submission — the user sees prompts failing, with the cause several layers removed.
If heartbeat.sh is wired by default, it should not be able to fail the hook. Either make the script tolerate an unreachable $BRIDGE_DIR internally, or document the wrapper:
bash ".../scripts/heartbeat.sh" >/dev/null 2>&1 || true
3. check-inbox.sh — marks other sessions' mail as read
check-inbox.sh loops over every session directory. Inside the loop, SESSION_ID is set to basename of the directory being scanned, not the caller's session. The echo guard [ "$TO_ID" != "$SESSION_ID" ] && continue therefore always passes for correctly filed mail — including mail belonging to other sessions.
Each matching message is printed and then rewritten with .status = "read".
On a shared $BRIDGE_DIR, running check-inbox.sh on one host consumes the other host's pending mail. Because the hook surface only shows status: "pending", those messages are never delivered to their recipient and their loss is silent.
The file's own comment notes the script scans all sessions and warns against touching other sessions' manifests — the same care was not applied to inbox reads.
bridge-listen.sh is not affected: it takes an explicit session-id argument and scans only that session's inbox.
4. register.sh — session reuse keyed off a per-machine pointer
register.sh decides whether to reuse an existing session by reading $PROJECT_DIR/.claude/bridge-session. That pointer is local to the machine, but the registry it points into is shared.
Two hosts registering the same logical project against one shared $BRIDGE_DIR each create their own session and never reconcile. Observed: two live session entries for the same logical project, distinguishable only by their projectPath prefixes, with no indication they are the same work from two ends.
Minor, but the same root cause, and it makes the registry harder to read while debugging the others.
Reproduction
Fully reproducible with a throwaway $BRIDGE_DIR on one machine — no second host or network mount required, since the bug is that the scripts do not scope to the caller.
SB=$(mktemp -d); PROJ="$SB/proj"
mkdir -p "$SB/sessions/self/inbox" "$SB/sessions/self/outbox" \
"$SB/sessions/victim/inbox" "$SB/sessions/victim/outbox" "$PROJ/.claude"
OLD="2020-01-01T00:00:00Z" # any timestamp older than the 30-minute cutoff
printf '{"sessionId":"self","projectName":"proj","projectPath":"%s","startedAt":"%s","lastHeartbeat":"%s","status":"active","capabilities":[]}\n' \
"$PROJ" "$OLD" "$OLD" > "$SB/sessions/self/manifest.json"
printf '{"sessionId":"victim","projectName":"victim","projectPath":"/nowhere","startedAt":"%s","lastHeartbeat":"%s","status":"active","capabilities":[]}\n' \
"$OLD" "$OLD" > "$SB/sessions/victim/manifest.json"
printf '{"id":"msg-precious","from":"self","to":"victim","type":"query","timestamp":"%s","status":"pending","content":"UNREAD MAIL THAT MUST SURVIVE","inReplyTo":null,"metadata":{}}\n' \
"$OLD" > "$SB/sessions/victim/inbox/msg-precious.json"
echo -n "self" > "$PROJ/.claude/bridge-session"
BRIDGE_DIR="$SB" PROJECT_DIR="$PROJ" bash scripts/cleanup.sh
[ -d "$SB/sessions/victim" ] && echo "PASS peer survived" || echo "FAIL peer deleted"
[ -f "$SB/sessions/victim/inbox/msg-precious.json" ] && echo "PASS mail intact" || echo "FAIL mail lost"
[ ! -d "$SB/sessions/self" ] && echo "PASS self removed" || echo "FAIL self remains"
[ ! -f "$PROJ/.claude/bridge-session" ] && echo "PASS pointer removed" || echo "FAIL pointer remains"
victim represents the other host's live session. On 0.1.1 the first two assertions fail: the peer directory and its unread message are both deleted. Run independently on macOS and Linux, identical results.
The fourth assertion is worth keeping — a leftover bridge-session pointer makes the next register.sh attempt to reuse a session whose directory no longer exists.
⚠ Reproduce on GNU or BSD date, not busybox
cleanup.sh computes its cutoff as:
STALE_CUTOFF=$(date -u -v-30M +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \
|| date -u -d "30 minutes ago" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \
|| echo "")
date implementation |
-v-30M |
-d "30 minutes ago" |
STALE_CUTOFF |
| GNU coreutils |
invalid option |
works |
set |
| BSD / macOS |
works |
n/a |
set |
| busybox |
invalid option -- 'v' |
invalid date |
empty |
Where neither branch works, STALE_CUTOFF is empty and the guard on the next line — if [ -n "$STALE_CUTOFF" ] — skips the sweep entirely.
So the data-loss bug does not fire on busybox-date systems (Alpine containers, minimal images). Reproducing there will show all four assertions passing.
This is also a second, independent problem: the fallback fails open and silent. If the sweep is intended as garbage collection, it quietly does nothing on those platforms, with no warning and no log line — the feature is simply absent and nobody finds out. A date that cannot produce a cutoff should be an explicit error or an explicit documented skip, not an empty string that happens to disable the feature.
The test suite asserts the destructive behaviour
tests/test-cleanup.sh Test 4 is "Stale sessions (>30 min heartbeat) cleaned up" and asserts [ ! -d "$STALE_DIR" ]. Any fix to cleanup.sh will fail this test. That is worth knowing before the fix looks like a regression — and it is part of why the behaviour persisted: the suite is green today.
The test is also weaker than it looks: it never checks whether the peer's unread mail survives, only the directory.
test.sh hides failures, and only when it matters
test.sh runs under set -e with OUTPUT=$(bash "$t" 2>&1). The first failing test aborts the loop at the assignment — before the line that would print ✘ and name the failing file. The result is a few green checkmarks, no error, and no totals line: 3 of 11 files reported, with nothing indicating anything went wrong.
The exit code is still correct (1), so CI would catch it. But a human running it locally sees a green-looking wall. The truncation manifests only when a test fails — precisely when the output matters.
The trigger topology is broader than a network mount
"NFS" invites "well, don't do that", so it is worth being explicit: no network is required. Any arrangement where one $BRIDGE_DIR is visible to two Claude Code processes with independent $PROJECT_DIR/.claude/bridge-session pointers is sufficient:
- a network export (NFS, SMB, sshfs)
- a synced folder (Syncthing, Dropbox, iCloud Drive)
- a container bind-mount, or two containers sharing a volume
- two user accounts on one machine sharing a directory
- one user pointing
$BRIDGE_DIR at a common path from two terminals in different projects
The last two need no special setup at all.
Suggested fix
Scope every iteration to the caller's own session. Cross-session reads and writes should be explicit and opt-in, never a side effect of exiting or checking mail.
cleanup.sh — remove the stale-session sweep. A session should delete only its own directory. If garbage collection is wanted, make it a separate opt-in command, not an exit hook. Note that wiring heartbeat.sh does not fix this: the hooks are event-driven while the sweep is wall-clock, so a session merely idle past the cutoff is still deleted by a peer's exit.
check-inbox.sh — resolve the caller's session id once, then scan only sessions/$SELF/inbox, as bridge-listen.sh already does correctly.
heartbeat.sh — wire it in hooks.json (UserPromptSubmit works well), and make it incapable of failing its hook when $BRIDGE_DIR is unreachable.
register.sh — when reusing a session, reconcile against the shared registry rather than a machine-local pointer alone.
- Update
tests/test-cleanup.sh Test 4 to assert the safe contract, and strengthen it to also verify the peer's unread mail survives.
- Document the contract. State whether a shared
$BRIDGE_DIR is supported. If yes, the scoping fixes above are required. If no, say so prominently — the configuration is the obvious way to bridge across machines and nothing currently warns against it.
A cheaper option than full scoping
register.sh already records projectPath in each manifest. A session whose projectPath root does not exist on the local filesystem is almost certainly another host's. That is cheap to check and enough to warn on, refuse to sweep, or scope automatically — it converts silent data loss into a visible condition without committing the project to supporting shared directories.
Make the fix verifiable, not just described
After patching cleanup.sh, exactly one executable rm -rf should remain — a check a reviewer can run in one command:
grep -c '^[[:space:]]*rm -rf' scripts/cleanup.sh # expect 1
Local workaround in use
Both hosts have the stale-session sweep removed from cleanup.sh, keeping only the rm -rf "$SESSION_DIR" that removes the exiting session's own directory. The patch comment is host-neutral, so shasum -a 256 scripts/cleanup.sh serves as a cross-machine symmetry check.
- 0.1.1 as shipped:
df61a3ecce783b0afd856f0c7afaaedf7ea21666a22b820234342e99d0fc123e
- patched:
91963be817e7010c217ef1700d61d606ace736adcf452561d7e2862b5aa44db4
This lives in the plugin cache, so an upgrade silently restores the sweep — and the failure mode is invisible until someone's unread mail disappears. That is the main reason for reporting rather than keeping the patch private.
Notes
find -name "*.json" in cleanup.sh peer discovery matches macOS AppleDouble ._*.json sidecars, which shell globs skip as dotfiles. jq fails on them and the errors are suppressed, so it is harmless today — but the glob-vs-find distinction matters for anyone auditing these paths.
- Environment: plugin 0.1.1 (
87dfba77cae09c72a2e7950fa790514ba9287770) on macOS 15.7 and Linux 6.17, jq 1.7.x, one $BRIDGE_DIR shared between the two hosts over NFSv3.
Happy to open a PR for any subset of the above if that is useful.
Summary
Every script that iterates
$BRIDGE_DIR/sessions/*/instead of scoping to$BRIDGE_DIR/sessions/$SELF/assumes the bridge directory belongs to a single host. That assumption is not stated anywhere, and nothing enforces it.Point two machines at one
$BRIDGE_DIR— a network export, a synced folder, a container bind-mount, or simply two accounts on one box — and the plugin works well enough to look correct: registration,send-message.sh, andbridge-listen.shall behave. Then one session exits and silently deletes the other machine's live sessions and their unread mail.Sharing the directory is the natural way to bridge sessions across machines, and every script header documents
Env: BRIDGE_DIR (default: ...)as configuration, so nothing signals that it is unsupported.Four defects follow from the one assumption. The first destroys data.
1.
cleanup.sh— cross-hostrm -rfon everySessionEnd(data loss)hooks.jsonwires exactly one hook:SessionEnd→cleanup.sh. After removing its own session,cleanup.shsweeps every session directory under$BRIDGE_DIR/sessions/andrm -rfs any whoselastHeartbeatpredates a 30-minute cutoff.The sweep is not scoped to the exiting session. On a shared
$BRIDGE_DIR, one machine ending any session deletes the other machine's session directories, including unread messages sitting in their inboxes.This is not a rare race — it fires on essentially every exit, because of defect 2.
2.
heartbeat.shships but is never invokedscripts/heartbeat.shexists and works correctly. No hook calls it.hooks.jsoncontains only theSessionEndentry, and no other script invokes it either.lastHeartbeatis therefore written once byregister.shand never updated. Every session crosses the 30-minute staleness threshold while perfectly alive, and stays there for its whole lifetime.Combined with defect 1: every session is a valid deletion target, permanently. The first
SessionEndafter 30 minutes of uptime wipes the shared registry.It also means
list-peers.sh//bridge peersreports every peer as stale, so the one liveness signal the plugin exposes is unusable.Controlled A/B confirming the mechanism is sound
Two live hosts sharing one
$BRIDGE_DIR, differing only in whetherheartbeat.shis wired:heartbeat.shonUserPromptSubmitThe mechanism is correct and needs no repair. It was simply never connected.
Trap for anyone wiring it
heartbeat.shrunsset -euo pipefail. If$BRIDGE_DIRis a network mount, asoftNFS mount returns I/O errors when the peer is unreachable, so the script exits non-zero. A non-zeroUserPromptSubmithook can block prompt submission — the user sees prompts failing, with the cause several layers removed.If
heartbeat.shis wired by default, it should not be able to fail the hook. Either make the script tolerate an unreachable$BRIDGE_DIRinternally, or document the wrapper:3.
check-inbox.sh— marks other sessions' mail as readcheck-inbox.shloops over every session directory. Inside the loop,SESSION_IDis set tobasenameof the directory being scanned, not the caller's session. The echo guard[ "$TO_ID" != "$SESSION_ID" ] && continuetherefore always passes for correctly filed mail — including mail belonging to other sessions.Each matching message is printed and then rewritten with
.status = "read".On a shared
$BRIDGE_DIR, runningcheck-inbox.shon one host consumes the other host's pending mail. Because the hook surface only showsstatus: "pending", those messages are never delivered to their recipient and their loss is silent.The file's own comment notes the script scans all sessions and warns against touching other sessions' manifests — the same care was not applied to inbox reads.
bridge-listen.shis not affected: it takes an explicit session-id argument and scans only that session's inbox.4.
register.sh— session reuse keyed off a per-machine pointerregister.shdecides whether to reuse an existing session by reading$PROJECT_DIR/.claude/bridge-session. That pointer is local to the machine, but the registry it points into is shared.Two hosts registering the same logical project against one shared
$BRIDGE_DIReach create their own session and never reconcile. Observed: two live session entries for the same logical project, distinguishable only by theirprojectPathprefixes, with no indication they are the same work from two ends.Minor, but the same root cause, and it makes the registry harder to read while debugging the others.
Reproduction
Fully reproducible with a throwaway
$BRIDGE_DIRon one machine — no second host or network mount required, since the bug is that the scripts do not scope to the caller.victimrepresents the other host's live session. On 0.1.1 the first two assertions fail: the peer directory and its unread message are both deleted. Run independently on macOS and Linux, identical results.The fourth assertion is worth keeping — a leftover
bridge-sessionpointer makes the nextregister.shattempt to reuse a session whose directory no longer exists.⚠ Reproduce on GNU or BSD
date, not busyboxcleanup.shcomputes its cutoff as:dateimplementation-v-30M-d "30 minutes ago"STALE_CUTOFFinvalid option -- 'v'invalid dateWhere neither branch works,
STALE_CUTOFFis empty and the guard on the next line —if [ -n "$STALE_CUTOFF" ]— skips the sweep entirely.So the data-loss bug does not fire on busybox-
datesystems (Alpine containers, minimal images). Reproducing there will show all four assertions passing.This is also a second, independent problem: the fallback fails open and silent. If the sweep is intended as garbage collection, it quietly does nothing on those platforms, with no warning and no log line — the feature is simply absent and nobody finds out. A
datethat cannot produce a cutoff should be an explicit error or an explicit documented skip, not an empty string that happens to disable the feature.The test suite asserts the destructive behaviour
tests/test-cleanup.shTest 4 is "Stale sessions (>30 min heartbeat) cleaned up" and asserts[ ! -d "$STALE_DIR" ]. Any fix tocleanup.shwill fail this test. That is worth knowing before the fix looks like a regression — and it is part of why the behaviour persisted: the suite is green today.The test is also weaker than it looks: it never checks whether the peer's unread mail survives, only the directory.
test.shhides failures, and only when it matterstest.shruns underset -ewithOUTPUT=$(bash "$t" 2>&1). The first failing test aborts the loop at the assignment — before the line that would print✘and name the failing file. The result is a few green checkmarks, no error, and no totals line: 3 of 11 files reported, with nothing indicating anything went wrong.The exit code is still correct (1), so CI would catch it. But a human running it locally sees a green-looking wall. The truncation manifests only when a test fails — precisely when the output matters.
The trigger topology is broader than a network mount
"NFS" invites "well, don't do that", so it is worth being explicit: no network is required. Any arrangement where one
$BRIDGE_DIRis visible to two Claude Code processes with independent$PROJECT_DIR/.claude/bridge-sessionpointers is sufficient:$BRIDGE_DIRat a common path from two terminals in different projectsThe last two need no special setup at all.
Suggested fix
Scope every iteration to the caller's own session. Cross-session reads and writes should be explicit and opt-in, never a side effect of exiting or checking mail.
cleanup.sh— remove the stale-session sweep. A session should delete only its own directory. If garbage collection is wanted, make it a separate opt-in command, not an exit hook. Note that wiringheartbeat.shdoes not fix this: the hooks are event-driven while the sweep is wall-clock, so a session merely idle past the cutoff is still deleted by a peer's exit.check-inbox.sh— resolve the caller's session id once, then scan onlysessions/$SELF/inbox, asbridge-listen.shalready does correctly.heartbeat.sh— wire it inhooks.json(UserPromptSubmitworks well), and make it incapable of failing its hook when$BRIDGE_DIRis unreachable.register.sh— when reusing a session, reconcile against the shared registry rather than a machine-local pointer alone.tests/test-cleanup.shTest 4 to assert the safe contract, and strengthen it to also verify the peer's unread mail survives.$BRIDGE_DIRis supported. If yes, the scoping fixes above are required. If no, say so prominently — the configuration is the obvious way to bridge across machines and nothing currently warns against it.A cheaper option than full scoping
register.shalready recordsprojectPathin each manifest. A session whoseprojectPathroot does not exist on the local filesystem is almost certainly another host's. That is cheap to check and enough to warn on, refuse to sweep, or scope automatically — it converts silent data loss into a visible condition without committing the project to supporting shared directories.Make the fix verifiable, not just described
After patching
cleanup.sh, exactly one executablerm -rfshould remain — a check a reviewer can run in one command:Local workaround in use
Both hosts have the stale-session sweep removed from
cleanup.sh, keeping only therm -rf "$SESSION_DIR"that removes the exiting session's own directory. The patch comment is host-neutral, soshasum -a 256 scripts/cleanup.shserves as a cross-machine symmetry check.df61a3ecce783b0afd856f0c7afaaedf7ea21666a22b820234342e99d0fc123e91963be817e7010c217ef1700d61d606ace736adcf452561d7e2862b5aa44db4This lives in the plugin cache, so an upgrade silently restores the sweep — and the failure mode is invisible until someone's unread mail disappears. That is the main reason for reporting rather than keeping the patch private.
Notes
find -name "*.json"incleanup.shpeer discovery matches macOS AppleDouble._*.jsonsidecars, which shell globs skip as dotfiles.jqfails on them and the errors are suppressed, so it is harmless today — but the glob-vs-finddistinction matters for anyone auditing these paths.87dfba77cae09c72a2e7950fa790514ba9287770) on macOS 15.7 and Linux 6.17,jq1.7.x, one$BRIDGE_DIRshared between the two hosts over NFSv3.Happy to open a PR for any subset of the above if that is useful.