DEV Community

Zika Zag
Zika Zag

Posted on

Keeping a fleet of Claude Code sessions alive when everything wants to kill them

I run Claude Code a lot: often several sessions at once, often on a remote box over SSH, often for hours. The CLI is great at this. What I was not great at was not losing the sessions.

A terminal tab closes. The laptop sleeps. The SSH tunnel drops for four seconds on hotel wifi. Any one of these and a running claude -p session is just... gone, mid-thought, with whatever it was doing. I got tired of it and built a web UI (Walnut) whose entire job for sessions is: the session outlives the things that try to kill it.

This post is about the part that was genuinely hard (the resilience model), not the UI.

The naive version, and why it dies

The obvious design is: browser → my server → spawn('claude', ...). The server owns the child process. This works until the server restarts (deploy, crash, OOM), and now every session it was parenting dies with it. For a tool whose pitch is "your sessions don't vanish," that's the one thing it can't do.

The second problem is subtler. claude -p --input-format stream-json is long-running, not per-turn. One CLI process stays alive across many messages, reading new input between turns. So you can't treat a turn ending as the session ending: the process is supposed to sit there idle, waiting. If your lifecycle logic assumes "turn done = process done," you'll keep reaping live sessions, and you'll never understand why they keep disappearing.

What actually worked: a daemon that nothing owns

The model I landed on:

browser  ──ws──▶  walnut server  ──ssh tunnel──▶  daemon (remote)  ──spawn──▶  claude -p
Enter fullscreen mode Exit fullscreen mode

The key move is that the daemon is not a child of my server. It's a small long-lived process on the remote host. My server connects to it; it doesn't depend on my server to live. When my server restarts, the daemon (and every claude process under it) keeps running. The server reconnects and re-adopts the sessions that were already there.

That adoption step is the whole ballgame, and it's where the bugs live.

Three things that took real debugging

1. The FIFO has to be held open, or the pipe collapses between turns.
Each session's stdin is a named pipe. If the only writer closes its handle after a turn, the reader sees EOF and the CLI exits. The fix is unglamorous: the daemon holds the FIFO open with O_RDWR for the life of the session, so the pipe survives the gaps between turns. The process is only reaped by a real death or a long idle timer, never by "the turn ended."

2. "Re-adopt" must skip what it already adopted.
On restart the daemon reconciles its registry, then scans the host for orphaned session process-groups it should pick back up. The non-obvious bug: if the scan doesn't skip sessions it already re-adopted from the registry, you double-adopt and get duplicate streams. One line (if (already_have(sid)) continue), but it cost an afternoon.

3. A clean turn-end and a crash look the same at the exit code, until you read the tail.
When a process does die, you want to tell the user why. A turn that completed cleanly and a process that crashed can surface the same way. The session's output stream (JSONL) is the source of truth: if the tail shows a clean result line, normalize the exit to "completed"; otherwise it's a real failure. Without this, every normal turn-end shows up in the UI as "exited -1," which is alarming and wrong.

The transfer problem (a fun one)

Deploying a new daemon binary to the remote host should be easy. It wasn't, because the corporate SSH proxy in my setup silently kills any transfer over ~5MB. scp of the binary just hangs forever.

The fix is dumb and I love it: gzip the binary, chunk it into 1MB pieces, send each over its own SSH connection, reassemble on the other side, retry per-chunk. Each chunk is small enough to slip under the proxy's limit. It auto-deploys on the next session send if the local and remote versions differ, and falls back to shipping the (tiny) source if the chunked binary fails. No more manual copies.

What I'd tell someone building similar

  • A long-running agent process is a resource you reconnect to, not a function you call. Design the reconnect path first; it's the hard part.
  • Make the durable state live where the work lives (on the remote, in a file), not in the process that happens to be orchestrating it right now.
  • When two different events (clean exit / crash; spawn / re-adopt) can look identical, find the one piece of ground-truth that distinguishes them and key everything off that. Don't infer it from timing.

Walnut is open source (MIT) if you want to read the actual code: https://github.com/EvanZhang008/open-walnut. The session layer (src/providers/) is the most interesting part. There is also a 2-minute demo of the whole thing (calendar + iPhone app included): https://youtu.be/LbdQJXPwGVE

Top comments (0)