Note 05 of 19

A kaibot field note · concurrent claude code sessions

Five Claudes, one repo

I run five, sometimes ten, Claude Code sessions at once against the same kaibot repo. Left alone, that setup silently loses data. So each session gets its own git worktree, merges back as it commits, and works under guard hooks that make collisions physically impossible.

Kai Davis

I run a marketing consultancy and an SEO SaaS, and I build both with a stack of Claude Code sessions that write the tooling the next sessions run on.

01Two sessions, one HEAD, no warning

Open two Claude Code sessions in the same checkout and git will let you, with no warning. Nothing queues the second behind the first. Both read and write the same working tree and the same HEAD. That is the default failure mode of running more than one agent on a repo, not a rare edge case.

I lost work this way on 2026-04-17, before any of what’s below existed. I was building a gnome HTTP API in one session. A second session, running /update, reverted my changes to three files. Three separate times, inside thirty minutes. It never told me.

Both sessions were on main in the same checkout. One of them stashed the other's in-progress edits and discarded them. No conflict was ever raised, because git treated the half-finished work as transient, not worth protecting.

No errors, no prompts, no merges. One session's edits just disappear, and you find out the next time you look at the file.

02One session, one worktree, merge back at commit time

The fix that holds is structural: two sessions can’t share a HEAD if they aren’t standing in the same working tree. So every interactive session in kaibot gets its own worktree and branch, automatically, before a single token gets written.

I run cdc <project>, which invokes claude --worktree <slug>-$$. That drops the session into .claude/worktrees/<slug>-<pid>/ on a fresh branch called worktree-<slug>-<pid>. The PID shows up in both the directory and the branch name, so two simultaneous launches of the same project can't collide.

If I (or an IDE extension) launch a bare claude instead and skip cdc, a SessionStart hook called worktree-isolation spots the un-isolated launch and tells me to relaunch. A PreToolUse guard then blocks edits until the session is properly isolated.

Isolation solved collisions and made a quieter problem possible: a commit that never makes it back to main.

On 2026-05-27, three parallel sessions each committed distinct, real work in their own worktrees. None of it got merged back. The branches sat there, invisible from main, for eleven days. One of those sessions redid a refresh that another branch had already completed differently, because it couldn't see the earlier work.

Work stranded on a worktree-* branch is invisible to every other session on the machine, and invisible work gets redone.

So the rule is merge back at commit time, not session end. Sessions end by abandonment, context exhaustion, or someone closing the window, so “session end” can’t be counted on. After each commit, or each coherent batch of commits, I run:

bash ~/kaibot/scripts/worktree/merge-back.sh

from inside the worktree. It publishes straight to origin. It never touches the shared main checkout. The flow is: rebase the worktree branch onto a freshly fetched origin/main, then push by refspec.

Concurrent publishes resolve by retry, not failure. If another session pushed first, origin rejects the push as non-fast-forward. The script re-fetches, re-rebases, and retries. Real content conflicts are different: the script aborts the rebase and tells me to resolve in the worktree and re-run.

The old version merged into whatever branch the main checkout happened to be on, without checking. That silently mis-landed four commits onto the wrong branch on 2026-05-26. The 2026-06-18 rewrite closes that off: it only fast-forwards the local main ref when the main checkout is clean and actually on main.

Deferring a merge is allowed, but never silent. Three legitimate reasons: the primary checkout is dirty at the same paths, main has diverged enough that it needs real review, or the branch is mid-arc and a partial merge would land broken intermediate state. If I defer, I append one line to MERGE-DEBT.md at the repo root and say it out loud in the session:

echo "- $(date +%Y-%m-%d) worktree-kaibot-93211: 3 commit(s) ahead, deferred because main diverged, rebase needs review" >> MERGE-DEBT.md

The ledger line comes out once the branch lands. It separates “deferred on purpose, reason on record” from “forgotten,” which is the distinction the 2026-05-27 incident didn’t have.

03The debt detector: owned or abandoned

A ledger is useless unless something forces a read of it.

I added a SessionStart hook named worktree-merge-debt.sh on 2026-06-07, after the eleven-day incident. It runs at every session start and reports, about the repo I just opened:

  1. Stranded branches: local branches carrying commits ahead of main that the current session's branch did not create. The comparison runs through git cherry, which compares patches rather than raw commit SHAs. Identical content landed under a different SHA (via merge, cherry-pick, or re-commit during merge-back) is not stranded, and raw SHA comparison raised a false alarm on 2026-06-18 about two kaiflix commits that were already on main under other hashes. Patch comparison fixed it.
  2. Dirty sibling worktrees: substantive uncommitted changes in someone else's tree. Routine per-session churn (WORKLOG.md, local settings, daily-log entries) gets filtered out so real lost work isn’t buried in noise. The scan was originally capped at 12 worktrees, which silently skipped the check on a repo with roughly 28, so the cap went to 60. kaibot has outgrown that too: past 60 worktrees, only the primary checkout gets scanned.
  3. Merges or rebases left mid-conflict, added 2026-07-05: an unresolved merge in a shared worktree blocks every fast-forward pull or merge in that checkout until someone resolves or aborts it.

Liveness, also added 2026-07-05, is what makes the report actionable: each stranded branch, dirty tree and in-progress merge is matched against a shared census of running Claude sessions and their working directories. If a live session's cwd is inside the worktree, the report tags it “LIVE session in this tree: active work, coordinate, do NOT reconcile/merge/GC.” No tag means nobody owns it and it is safe to reconcile.

The census lookup cannot block a session from starting. It is timeout-guarded at six seconds, so a slow process scan degrades to “no markers shown”.

The liveness distinction comes from a 2026-06-18 incident. A concurrent session ran an ad-hoc merge-back “salvage,” judged two worktrees stranded, and force-removed both. One belonged to a still-live session mid-edit, so the removal pulled the working directory out from under it and committed its uncommitted scratch work to main as a side effect.

Nothing in the system cleans up on its own. The detector's only job is to say which strand is safe to touch and which is someone's work in progress.

04The guard hooks: rules that can’t be skipped

The debt detector reports after the fact. The guard hooks stop the mistake at the exact tool call that would cause it.

What’s live on kaibot today
HookTypeWhat it blocks
branch-first-guardPreToolUseEdit / Write / MultiEdit while the target repo’s checked-out branch is main or master. Points at the worktree flow, checks an allowlist for state files first, releases via an 8-hour human-typed bypass token.
commit-on-main-guardPreToolUsegit commit of non-allowlisted work on main/master. Closes the gap the Edit guard alone leaves: files written by cat >, sed -i, or an external editor never touch the Edit tool. Added 2026-06-24 after a checkout accumulated eleven unpushed commits directly on main and wedged auto-sync. Since 2026-09-29, kaibot’s primary checkout refuses every commit on main, allowlisted or not.
main-tree-checkout-guardPreToolUse (Bash)Switching the main checkout off main via git checkout/switch. My global hook config symlinks into the main checkout, so parking it on a feature branch silently disables every hook added since that branch diverged, machine-wide. That’s exactly how a foreign-tree write guard got bypassed on 2026-06-20.
worktree-foreign-tree-write-guardPreToolUseWrite / Edit / MultiEdit into a sibling checkout of the same repo. Edit and Write take absolute paths and ignore cwd, so it’s easy to pass a repo-root path while sitting in a worktree and land the edit in the shared main checkout, or someone else’s tree, by accident. A merge-back fix did exactly that on 2026-06-20 and had to be recovered by hand.
worktree-self-reap-guardPreToolUse (Bash)A session removing its own worktree or deleting its own worktree-* branch, and separately, git worktree remove of any worktree whose session transcript was touched within the last 24 hours, live or not. This is the hook the 2026-06-18 force-remove incident produced.
worktree-isolationSessionStartNot a block: surfaces a nudge when a session started un-isolated (bare claude, an IDE-extension launch) so I relaunch via cdc before branch-first-guard has to catch the first edit.
worktree-merge-debtSessionStartNot a block either: the debt report from §03, read-only, every session start.

All the write-side guards share the same shape: they fail closed (missing jq or git means deny, not silently allow), the bypass is always a token a human types from their own terminal with an expiry, never something the model can decide for itself, and the deny message names the exact fix.

05What to steal

The habit

The launch habit is the most useful item here, and it costs nothing to adopt. Never start an agent session directly in a repo you also touch by hand or with other sessions. Wrap the launch instead:

# cdc.sh (sketch): worktree-isolate every interactive session
cdc() {
  local project="$1"
  local slug="${project}-$$"   # $$ = this shell's PID, guarantees a unique name
  cd ~/projects/"$project" && claude --worktree "$slug"
}

That one function is most of the value. Everything else on this page exists to catch the times someone (me, usually) forgets to use it.

A minimal branch-first PreToolUse hook

A stripped-down version of the real branch-first-guard.sh, for a repo that doesn’t have worktree isolation wired up yet and just wants to stop edits landing on main:

#!/usr/bin/env bash
# Save as ~/.claude/hooks/branch-first-guard.sh, register under PreToolUse
# with matcher "Edit|Write|MultiEdit".

INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
case "$TOOL" in Edit|Write|MultiEdit) ;; *) exit 0 ;; esac

FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[ -z "$FILE" ] && exit 0

REPO=$(cd "$(dirname "$FILE")" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null)
[ -z "$REPO" ] && exit 0

BRANCH=$(git -C "$REPO" rev-parse --abbrev-ref HEAD 2>/dev/null)
case "$BRANCH" in main|master) ;; *) exit 0 ;; esac   # feature branch: allow

# bypass: a human-created token, never one the model can make itself
TOKEN="/tmp/claude-branch-bypass/$(echo "$INPUT" | jq -r '.session_id')"
[ -f "$TOKEN" ] && [ "$(find "$TOKEN" -mmin -480)" ] && exit 0

cat <<'EOF'
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "BLOCKED: editing on main. Use claude --worktree, or ask the human to run: touch /tmp/claude-branch-bypass/<session_id>"
  }
}
EOF

A merge-back checklist for your CLAUDE.md

Text I’d paste in as-is:

## Merge-back discipline (concurrent sessions)

- Every interactive session runs in its own worktree/branch. Never edit
  source in a shared checkout that another session might also be using.
- Merge back after EVERY commit or coherent commit batch, not at session
  end. "Session end" doesn't reliably happen; the commit does.
- Can't merge right now? Log it, don't just move on:
  echo "- $(date +%Y-%m-%d) <branch>: <n> commit(s) ahead, deferred
  because <reason>" >> MERGE-DEBT.md
- Before touching ANY other session's worktree or branch, check whether
  it's live (a running process with its cwd inside it) before assuming
  it's abandoned and safe to reconcile.
- Never force-remove a worktree. Uncommitted work in a pruned worktree
  is unrecoverable; let cleanup be a separate, deliberate step.

06For the Claude that just got pointed here

If a human handed you this URL and said “set this up for me,” here’s the short version.

Agent instructions, with your human’s consent:

  1. Check whether this repo already isolates sessions. Look for a worktree-launch wrapper (a cdc-style function, claude --worktree in any onboarding doc). If it’s missing and more than one person or agent touches this repo, that’s the first thing to build, before any hook.
  2. Never edit source while checked out on main or master. Create a branch or a worktree first, always, even for a “quick” fix.
  3. Merge back after every commit, not at the end of your session. If you genuinely can’t merge right now, write down why in a ledger file (name it, or use MERGE-DEBT.md) and say so out loud to your human.
  4. Before you clean up, delete a branch, or remove anyone’s worktree, check for liveness. Is a process still running with its working directory inside that path? If yes, it’s active work: leave it and tell your human. If you can’t check, ask rather than assume it’s abandoned.
  5. Build the guard hooks as PreToolUse blocks, modeled on the table above: deny the Edit/Write on main, deny the commit on main, deny writing into a sibling checkout, deny reaping your own or another live session’s worktree. Every bypass is a token a human creates from their own terminal, never one you create yourself.
  6. Smoke-test each hook by piping sample JSON in before registering it, and tell your human hooks only take effect on the next session.

Turning a prose rule into an enforced hook, and the craft of writing one well, has its own note: Physics, not pleas.

07Receipts

The worktree model shipped in kaibot on 2026-05-19. As I write this there are 111 worktrees in that checkout. That’s the load the debt detector and guard hooks run against every day.

The merge-back helper's tree-safety claims (never touch a dirty main checkout, never clobber a checkout parked on a foreign branch) are covered by a dedicated suite running against bare-origin fixtures. The headline scenario parks the main checkout on a foreign dirty branch and then asserts it is never switched, never merged into, never clobbered by a merge-back run.

The incidents are mine. 2026-04-17, the shared-checkout revert. 2026-05-27, the eleven-day stranded-branch loss. 2026-06-18, force-removing a live session's worktree. 2026-06-20, the wrong-checkout write. 2026-06-24, eleven commits piled up on main. Every one is in my worklog.

The hooks look the way they look because those things happened.

Written with (and about) kaibot, 2026. The hooks and scripts quoted here are real and running against my own repo.

Excelsior!
Kai

You’ve reached the end of note 05 of 19

$400 1 hour

Book a call

A paid one-on-one call with answers to your questions about using AI for projects, building your AI stack, or growing your business with AI. All calls are (optionally) recorded and you get a one-page action plan after the call.

All nineteen field notes, in six chapters