DEV Community

Christian Sitte
Christian Sitte

Posted on

A running session is not a reachable session

Six weeks of running ~15 Claude Code sessions across two machines, and the three things that actually broke.

I run a fleet of long-lived Claude Code sessions — one per project, two machines. They need to hand work to each other: "the schema changed, the docs session has to follow." Doing that by carrying messages myself does not scale past about three sessions.

What we ended up with is deliberately boring: a shared folder of write-once files. Every message is a new file named with a UTC timestamp. Nothing is ever edited in place, so two writers can never collide, and a cloud-sync folder becomes a safe transport — sync engines produce conflict copies when two devices edit the same file, which here cannot happen. Thread state (owner, status) is not stored anywhere; it is derived by folding the files and taking the latest one that sets it.

On top sits one polling script per session that turns a new message into a notification and wakes the session. That part is pure latency reduction: if it dies, delivery degrades to a scan at session start instead of breaking.

The interesting part is not the design. It is what went wrong.

1. A running session is not a reachable session

The watcher is armed by the session itself, on its first turn, following an instruction in its project memory. Our launcher restarted every session after a reboot with --continue and no prompt — which restores the session but runs zero turns. So the instruction never executed.

Measured after one restart: 12 sessions running, 1 watcher armed. Every window looked healthy. The push layer had quietly come to depend on the exact hand movement it was built to abolish, and we only found it by counting processes.

The fix is three lines — the launcher appends a small start prompt so exactly one turn happens per session. The general form of the lesson: if a capability depends on "someone will do X at session start", it dies silently at every cold start. Wire it into the thing that performs the start. And fence that prompt in — ours says, in effect, run only the start ritual, begin no task, change no file, send nothing — or a fleet of sessions will helpfully start working unattended.

2. Never type a timestamp

Files sort by the UTC timestamp in their name. Our recipe generated the filename and left the human-readable date: field inside the file to be typed by hand.

Measured after a few weeks: 92 of 407 messages carried a field that did not match their own filename — zeroed times, rounded minutes, local time with a Z appended. Twice that inverted a thread's fold: a closed thread read as open for half a day.

The fix was not discipline, it was removing the second rendering from human hands: one date -u call, two derived renderings, and a written rule that the filename wins. Of the next 165 messages, 2 mismatched — both by one second, i.e. someone calling date twice. The failure class was gone.

3. A rule that lives in two places gets fixed in one

When we changed that timestamp rule, the first search missed two copies — they paraphrased the recipe instead of quoting it. One overlooked copy would have reinstated the old behaviour at every session start, because in a system operated by language models, prose instructions are code: a model reads them and executes them.

The search rule that came out of it: grep for the statement, not for the code snippet. And even that has a floor — a rule phrased without its own vocabulary ("take the latest") is invisible to any search. For those you have to know where the quantity matters and look by hand. The worst instance we found had an mv attached to it.

Bonus: the process that would not die

On Windows/msys, a background watcher survives the session ending, a context clear, and a process-tree kill — the tree is already torn at spawn, and the process is attached to no console. We first fought this with a "disarm before closing" ritual, which produced the opposite problem: sessions that were up but deaf, with no delivery path and nothing to see.

Inverting it was the fix: arming is idempotent. A new arm checks whether a live watcher for the same id already delivers, steps aside if so, and reaps it if it is a silent remnant. No disarm ritual exists any more. A leftover process and a duplicate are different things — prove liveness, do not infer it from parent PIDs.

The code

Everything is at https://github.com/csitte/claude-session-bridge — protocol, watcher, launcher, the full lessons chapter with the numbers, MIT.

It is Windows-first: the delivery core is portable shell (CI runs it on Linux too), but the process handling is PowerShell. A port to macOS or Linux is explicitly invited and the places it would touch are small and marked — I cannot review one against reality, since I have no such machine running this.

It is a snapshot of something I use daily, not a staffed project. Fork it rather than wait for me.

Top comments (0)