Optional add-on · iTerm2 + Claude Code

Pane Handoff —
File-Based Routing

Write a directive to a file in one pane, it arrives as a single paste in the other. Scope-keyed so two projects run in parallel without cross-talk. Watcher-managed, launchd-resilient.

macOS only · iTerm2 · fswatch · ~3 min install · back to main guide
In this guide
00

Quick start cheat sheet

Four commands cover 95% of day-to-day usage. Read once, memorise, refer back when something doesn’t paste.

Daily commands
handoff-on # ARM routing (touch ~/.claude/handoff/active)
handoff-off # DISARM routing
handoff-use <project> # Set THIS PANE's scope (once per pane open)
handoff-claim-impl # OR handoff-claim-audit — bind this pane to its role

Mental model — two independent switches

SwitchScopeSet byState lives in
Watcher runningGlobal (whole Mac)launchdOS process table
Routing armedGlobalhandoff-on / handoff-off~/.claude/handoff/active
Pane boundPer panehandoff-use + handoff-claim-*~/.claude/handoff/*-target.<scope>.id
💡
If a paste doesn’t arrive, ask three questions in order

1. Is the watcher running? — launchctl list | grep handoff

2. Is routing armed? — ls ~/.claude/handoff/active

3. Is the target pane still bound? — handoff-targets

90% of failures are #3. After an iTerm restart, UUIDs change and you must re-claim.

Reading the examples

Every command below has a pane tag showing where to run it:

IMPL the pane where Claude implements AUDIT the pane where Claude reviews ANY file-only, anywhere MAC plain Mac Terminal outside Claude

01

Install

One installer covers everything: dependencies, file placement, launchd registration, and arming. Idempotent — safe to re-run after edits.

1.1Install fswatch
Mac terminal
brew install fswatch

Required dependency. Without it the watcher cannot detect file events.

1.2Run the installer
From repo root
./handoff/install.sh

The installer:

  1. Verifies fswatch, osascript, and iTerm2 are present
  2. Copies pane-handoff.sh~/.claude/hooks/
  3. Copies enforce-handback.py~/.claude/hooks/
  4. Copies start-impl.md and start-audit.md~/.claude/commands/
  5. Renders the launchd plist with your $HOME and installs to ~/Library/LaunchAgents/com.user.handoff.plist
  6. Loads the launchd agent (auto-starts at login, restarts on crash)
  7. Arms routing (touch ~/.claude/handoff/active)
1.3Verify
Sanity checks
launchctl list | grep handoff # → "<PID> 0 com.user.handoff"
tail /tmp/handoff.log # → "[handoff] Watching ~/.claude/handoff"
Healthy state launchctl list shows a numeric PID (not -) and exit code 0. Log shows a startup banner. You’re ready for Section 2.

To uninstall: ./handoff/install.sh --uninstall

02

Shell functions

The user-facing API lives in shell functions. Append the block, source ~/.zshrc, you’re done.

Append to ~/.zshrc
cat handoff/zshrc-handoff.sh >> ~/.zshrc
source ~/.zshrc

You now have these commands available in every pane:

CommandWhat it does
handoff-use <scope>Set this pane’s HANDOFF_SCOPE. Required before any other handoff command.
handoff-claim-implBind this pane as the IMPL target for the current scope.
handoff-claim-auditBind this pane as the AUDIT target for the current scope.
handoff-on / handoff-offArm / disarm routing (creates / removes ~/.claude/handoff/active).
handoff-statusPrint ARMED or DISARMED.
handoff-haltWrite HALT.<scope> (reason from stdin) — pauses only this scope.
handoff-resumeRemove HALT.<scope>.
handoff-targetsList all current bindings (UUID per role per scope).
handoff-scopePrint this pane’s current scope.
send-impl / send-auditPipe stdin to the scoped inbox.
03

Activate a NEW project

Example uses scope name myapp. Substitute your project name (lowercase, no spaces).

💡
Watcher: you don’t start it. It’s a launchd daemon (com.user.handoff) running 24/7 since first install. You only define scopes and claim panes.
3.1Open the IMPL pane and claim it

Open iTerm, open a new pane in the CC-IMPL profile, cd to the project, start Claude (cc).

IMPL shell
cd ~/Desktop/myapp
cc

Then inside Claude in that pane, set the scope once and claim:

IMPL inside Claude
handoff-use myapp
handoff-claim-impl

Expected output:

[handoff] scope set: myapp (pane: <UUID>)
[handoff] IMPL scope=myapp bound to <UUID>
3.2Open the AUDIT pane and claim it

Open another iTerm pane in the CC-AUDIT profile, start Claude there.

AUDIT shell
cd ~/Desktop/myapp
cc

Inside Claude, set the scope, type /start-audit to load the audit skill, then claim:

AUDIT inside Claude
handoff-use myapp
# then /start-audit, then:
handoff-claim-audit
3.3Confirm both bindings
ANY
handoff-targets

Expected:

audit-target.myapp -> <AUDIT pane UUID>
impl-target.myapp -> <IMPL pane UUID>
3.4Smoke test
ANY
echo "smoke test $(date +%s)" > ~/.claude/handoff/to-impl.myapp.txt

Within ~1 second, the IMPL pane should show smoke test <timestamp> as a single paste.

Verify the file was drained AND the watcher logged the delivered-marker:

wc -c < ~/.claude/handoff/to-impl.myapp.txt # → 0 = delivered (not failure)
tail -3 /tmp/handoff.log

Expected — two adjacent lines per delivery:

[handoff] -> IMPL scope=myapp (1 lines)
[handoff] delivered to IMPL/myapp — file truncated to 0 bytes (delivered-marker)
Done. The project is live. If the paste didn’t appear → §7 Troubleshooting.
04

Resume an EXISTING project (after reboot or iTerm restart)

iTerm assigns a new session UUID every time a pane opens. Yesterday’s *.id bindings point at UUIDs that no longer exist. You only need to re-claim.

💡
The watcher is already running. launchd auto-starts com.user.handoff at login. You do NOT need to start anything — just re-claim the panes.
4.1Re-claim the IMPL pane
IMPL inside Claude
handoff-use myapp
handoff-claim-impl
4.2Re-claim the AUDIT pane
AUDIT inside Claude
handoff-use myapp
handoff-claim-audit
4.3Verify
handoff-targets # UUIDs should reflect today's session

Watcher health check (only if something looks off)

MAC terminal
launchctl list | grep handoff # PID column must be a number, not "-"
launchctl load -w ~/Library/LaunchAgents/com.user.handoff.plist # if missing/dead
05

Sending work between panes

Two directions, two scoped filenames, one delivery contract: write a file, the watcher pastes it.

5.1AUDIT → IMPL (issue a directive)
AUDIT inside Claude, via Bash tool
cat > ~/.claude/handoff/to-impl.myapp.txt << 'HANDOFF'
Step 4.2 — implement Pydantic contract for User
Acceptance:
- src/contracts/user.py defines model
- pytest tests/contracts/test_user.py passes
- Gate clean
Begin now.
HANDOFF

Within ~1s, the IMPL pane receives the full multi-line text as one paste.

5.2IMPL → AUDIT (hand back)
IMPL inside Claude, via Bash tool
cat > ~/.claude/handoff/to-audit.myapp.txt << 'HANDOFF'
=== HAND-BACK ===
Step: 4.2
Merged SHA: abc1234
Gate: PASS
Surface assessment: contract is minimal, no external deps added
→ handed back
HANDOFF
5.3Convenience aliases (one-liners)
ANY
echo "ping" | HANDOFF_SCOPE=myapp send-impl
echo "ping" | HANDOFF_SCOPE=myapp send-audit
⚠️
Three load-bearing details

Multi-line content is preserved. Code blocks, blank lines, and indentation survive because the watcher wraps content in bracketed-paste escape codes.

Don’t add a trailing Enter. The watcher adds the final newline that submits the paste.

Filenames are scoped. Always to-impl.<scope>.txt and to-audit.<scope>.txt. Unscoped writes are refused with a WARN.

06

Pause the loop (HALT) — per scope

HALT is scoped. A halt in myapp does not block any other project.

ANY pane in the affected project
handoff-halt <<'EOF'
Trigger: gate failure on tests/contracts/test_user.py
Human action required: review failure, decide rollback vs fix-forward
EOF

This writes ~/.claude/handoff/HALT.$HANDOFF_SCOPE. Equivalent explicit form:

cat > ~/.claude/handoff/HALT.myapp << 'EOF'
Reason: ...
Human action required: ...
EOF

Effect

Resume (after a human resolves the issue)

handoff-resume # or: rm ~/.claude/handoff/HALT.myapp
07

Troubleshooting

Common failure modes and how to diagnose them from /tmp/handoff.log.

“Inbox file shows 0 bytes — is that a failure?” (FAQ)

No. The to-{impl,audit}.<scope>.txt files are transport, not state. The watcher truncates them to 0 bytes immediately after successful bracketed-paste delivery as its “delivered-marker.” The content is already inside the receiving pane’s transcript at that point.

How to tell success from failure (since the file looks the same in both cases):

tail -10 /tmp/handoff.log
Log patternMeaning
-> IMPL scope=X (N lines)
followed by
delivered to IMPL/X — file truncated to 0 bytes (delivered-marker)
✓ Success
-> IMPL scope=X (N lines)
followed by
WARN: delivery failed for to-impl.X.txt — file NOT truncated; inspect and retry
✗ Failure file left intact for inspection
No -> IMPL scope=X line at all! Silent watcher not running, routing disarmed, or write happened to wrong path

“Delivery failed — file NOT truncated”

Three causes, in order of likelihood:

  1. Stale binding (most common after reboot): the bound pane no longer exists. Re-claim from the live pane (§4).
  2. No binding for that scope: you wrote to-impl.foo.txt but impl-target.foo.id doesn’t exist. Run handoff-claim-impl with HANDOFF_SCOPE=foo from the intended pane.
  3. iTerm AppleScript permission denied: macOS → Settings → Privacy & Security → Automation → grant access to iTerm/osascript.

Watcher not running — check + recover

MAC terminal
launchctl list | grep handoff # PID column must be a number, not "-"
launchctl load -w ~/Library/LaunchAgents/com.user.handoff.plist # if missing/dead

Force-restart the watcher

The watcher does NOT hot-reload. After editing ~/.claude/hooks/pane-handoff.sh, restart:

pkill -f pane-handoff.sh # launchd will respawn within ~1s
pgrep -fl pane-handoff.sh # confirm fresh PIDs
handoff-on # re-arm routing (safe to run anyway)
⚠️
Expected steady state: 2 × pane-handoff.sh (main + fswatch subshell) + 1 × fswatch. If pgrep shows more, pkill again — duplicates cause double-delivery.

Routing disarmed (no log lines at all)

ls ~/.claude/handoff/active # if missing, routing is OFF
handoff-on # re-arm

Why does pgrep show 2 pane-handoff.sh processes?

That’s correct. The watcher uses fswatch | while ..., which forks a subshell. Steady state under launchd is 3 processes total: 2 × pane-handoff.sh (main + while-subshell) + 1 × fswatch. Anything more or fewer is a problem.

Pause routing globally without removing bindings

The clean way to deactivate the entire system while keeping pane bindings + scope + watcher process intact. Use it when you want to stop file→pane delivery temporarily (e.g. taking a break, swapping projects, debugging).

handoff-off # disarmed (writes accumulate in inbox, no delivery)
handoff-on # re-armed (queued inbox entries deliver on next tick)

What handoff-off does NOT do:

To fully deactivate everything (rare — usually only for OS troubleshooting):

handoff-off # disarm routing
launchctl unload ~/Library/LaunchAgents/com.user.handoff.plist # stop watcher daemon

Don’t put archives inside the watched dir

🚫
The watcher matches to-{impl,audit}*.txt recursively, so files under ~/.claude/handoff/archive/old.txt get routed as scope old. Always archive to the sibling dir ~/.claude/handoff_archive/.
08

Reference

Every command, every path, every rule in one place.

Critical paths

PathWhat it is
~/.claude/hooks/pane-handoff.shThe watcher script
~/.claude/hooks/enforce-handback.pyStop hook — blocks IMPL from claiming “→ handed back” without writing the file
~/.zshrcDefines handoff-* and send-* shell functions
~/Library/LaunchAgents/com.user.handoff.plistlaunchd config — runs watcher on login, restarts on crash
/tmp/handoff.logWatcher stdout + stderr
~/.claude/handoff/activeFlag — must exist for routing to work
~/.claude/handoff/HALT.<scope>Per-scope pause sentinel
~/.claude/handoff/{impl,audit}-target.<scope>.idBinding files (one UUID per scope per role)
~/.claude/handoff/to-{impl,audit}.<scope>.txtInbox files (auto-truncated on delivery)
~/.claude/handoff_archive/Sibling dir for old hand-backs — never inside the watched dir

Scope rules

09

Multi-project isolation

Five probes that prove two projects can run concurrently without cross-talk.

Pane choreography

Probe 1 — Scoped delivery isolation

MAC Window M
echo "ping A $(date +%s)" > ~/.claude/handoff/to-impl.proj_a.txt
echo "ping META $(date +%s)" > ~/.claude/handoff/to-impl.meta.txt

Pass: Window A IMPL shows only ping A …; Window B IMPL shows only ping META …; log shows two separate -> IMPL scope=… + delivered-marker pairs. No WARN.

Probe 2 — Unscoped writes refused

echo "should not arrive" > ~/.claude/handoff/to-impl.txt

Pass: log shows WARN: refusing unscoped to-impl.txt; file drained; neither pane received the text.

Probe 3 — HALT isolation

cat > ~/.claude/handoff/HALT.proj_a << 'EOF'
Reason: isolation probe
EOF
echo "meta still flows $(date +%s)" > ~/.claude/handoff/to-impl.meta.txt
rm ~/.claude/handoff/HALT.proj_a # cleanup

Pass: log shows ⛔ HALT scope=proj_a AND meta delivery succeeds despite the HALT.

Probe 4 — Missing scope refused

unset HANDOFF_SCOPE
echo "x" | send-impl # → ERROR: HANDOFF_SCOPE is not set

Pass: shell prints the error; no file written; no log line generated.

Probe 5 — /start-audit cold-start sees only its own scope

Restart Claude in the AUDIT pane, run handoff-use proj_a + /start-audit. The slash command does NOT pre-flight read the inbox file (it is 0 bytes by design after delivery); it only checks the scoped HALT (HALT.proj_a — absent), and stops cleanly. No foreign-project content surfaces.

Three log signals = full pass. Two delivered-marker pairs (Probe 1), one unscoped WARN (Probe 2), one HALT + parallel-scope delivery pair (Probe 3). Probes 4–5 confirm refusal paths.
10

How it works

The actual flow under the hood — useful for debugging unfamiliar failure modes.

AUDIT pane                  Watcher                       IMPL pane
 (Claude)               (pane-handoff.sh)                  (Claude)
    |                          |                              |
    |  Bash tool:             |                              |
    |  cat > to-impl.myapp.txt |                              |
    |  ----------------------> |                              |
    |                    fswatch fires                       |
    |                    reads file                           |
    |                    wraps in bracketed-paste             |
    |                    osascript -> iTerm session id ------> |
    |                    <----- ok                           |
    |                    truncates file to 0 bytes            |
    |                    logs delivered-marker                |

Why bracketed paste?

Without it, multi-line content with blank lines or code blocks would submit on the first \n. Bracketed paste tells the terminal “this is paste content” — the receiving Claude sees it as a single fresh user message.

Why scope-keyed filenames?

So two projects run side-by-side. to-impl.proj_a.txt routes to impl-target.proj_a.id; to-impl.meta.txt routes to impl-target.meta.id. No cross-talk possible.

Why a Stop hook on IMPL?

Without it, the model can claim → handed back in chat without actually writing the file — leaving AUDIT idle and the loop silently broken. enforce-handback.py detects this exact failure mode and forces the model to either write the file or remove the marker.

11

Changelog

DateChange
2026-06-05Integrated into the claude-code-multipane-iterm2 repo. Public installer, sanitised paths, AppleScript-injection fix (argv-passed file/session), symlink refusal, scope regex tightened to ^[A-Za-z0-9_-]+$, legacy plist auto-cleanup, Stop hook auto-merged into ~/.claude/settings.json.
2026-05-15Watcher logs delivered to {IMPL,AUDIT}/<scope> after each successful bracketed-paste + file-truncation. Closes the false-alarm where receiving panes saw a 0-byte inbox and misdiagnosed it as a write failure.
2026-05-15/start-audit no longer pre-flight-reads to-audit.<scope>.txt. The hand-back arrives via bracketed paste in the chat transcript; the file is transport, not state.
2026-05-13Initial scope-keyed handoff. Per-scope HALT sentinels. Multi-project isolation probes.