happyin.work / blog

Several Claudes on one repo. Locks, handoffs and email from 1982

By Anastasiia Butova, ComfyUI and diffusion-model engineer, Belgrade · published 23 April 2026 · updated 23 September 2026

I run Claude Code in parallel on three Pro subscriptions, and my colleagues run their own, often in the same files on the same day. This post covers the three coordination problems that came out of that, in the order they appeared: passing context from one session to the next, giving one session exclusive use of a resource, and letting two live sessions talk to each other. Each turned out to be a distributed-systems pattern about forty years old: an append-only log, a lock file with a heartbeat, and email. At the end: how the three are packaged as mclaude, what real use looked like, and where it breaks.

Handoffs came first, when I got tired of retelling the state of things to every new chat. Rollups came when long projects piled up eighty handoffs each. Mail came when I got tired of relaying my colleagues' Claude statuses through a messenger.

Layer 1: handoffs, "I am done, pick it up"

First attempt: write everything into a single HANDOFF.md when the session closes. It breaks the moment there is more than one chat. The last writer wins, and everyone else is overwritten.

So I moved to append-only. Every session writes its own file with a unique name:

.claude/handoffs/
├── 2026-04-09_14-32_373d1618_drift-validator.md
├── 2026-04-09_16-47_b858f500_dashboard-refactor.md
├── 2026-04-10_11-20_a1c2d3e4_auth-fix.md
└── INDEX.md   (append-only log)

The name encodes the date, the time, the first eight characters of the session id and a task slug. Two chats cannot produce the same name: even in the same second, their session ids differ. The race is impossible by construction, with no locks and no broker.

INDEX.md is append-only too: a new line at the bottom, never an edit above. To move a status from ACTIVE to CLOSED, you append a new record instead of editing the old one. A write-ahead log in a database works exactly this way, and for the same reason: if a process dies mid-write, the log stays consistent.

A handoff is not a diary, it is a contract with the next session. Five sections, one question each: the goal, what was done (with paths, so nothing gets rebuilt), what did NOT work and why, the current state (working, broken, blocked), and one next action. One line per section from a real colour-checker session:

  • Goal: "Colour checker: CNN sweep + diffusion, first results"
  • Done: "CNN baseline median 1.99 deg, 11M params, 21 MB"
  • Did not work: "lr=3e-4, NaN after epoch 10-13, no gradient clipping"
  • Broken: "Diffusion training OOM at bs=16"
  • Next step: "Inference script for diffusion + visual sheets"

The full text of a handoff from the same project is in My CLAUDE.md is 582 lines.

The section that earns its keep is "did NOT work". In four days 27 handoffs piled up and no dead end was walked into twice, because every new session saw the warning. A handoff fits in about 1,500 tokens against roughly 100K of raw session context: about 67 times smaller, with nothing lost that matters for carrying on.

Writing is automated. A Stop hook checks how long the session ran and, if it was substantial and nothing was recorded, asks whether to save a handoff. On top of that, in long sessions the model itself offers to write one before going on, usually after a large task or when the context is 70 to 80% full. My hooks have no length check for that; it is the model's own call. Reading is one sentence at the start of the next session, "pick up the colour-checker handoff", or a link to the file.

Rollups, when handoffs pile up

Work on something for a month or two and dozens of handoffs build up, full of dead ends already walked and hypotheses already disproved. A new session reads the latest few, looks no deeper, and calmly proposes an approach I disproved two weeks earlier.

The fix is to fold the old ones. One rollup summarises twenty earlier handoffs, marks the boundary date, and stamps each handoff it covers with a rolled_up_into: line naming the rollup file. A new session reads one rollup plus only the handoffs dated after its boundary. The rest stays for history but does not enter the context.

The rollup carries a rollup_ segment in its file name so it stands out in INDEX.md, and its frontmatter lists what it covers (the format from the mclaude README, shortened):

---
type: rollup
covers:
  - 2026-03-01_12-00_session-01_kickoff
  - 2026-03-02_14-30_session-02_auth-refactor
  - 2026-03-15_18-00_session-12_ci-pipeline
through: 2026-03-15 18:00
---

The body is an ordinary handoff that describes twenty sessions instead of one. The pattern is not mine. PavelMuntyan/MF0-1984 solved the same problem in a SQLite schema with a covered_until_message_id column, and I swapped the table for Markdown frontmatter. Nothing clever: the familiar "summary of previous chapters".

Layer 2: locks, "this resource is mine right now"

The next problem showed up with training runs on remote servers. Handoffs only ever append, so nobody overwrites anybody. Real resources are different. Two sessions cannot refactor one module at once (merge conflict), cannot deploy to staging at once (the second overwrites the first), cannot train on one GPU at once (the second gets an OOM). One of them has to hold the resource exclusively.

One file per resource, named after it: auth-middleware.lock, staging-deploy.lock, gpu-host-a_gpu2.lock. Inside: who took it, when and what for. A new session lists the folder, sees what is taken and picks something free without asking me.

The trick is in how the file is created. os.open(path, O_CREAT | O_EXCL) is atomic: if the file already exists, the call fails. Two processes cannot create the same file at once, because the kernel guarantees it. It is the same primitive behind /var/lock and pid files since the 1980s.

A lock without a heartbeat only survives until the first crash: the session dies, the lock stays, and everyone waits forever. So in the file convention an active session refreshes its timestamp every 30 to 60 minutes, and a lock whose heartbeat is older than about four hours is possibly dead. Possibly is not certainly. Before taking it over I check the resource itself: ps over SSH, nvidia-smi on the GPU, the deploy queue, git status on the module, whatever tells me whether it is really busy or was just never released. Only then do I take it. Chubby and ZooKeeper verify from outside before a takeover too, for the same reason.

Next to each lock sits JSON metadata: task slug, session id, time taken, description, list of files. A new session reads .claude/locks/ and sees in a second that auth-middleware is held by ani for a refactor, second hour, and that GPU 2 is busy training embeddings. The question "what is going on here?" no longer comes up.

My first instinct was a PreToolUse hook that parses commands and marks resources busy automatically: a command touches resource X, so X is taken. Dead end. Commands are too varied, and a regex parser turns into a machine for false positives. The right order was the opposite: convention and files first, automation later, and only for patterns that actually repeat.

Layer 3: mail, "Artem, look at my code"

Handoffs and rollups carry context through time. Locks arbitrate resources. A third job fits neither: keeping two live sessions in sync.

A typical day: I set a colleague's Claude to refactor auth-middleware and went off to write a plugin in my own chat. An hour later I wanted to know where it was and whether it had got stuck. The old answer: I open a messenger and write to my colleague, the colleague looks at their chat and copies the text back. Three relays through a person, every evening, on several projects.

What I needed was addressed messages between sessions that may never be online at the same time, with delivery guaranteed: if a message is not read now, it gets read in the next session. I thought about it and realised I was describing email.

.claude/mailbox/
├── ani/
│   ├── inbox/
│   ├── sent/
│   └── archive/
├── artem/
│   └── ...
└── all/          (broadcast)

Each message is a separate Markdown file with YAML frontmatter (from, to, type, subject, message_id, in_reply_to, status) and the letter itself underneath. A simple case: ani's session sends Artem a question with the subject "Re: cmake libsodium" and the body "Did you change the libsodium linking in CMakeLists? My build fails on target_link_libraries". An hour later Artem opens Claude Code on his machine, a UserPromptSubmit hook checks his inbox before the first prompt, sees the unread message and puts it into context. He answers on the spot, and the reply lands in my inbox.

Threads run through in_reply_to, as in RFC 822. Delivery receipts are separate files. A message to to: * goes into all/, which everyone checks. For de-duplication the hook remembers in .watcher_state.json which messages it has already shown, so nothing enters the context twice. No polling, no broker, no background daemon: everything rests on a hook that fires at the right moment. Every feature maps one to one onto classic email:

Email (RFC 822, 1982) Agent mailbox
To / Cc addressing a specific session
From / Reply-To return address for the answer
Subject triage without reading the body
In-Reply-To message threads
Date how fresh the request is
Read / Unread seen it or not yet
Inbox per user per-recipient queue
Sent folder the sender's audit trail
Delivery receipt confirmation without "did you see it?"

mclaude: the three layers as one tool

I packaged the layers as an open-source tool, mclaude: Python, no dependencies in the core, 193 tests, version 0.6.0. As I described it then, it had six layers, three basic ones and three added later:

Layer What it does Lines of code
Locks atomic resource claim + heartbeat 469
Handoffs per-session summaries, append-only INDEX 371
Memory Graph hierarchical store of decisions and gotchas ~400
Identity Registry names for sessions (ani, artem, nastya) 246
Messages + Active Mail messaging between sessions, with threads 400 + 236
Code Indexer AST scanner → code-map.md + llms.txt new in v0.6.0

The indexer walks the project and writes two files for the agent: code-map.md, a map of modules, classes and functions, and llms.txt, a compact index for fast loading. A new session does not spend its first minutes rereading code, because the map already sits in the repository next to the handoffs.

On top of that come five hooks, four on Claude Code events (SessionStart, PreToolUse, UserPromptSubmit, Stop) and a git pre-commit guard that refuses a commit touching a file someone else has locked. An MCP server with 20+ tools returns structured JSON straight into Claude Code, with no parsing of CLI output.

This is what a lock looks like from the command line, from the mclaude README:

$ mclaude lock claim --slug fix-auth-middleware-race \
    --description "Race condition when two requests write the same session key" \
    --files src/auth/middleware.py src/auth/session.py
[lock] claimed fix-auth-middleware-race

# a second session tries the same slug
$ mclaude lock claim --slug fix-auth-middleware-race --description "same bug I guess"
[lock] fix-auth-middleware-race already held
  by session: 665a44027f424db7
  doing:      Race condition when two requests write the same session key

The design rule: the hub is an accelerator, not a dependency. Everything works on files. With the hub up, messages travel over WebSocket in real time. With the hub down, the bridge writes to the same .claude/messages/inbox/ in the same format, and the recipient sees exactly the same thing after git pull. Remove the mclaude package and the files in .claude/ stay readable: Markdown for the narrative, JSON for the metadata.

mclaude demo --no-pause takes about 30 seconds. It runs two simulated sessions, ani and vasya, through the layers and prints a Mermaid diagram of what happened, which renders as is in a pull request or a post.

The gap in the ecosystem

Then I went to see who else was solving this. Isolation has plenty of answers: git worktrees (parallel-cc), sandboxes (Kmux), a separate port per agent with forwarded environment variables, Anthropic's Agent Teams with a task queue and claims. They all solve "sessions do not disturb each other" by making sure there is no shared state.

Coordination of shared state was nearly empty. Only Dicklesworthstone's claude_code_agent_farm does file-based locks, and that is orchestration for 20+ agents in tmux, not many chats on one project. Claude Code's own tracker shows the gap from the other side: #19364, a request for a session lock file, was never implemented, and #29217 documents .claude.json being corrupted by concurrent writes from several sessions.

The ecosystem is mature at avoiding shared state and nearly empty at coordinating it.

None of this is new. Heartbeat timeouts, lock files named after the resource, a log you only append to, an external check before taking over a stale lock, one file per resource instead of one shared table: Google's Chubby, ZooKeeper and every coordination system of the last forty years rest on the same things. AI agents are one more kind of process in a distributed system we already understand.

RFC 822 fits for the same reason. Email solves exactly this abstract problem: asynchronous delivery between processes that may not be active at the same time, with threads and confirmation of delivery. Every generation of distributed systems rediscovers that email was right. In the 2010s it was message queues, RabbitMQ and Kafka. In 2026 it is AI agents.

Real use

Three people, one C++ plugin project, six parallel Claude Code sessions, each person on their own part: architecture, the inference pipeline, the UI.

One agent decided to change the key derivation in the scramble module and sent a broadcast through mailbox/all/. Another agent saw it at the start of its next session, before writing any code, and took the change into account. Without the mailbox it would have written code against an incompatible format: a merge conflict at best, corrupted data at worst.

Handoffs between subscriptions, finishing in one and continuing in another, saved about four hours that would otherwise have gone on rebuilding context.

Where it breaks, and what I do not know

  • Scale. Tested on 3 to 6 parallel sessions, 2 or 3 people, one C++ project, plus a great many sessions on a website. I have not measured 20+ sessions from 10 agents. I suspect the inbox breaks first: de-duplication through .watcher_state.json is linear in the number of messages and will slow down at hundreds.
  • Several machines without the hub. The file core syncs through a shared repository, so the other side sees a message after git pull: minutes, not seconds. The hub gives seconds over WebSocket, but it is one more process to run, and I have not tried it in production.
  • Lock races on network filesystems. O_CREAT | O_EXCL is atomic on a local filesystem. On NFS or SMB the guarantee depends on the implementation, so whoever puts .claude/locks/ there may hit a race.
  • Stale-lock takeover is semi-automatic. The checks through nvidia-smi, ps and git status are written for a person. Automating them needs a handler that knows how to check each type of resource.
  • Mail does not wake a session. With the hook alone, messages appear on UserPromptSubmit, only when the active session is typing something. Real push delivery is not there (see the update below).
  • A person is still in the loop. If six agents decide at once that auth-middleware needs rewriting, they will negotiate by mail, but who takes it is still a human decision. mclaude gives the tools for negotiating, not autonomous orchestration.

What changed after April

Update, September 2026. Three corrections and two additions, all checked on 23 September 2026.

  • Installation. pip install mclaude, as I wrote it in April, does not work: the package is not published on PyPI, which returns 404 for it. Install from a clone of the repository instead, as below.
  • Issue #19364. I described it as an open request. A stale bot had already closed it as not planned on 28 February 2026, before this post went out. It was never implemented, which is the point the section makes, but it was not open.
  • Issue #29217. I quoted "315 broken backups in seven days". That figure is not in the thread as it reads now. The original report counts 165 corrupted backups in about 40 minutes, and one commenter counted at least 423 corruptions over seven days. The issue was closed as inactive on 25 May 2026.
  • Seventh layer. The mclaude README also describes file-based session heartbeats, which this post does not cover, and the package runs locks tighter than the manual convention above: a heartbeat every 30 seconds, and a lock counts as stale after 3 minutes of silence.
  • Mail mid-conversation. The first plan from the end of the Russian original shipped the same day. Pull request #5, merged at 17:22 UTC on 23 April, a few hours after the post went up, adds a script for Claude Code's Monitor tool: it polls the inbox every 30 seconds and hands each new message to the running session without anyone typing a prompt. So the "mail does not wake a session" limit above holds only for the hook-only setup.

The main branch has had no commits since that merge. The MF0-1984 repository the rollup idea came from now returns 404.

Trying it

git clone https://github.com/AnastasiyaW/mclaude.git
cd mclaude
pip install -e .
mclaude hooks install --apply
mclaude demo --no-pause

MIT licence, Python 3.9+. The principles behind it are written up separately as principle 18, multi-session coordination, in the config repository described on the codex-claude-code-config page. The project page for the tool is mclaude. Why I rely on handoffs in my own repository rather than on the desktop app's session history is in 45 MB of Claude Code sessions you never look at.

The Russian original was published on Habr.

More posts

Elsewhere on this site

Anastasiia Butova - ML engineer, Belgrade, Serbia. Email [email protected] · LinkedIn · GitHub · Telegram · Hugging Face