A shared whiteboard for parallel Claude Code sessions.
Run 2–6 sessions on the same repo (typically one per git worktree) and they can't see each other's context — so two of them may unknowingly pick overlapping tickets and do the same work twice. Git worktrees isolate files; they do nothing about double-work. This plugin fixes that with a tiny shared registry.
Each session posts what ticket it's working on to a shared JSON file. Every other session reads that file at startup and on every prompt, and is warned before starting work another session already owns — and told how to reach the session that owns it, so the two can settle it between themselves.
Not orchestration — no lead, no workers. Just a whiteboard equal peers read and write.
| Hook | What it does |
|---|---|
SessionStart |
Registers this session and injects the list of other active sessions into context, each with the address you can message it on. |
UserPromptSubmit |
Works out this session's current ticket (see Ticket evidence) plus any claimed label, records it, and — only if another live session demonstrably owns that same ticket or label — injects a conflict warning telling the agent to stop and check with you. Silent otherwise. |
PreToolUse (Bash) |
Matches the command against the resource map. Claims a free resource silently; blocks the command when another live session holds it, and records this session as a waiter. Does nothing at all for a command that touches no resource. |
SessionEnd |
Removes this session (ticket, label and holds) and prunes stale (crashed) entries past the TTL. Opens the priority window on anyone waiting for a resource it held. |
session A ──"work on ETHEN-447"──▶ whiteboard: A = ETHEN-447
session B ──"work on ETHEN-447"──▶ ⚠ warns B: ETHEN-447 already owned by A → stop & confirm
session A ──"now ETHEN-880"─────▶ whiteboard: A = ETHEN-880 (updated in real time)
Where a ticket id comes from decides how much it is trusted:
| Evidence | ticket_src |
Strength | Behaviour |
|---|---|---|---|
Worktree dir or branch name (ethenapayf-1013-kyc-camera, jira-12-login-fix, feature/ETHEN-447) |
worktree |
strong | Always wins. Overwrites a value previously sniffed from a prompt. |
Ticket id inside your prompt (ETHEN-447) |
prompt |
weak | Used only when the session has no worktree evidence and no ticket recorded yet. Never overwrites an existing ticket. |
Dir/branch detection uses the same CC_WHITEBOARD_TICKET_RE as prompt sniffing,
so it works for any tracker — not one hard-coded project prefix.
How loud a warning gets depends on the holder's evidence:
Holder's ticket_src |
You get |
|---|---|
worktree — they are sitting in that worktree |
⚠ CONFLICT + an instruction to stop and check with you |
prompt (or a pre-0.2.2 entry with none) — they only mentioned it |
a one-line note, no stop directive |
[claude-whiteboard] ⚠ CONFLICT: ticket "ETHENAPAYF-1013" is already being worked
on by session 4a9f21c8 (dir: ethenapayf-1013-kyc-camera, branch: feat/kyc-camera)
This is likely DOUBLE WORK. STOP before editing code: …
[claude-whiteboard] note: session 3689a1c2 (dir: worktrees) also mentioned ETHENAPAYF-1013.
The message names the holder's directory and branch so you can verify ownership at a glance. Each distinct overlap is announced once — the same ticket held by the same session will not warn again, so a real conflict can't turn into an every-prompt drumbeat. A different holder warns again.
Put @wb-ignore in a prompt (as a whitespace-delimited token, anywhere) and the
hook is a complete no-op for it — no ticket sniffing, no claim markers, no
registry write, no conflict check:
@wb-ignore
Board dump from the other terminal — ETHENAPAYF-1013 is owned by session 3689…
Use it for pasted coordination dumps, cross-checks, and status reports that talk about tickets you are not working on.
No ticket id? Claim a free-text label so other sessions still get warned:
/claude-whiteboard:claim auth refactor → whiteboard: this session = "auth refactor"
another session that claims "auth refactor" is warned
/claude-whiteboard:release → drop the label (also cleared at session end)
Labels match exact, case-insensitive — same reliability contract as ticket ids, so a warning always means a real overlap. There is no fuzzy free-text matching (that would fire false conflicts and erode trust in the warning). Ticket detection is unchanged; a prompt can carry both a ticket and a claimed label.
A warning that names a stranger is only half an answer. Every board row also
carries that session's peer name — the address Claude Code's own
SendMessage tool delivers to — so the agent can ask the other session what it
has already done instead of routing the question back through you:
- 07a7860e ticket: ETHENAPAYF-1013 dir: ethenapayf-1013-kyc-camera peer: 1013 (busy)
[claude-whiteboard] ⚠ CONFLICT: ticket "ETHENAPAYF-1013" is already being worked on …
That session is reachable: SendMessage({to: "1013", message: "..."}) — ask what it
has already done before you touch anything.
The name comes from Claude Code's own session registry (~/.claude/sessions/*.json),
joined against the board at render time on the session id both sides already
record. Nothing is copied into the whiteboard's registry, so a session you rename
stays reachable and the board never serves a stale address.
Consequences worth knowing:
- No new transport. The whiteboard does not move messages;
SendMessagedoes. The board only tells you the address. - Older Claude Code, or no session registry — rows simply render without
peer:, exactly as they did before. Nothing errors. - A session Claude Code has not named gets no address line.
- Duplicate names are possible (two sessions can both be called
avax). The board prints the name and the directory;SendMessageitself asks you to disambiguate when the name is ambiguous. The 6-hex[ref]thatListAgentsprints is not stored in the session registry, so the board cannot offer it. - A mention-only note gets no address, deliberately — it stays one line.
A ticket conflict means two sessions may do the same work twice — wasteful, but recoverable. A resource conflict is different in kind: the second session does not duplicate the first, it corrupts it. Bringing up a second local stack rewrites the database and the shared config rows the first session is running against, and the victim then debugs a failure that looks like a bug in its own feature.
So resources are not warned about at prompt time. They are blocked at the
moment the command runs, by a PreToolUse hook on Bash:
$ docker compose up -d
BLOCKED: "local-stack@wallet-monorepo" is held by session fe649d0f
dir: backend branch: fix/pay-106 since: 12m ago
You are now recorded as WAITING for it.
Ask the holder directly:
SendMessage({to: "931-deposit", message: "I need local-stack. When can I take it?"})
Names are scoped per repo — local-stack@wallet-monorepo. The registry is one
file shared by every repo on the machine, so an unscoped name would let one
project block an unrelated one. The repo key comes from
git rev-parse --git-common-dir, not --show-toplevel: a linked worktree's
toplevel is its own directory, and worktrees of one repo are exactly the
collision domain, since they share the database and the ports.
CC_WHITEBOARD_RESOURCES is a JSON map of resource → {claim, release, probe}.
The shipped default is deliberately generic, because this plugin is public:
{
"local-stack": { "claim": "(^|[;&|(])[[:space:]]*(docker[- ]compose\\b.*\\bup\\b|ngrok\\b)",
"release": "(^|[;&|(])[[:space:]]*docker[- ]compose\\b.*\\bdown\\b", "probe": "" },
"xcode": { "claim": "(^|[;&|(])[[:space:]]*(xcodebuild\\b|xcrun +simctl +(boot|install|launch))",
"release": "(^|[;&|(])[[:space:]]*xcrun +simctl +shutdown", "probe": "" }
}Every claim pattern is anchored to command position — start of line, or just
after ;, &, | or (. A bare \bngrok\b claimed on the mere appearance of
the word: a sed writing the literal string your-tunnel.ngrok-free.dev into a
config file took the local-stack hold with no stack running. Anchor your own
patterns the same way. A claim blocks every other session, so a false positive is
far more expensive than a missed one.
CC_WHITEBOARD_RESOURCES replaces this map; it does not merge into it. So a
fix to a shipped pattern never reaches a machine that sets the variable — anchor
your own patterns yourself, including the branches that look guarded. In a real
override, \bjust\b[^|;&]*\bstack::up\b was assumed safe because [^|;&]*
keeps both halves inside one pipeline segment. It does not anchor anything:
grep -rn "just stack::up" docs/ still claimed the resource.
Override the whole map to add your project's own recipes (just stack::up,
just db::migrate) and a real probe. A command matching several resources is
all-or-nothing: if one is held, nothing is claimed, so a block never leaves a
phantom on a resource that was not the reason for it.
A blocked session is recorded as a waiter and handed the holder's SendMessage
address. The holder asks you, because only you know whether its run is still
needed — no session ever releases another session's resource.
When the holder releases (/claude-whiteboard:free, a matching release command,
or simply ending its session), every waiter gets a short priority window:
during it, a session that never waited is held off, while any waiter may
claim — not only the one who waited longest. That is deliberately a timestamp
rather than an auto-grant. Handing ownership to an idle waiter would create the
very phantom this feature exists to prevent.
Three layers, all evaluated when a hook reads the registry. No daemon, no timers.
| Layer | What it catches |
|---|---|
| PID liveness | The holder crashed. Its pid / procStart come from Claude Code's own ~/.claude/sessions/<pid>.json, so a dead holder is reclaimed on the next check instead of waiting out the TTL. |
| Probe (optional, per resource) | The holder is alive but tore the stack down. A probe exiting 0 means really up; it also outranks idleness, because an hour of silence is normal while you hand-test on a device. |
| Idle soft-expiry | No probe and the holder has not prompted for CC_WHITEBOARD_HOLD_IDLE. Anyone may then take it with a one-line notice, no /force needed. |
Liveness is three-valued. If the session registry is missing or unreadable, liveness is unknown, never dead — otherwise every holder would read as dead on such a machine, every hold would be reclaimed, and the lock would silently stop working while still reporting success.
A probe that says a resource is up while nobody claims it produces a warning, not a block: that is a non-Claude process or a session older than this plugin, and there is nobody to ask.
Effectively zero in steady state. Hooks are shell scripts (no model tokens to
run). The only context cost is text a hook injects: a small list once at
SessionStart (peer names add roughly 8 tokens per listed session), and a
one-line warning only when a real conflict is detected. No conflict → nothing
injected. The address lookup runs only when a conflict is actually found, so an
ordinary prompt never pays for it.
/plugin marketplace add LOGANLEEE/claude-whiteboard
/plugin install claude-whiteboard@claude-whiteboard
Requires jq (brew install jq). Without it the
plugin no-ops safely.
claude --plugin-dir ./claude-whiteboard| Var | Default | Purpose |
|---|---|---|
CC_WHITEBOARD_REGISTRY |
$CLAUDE_PLUGIN_DATA/whiteboard/registry.json (falls back to ~/.claude/...) |
Where the shared registry lives. Point several sessions at the same path (default already does). Set to a repo-local path if you want per-repo boards. |
CC_WHITEBOARD_TICKET_RE |
[A-Z][A-Z0-9]+-[0-9]+ |
Regex used to detect a ticket id, both in a prompt and (case-insensitively) in the worktree dir / branch name. Matches ETHEN-447, JIRA-12, etc. Tighten it if your naming produces false hits — e.g. a directory called portfolio-2024 reads as ticket PORTFOLIO-2024. |
CC_WHITEBOARD_SESSIONS_DIR |
~/.claude/sessions |
Where Claude Code keeps its own per-session JSON files. Read-only — the plugin joins against it to learn each peer's SendMessage name. Point it elsewhere (or at an empty directory) to turn the address lookup off. |
CC_WHITEBOARD_TTL |
14400 (4h) |
Entries older than this are treated as stale and pruned (crash safety). |
CC_WHITEBOARD_RESOURCES |
generic map (see Shared resources) | resource -> {claim, release, probe}. Override the whole map to add project-specific commands. |
CC_WHITEBOARD_HOLD_IDLE |
3600 (1h) |
Holder idle seconds before a hold with no probe goes soft and anyone may take it. |
CC_WHITEBOARD_RESERVE |
300 (5m) |
Priority window given to waiters when a resource is released. 0 disables it. |
CC_WHITEBOARD_WAIT_TTL |
7200 (2h) |
A wait older than this is ignored and grants no reservation. |
CC_WHITEBOARD_PROBE_TIMEOUT |
2 |
Seconds before a probe is treated as unknown rather than down. |
/claude-whiteboard:status— print the current board (active sessions, tickets, labels, branches, peer names, last-seen age)./claude-whiteboard:claim <label>— claim a free-text label for ticketless work so other sessions are warned off it./claude-whiteboard:release— drop this session's claimed label./claude-whiteboard:use <resource>— manually claim a shared resource. Needed only for something no command can be matched against, notably a GUI Xcode build. Refuses, and names the holder, when another session holds it./claude-whiteboard:free <resource>— release a resource this session holds and give waiters a head start. Refuses, and names the holder, when someone else holds it./claude-whiteboard:force <resource>— take it from a holder you have verified is not using it.
- Pull, not push. A session learns another moved on at its own next prompt or restart — at most one turn of lag. Fine for avoiding double-work.
- Ticket or label. Automatic detection prefers the worktree/branch name and
falls back to a ticket id in your prompt (weak — see above). Ticketless work is
tracked only when you
/claude-whiteboard:claima label — there is deliberately no fuzzy free-text sniffing. - A weak ticket sticks. Once a session with no worktree evidence has a
prompt-sniffed ticket, later prompts never change it; only moving into a
matching worktree does. Prose can trip the default regex (
UTF-8,ISO-8601,RFC-7231all match), and that value then sits on the board for the rest of the session. Use@wb-ignoreon prompts that merely discuss tickets, and tightenCC_WHITEBOARD_TICKET_REif your prompts are full of such tokens. @wb-ignoreskips the heartbeat too. It writes nothing at all, so a session whose every prompt carries the marker can age past the TTL and drop off the board. Any normal prompt puts it back.- The address is a pointer, not a channel. The board tells a session where to
reach another one; delivery is entirely
SendMessage's job. If that tool is unavailable the row is just informational. - Concurrency-safe. Writes are serialized with a portable
mkdirlock (noflockdependency — works on macOS and Linux), so 2–6 sessions won't corrupt the file.
- Fix:
/usehanded one resource to two sessions and told both they had it.board.sh usecalledwb_hold, which records a hold whoever else already has one, and printedyou now hold "<res>"unconditionally — so the front door granted exactly the collision thePreToolUsehook refuses to allow. It now claims throughwb_hold_exclusive(check and write in onejqunder one lock, then a re-read) and refuses when another session holds the resource, naming the holder, the peer to message and/force. Crashed holders are swept first, so a phantom row cannot block a claim forever. Re-claiming a resource this session already holds stays idempotent and does not refreshsince. - The refusal message is now one helper shared by
useandfree, so the two commands cannot drift into telling a blocked session different things.
- Fix:
/freereported success without releasing anything.wb_unholddeletes from the caller's holds, so a session freeing a resource it did not hold deleted nothing — whileboard.sh freeprintedreleased "<res>"unconditionally, because nothing checked whether the registry had changed. A session ran/claude-whiteboard:free xcodetwice, was told it worked twice, and stayed blocked behind the same hold for 165 minutes.freenow settles its own hold first, then refuses when another session holds the resource, naming the holder, the peer to message and/force; re-reads the registry before claiming a release; and opens the waiters' priority window only after a release that actually happened.
- Fix: every marker-based slash command was a silent no-op.
/use,/free,/force,/claimand/releaseasked the model to emit an@wb-*marker and left theUserPromptSubmithook to pick it up. The hook receives the text the user typed —/claude-whiteboard:free xcode— not the expanded command body, so the marker was never there to find. Each command reported success and changed nothing; a session waited 30 minutes behind a hold its owner believed it had released. The commands now call the newscripts/board.shdirectly, usingCLAUDE_CODE_SESSION_ID, which a Bash tool call does carry and which is the same id the hooks get on stdin. Hand-typed@wb-*markers keep working unchanged. - Fix: scripts run outside a hook read a different, empty registry.
CLAUDE_PLUGIN_DATAis set for hooks only, soscripts/status.shfell back to~/.claudeand printedNo active sessionsagainst a live board of eight. That made/free's own verification step blind by construction.lib.shnow finds the plugin data directory by name when the variable is absent, andstatus.shexits non-zero saying so rather than reporting a missing file as an empty board. - Fix: claim patterns are anchored to command position.
\bngrok\bmatched asedthat merely wroteyour-tunnel.ngrok-free.devinto a config file, taking the local-stack hold with nothing running and queueing a second session behind it.\bxcodebuild\bhad the same shape. Both now require the tool to be in command position. No shippable generic probe exists for a public plugin — set one inCC_WHITEBOARD_RESOURCES(e.g.lsof -nP -iTCP:7925 -sTCP:LISTEN); theCC_WHITEBOARD_PROBE_GRACEwindow added in 0.4.1 already keeps it from evicting a stack that is still booting. - Tests: 223 assertions (lib 65, on-prompt 68, on-pretool 70, board 20).
- Fix: a probe no longer evicts a hold that is still booting. A claim is recorded at
PreToolUse, before the command runs, so a stack that takes minutes to start legitimately probes DOWN for that whole window. The probe-DOWN path treated such a hold as a phantom and cleared it, letting a second session claim mid-boot — the exact collision resource claims exist to prevent. Probe-driven eviction now applies only pastCC_WHITEBOARD_PROBE_GRACE(default 300s). - The defect was dormant in 0.4.0: every test ran with
probe: "", which yields unknown and never evicts, so the branch only armed itself once a real probe was configured. New env varCC_WHITEBOARD_PROBE_GRACE. - Tests: 195 assertions (lib 57, on-prompt 68, on-pretool 70).
- Exclusive resource claims. Sessions now coordinate shared singletons — the
local stack, an Xcode/device build, a tunnel — not just tickets and labels. A
command that would take a resource another live session holds is blocked by a
new
PreToolUsehook rather than warned about a prompt too late. - Resource names are repo-scoped (
local-stack@<repo>), keyed ongit rev-parse --git-common-dirso every worktree of a repo shares one key. - A blocked session is recorded as a waiter and handed the holder's
SendMessageaddress. On release, waiters get a short priority window; any waiter may claim during it, not only the first. Ownership is never auto-granted. - Phantom holds are resolved at read time by three layers: PID liveness from Claude Code's own session registry, an optional per-resource probe, and idle soft-expiry. Liveness is three-valued — an unreadable session registry means unknown, never dead, so the lock cannot silently invert.
- New commands
/claude-whiteboard:use,:free,:force; board rows gainuses:and/claude-whiteboard:statusgains aRESOURCEtable. - Five new env vars (see Configuration).
- Tests: 181 assertions across three suites (lib 54, on-prompt 68, on-pretool 59), up from 56 in one.
- Every board row now carries the peer's
SendMessageaddress, so a session that finds an overlap can talk to the session that owns it instead of handing the problem back to you.SessionStartrows gainpeer: <name> (busy|idle),⚠ CONFLICTgains the exactSendMessage({to: ...})call for the holder, and/claude-whiteboard:statusgains aPEERcolumn. - The name is joined from Claude Code's own
~/.claude/sessions/*.jsonat render time, keyed on the session id both sides already record. Nothing new is stored, so a renamed session cannot go stale on the board. - Degrades silently everywhere it can't resolve: no session registry, an unnamed session, or a half-written session file all render exactly the pre-0.3.0 output with a clean stderr.
- A mention-only note deliberately stays one line — no address.
- New
CC_WHITEBOARD_SESSIONS_DIRenv var (also what makes the join testable). - Tests: 56 assertions, up from 46.
- Fix: prompt mentions no longer claim tickets. Ticket evidence is now ranked
— worktree/branch name (strong) beats a ticket id sniffed from prompt text
(weak), and a weak value never overwrites an existing ticket. Previously the
first ticket-looking token in any prompt became the session's ticket, so
pasting coordination text that mentioned
ETHENAPAYF-1013tagged that session as owning 1013 and buried the session actually in theethenapayf-1013-*worktree under⚠ CONFLICTwarnings on every prompt. ⚠ CONFLICTnow fires only when the other session's ticket came from a worktree. A mention-only holder produces a one-line note instead.- Each overlap is announced once, not on every prompt, for as long as the ticket and the holder stay the same.
- Conflict messages now include the holder's directory alongside the branch.
- New
@wb-ignoremarker — a prompt containing it is skipped entirely. - Sessions record
ticket_src(worktree/prompt) in the registry. - Worktree ticket detection is no longer hard-coded to one project prefix; it
uses
CC_WHITEBOARD_TICKET_RE, sojira-12-login-fixandfeature/ETHEN-447are detected too. - Fixed:
@wb-ignoreand@wb-releasewere silently ignored on prompts larger than the pipe buffer (~64 KiB).grep -qexits at the first match, killing the upstreamprintfwithSIGPIPE, and underpipefailthat turned a match into a non-zero status — exactly on the giant pasted dumps the marker exists for. - Added
tests/on-prompt.test.sh(bash + jq, 46 assertions, no Claude Code restart needed).
- Auto-detect the ticket from the worktree dir / branch name when the prompt never mentions it.
- Keyword label claim for ticketless work (
/claude-whiteboard:claim).
bash tests/on-prompt.test.shMIT