Buddy: Parallel Research Workspaces
Related: the-orchestrator-premium Informs: Projects/research
A tmux + Ink sidebar over several resumable Claude Code sessions, so more than
one research thread can be live at a time and the board says which ones are
waiting on me. Lives in tools/buddy/; workspaces live in workspaces/.
The vision, checked
The ask was: a left sidebar listing the ideas I'm chasing, project folders with metadata instead of one doc per idea, state and events in JSON driven by hooks, several Claude Code instances the sidebar moves between, sessions that survive tmux dying, and folders that can host worktrees when research grows code.
It holds up. The pieces all exist and compose. Seven things needed deciding before it would actually work, and one of them would have quietly broken the vault.
1. projects/ would have collided with Projects/
The vault already has a gitignored Projects/ directory — Obsidian creates it
as stubs from [[Projects/sigil]] wiki links. macOS is case-insensitive, so
projects/economics and Projects/economics are the same directory, and
.gitignore line 17 would have swallowed every workspace silently.
Workspaces live in workspaces/ instead. It is also the word actually used to
describe them.
2. Session resume should not be scraped
The obvious approach is to read the session id out of a hook payload after startup and store it. That has a hole: between launch and the first hook there is no id, so a crash in that window strands the session.
claude --session-id <uuid> accepts an id up front. Buddy mints the UUID, hands
it to Claude, and resumes with --resume <uuid>. Resume is attempted only when
~/.claude/projects/<encoded-cwd>/<uuid>.jsonl exists on disk, because a
session id that was minted but never used makes --resume fail on launch. If
resume fails anyway, the launcher falls forward into a fresh session rather than
leaving a dead pane.
3. Hooks on every tool call would tax the agent
A hook per PreToolUse gives a lovely "what is it doing right now" line and puts
a process spawn on the agent's hot path — tens of milliseconds times hundreds of
tool calls per session, times several sessions at once.
So the signal is split by frequency:
- Rare state transitions → hooks.
SessionStart,UserPromptSubmit,Notification,Stop,SessionEnd. Five events per turn at most. - Per-second liveness → the transcript. The session's
.jsonlgrows as the agent works, so its mtime is a free heartbeat and its last line names the tool currently running. Zero cost to the agent.
A turn quiet for 90 seconds is flagged stalled, which in practice means a permission prompt or a long tool.
4. tmux should own pane identity
Storing pane ids in JSON means reconciling them every time a pane dies, a window
is renamed, or tmux restarts. Instead each pane carries tmux user options —
@buddy_role=panel|ws and @buddy_ws=<slug> — set at creation. tmux is then the
only source of truth for what is running; a killed pane simply stops appearing
in list-panes, and there is no stale bookkeeping to repair.
Moving between sessions is join-pane / break-pane rather than window
switching, which is what keeps the sidebar on screen while the staged session
changes underneath it.
5. "Done vs not done" belongs in git
Runtime state (session id, activity, event log) is disposable and gitignored. The task board is not — it is the durable shape of the thread, worth reviewing in a diff and editing by hand.
So each workspace splits in two: workspace.json (committed, hand-editable,
read-modify-written so it never clobbers manual edits) and .buddy/ (gitignored
session state and event log).
6. Existing docs should not move
Moving 90-odd docs out of docs/ into workspace folders would break the vault
app, whose DOCS_DIR is a single flat directory, and churn the wiki-link graph
for no gain.
A workspace references docs that already exist by wiki-link, and owns any new ones in its own folder. So the "one doc per idea" constraint is gone for new work — a workspace can deliver four docs — without a migration.
7. Code needs to be invisible to the vault
workspaces/*/code/ is gitignored. buddy worktree add <slug> <repo> adds a real
git worktree of an external repo there on a buddy/<slug> branch, owned by the
upstream repo. Research can grow code without the vault ever tracking it.
The arbiter, and where skills actually come from
The second build added an arbiter: one session that manages the board rather
than doing research. It is not special machinery — it is a workspace with
role: "arbiter", so it gets the same resume, event log, task board and sidebar
row as any thread. What differs is two things: it is pinned to the top of the
list, and it is launched with an extra plugin.
That second part turned out to be the whole design question. "Give the runners the system's skills, and the arbiter its own" sounds like a file-layout problem and is actually a resolution problem, because the four ways Claude Code finds context resolve against different roots:
| Layer | Resolves against | Survives a cd into a worktree? |
|---|---|---|
~/.claude/skills/ |
the user | yes |
--plugin-dir |
the session | yes |
CLAUDE.md |
cwd, then every parent | yes |
.claude/commands/ |
cwd | no |
Only --plugin-dir is both session-scoped and cwd-independent, which makes it
the one mechanism that can say "these skills, this session, wherever it runs."
So the buddy skills ship as two local plugins — buddy-runner (board discipline,
doc delivery, spinning out code, importing a thread) and buddy-arbiter (triage,
dispatch, harvest) — and the arbiter simply gets both. Skills namespace as
plugin:skill, so nothing can collide with a repo's own.
Three things were verified rather than assumed, by probing a live session inside a real worktree:
- Plugin skills load in a worktree. The runner set was present with cwd deep inside a foreign repo; the arbiter set correctly was not.
- Both
CLAUDE.mdfiles load. The upstream repo's and the vault's — the latter only becausecode/is nested inside the vault. That is a real payoff from putting worktrees under the workspace rather than beside it. --add-dir <vault>brings the vault's slash commands along, unprefixed, as a side effect of granting tool access. So abuddy opensession in a worktree keeps/researchand/magiwith no symlink at all.
Symlinks turned out to be the right answer for the other half of the problem —
mounting the vault into a workspace container, below — but not here.
buddy worktree add still links .claude/commands/vault into the worktree, for
the session started by hand with cd .../code/<name> && claude, which has no
--add-dir. The link is registered in the upstream repo's common git dir
info/exclude — not the per-worktree one, which git does not read — so the
foreign repo's git status stays clean. --no-link-vault opts out.
The precedence that matters in practice: inside a worktree the code repo's
conventions win for code, and the vault's win for research markdown. Nothing
enforces that but the buddy-spin-out skill saying so plainly.
Containers, and docs as the shared database
The first cut rooted every session at the vault, on the reasoning that a
research thread needs docs/ and the tooling in reach. That was the wrong shape,
and Ryuhei named the right one: each workspace is a container, and the vault's
markdown is a database that containers connect to — many clients, one corpus.
Rooting every thread at the vault made them all feel like the same session
wearing different labels.
So a workspace's session now has cwd set to the workspace folder, and the vault reaches it only through explicit mounts — relative symlinks in the container:
workspaces/ai-capex-mask/
BRIEF.md workspace.json <deliverables>.md
docs -> ../../docs the shared corpus
.claude/commands/vault -> …/.claude/commands the shared command set
code/<name> worktrees
.buddy/ state, events, settings
Four things had to be true for this to work, and all four were tested rather than assumed:
- Tools traverse a mount. Read, Glob, Grep and Write all follow the symlink
out to the real
docs/, sodocs/the-thing.mdfrom inside a container is the vault's file. That is what makes the database analogy literal rather than a metaphor. - No
--add-diris needed. The mount alone carries the corpus, so the container does not have to be handed the whole vault to do its job. - Slash commands mount too. They resolve from cwd, so the vault's set is
symlinked in and namespaces as
vault:research,vault:magi— leaving room for workspace-specific commands beside them. - Siblings can be denied. Each container launches with generated
permissions.denyrules naming every other workspace, so a thread cannot read or edit its neighbours. (Edit(...)covers all file-editing tools; aWrite(...)rule matches nothing — the harness says so directly.)
One honest limit: this is scope hygiene, not a sandbox. cwd plus deny rules stop the file tools, but Bash is not path-restricted, and reads outside a container are permitted unless a rule names them. The goal was threads that stop muddying each other, and that it achieves. It is not isolation against a session that means to leave.
The arbiter is the deliberate exception: it stays rooted at the vault with no sibling denies, because it cannot triage a board it is fenced out of. Runners are clients; the arbiter is the one seat that sees the whole database.
Two consequences worth knowing. Transcripts are keyed by working directory, so
each thread's conversations now live under its own container — /resume inside a
workspace offers that thread's history and nothing else. And because of that
keying, a workspace that adopts a vault-rooted conversation with buddy import session is pinned back to the vault root, recorded in state.cwd so the resume
keeps finding its transcript. Sessions that predate the recorded field are
treated as vault-rooted for the same reason.
Code sessions have their own identity
A session rooted in a worktree cannot share the workspace's session id, because
Claude Code keys transcripts by working directory — resuming from a different
cwd would look for a transcript that isn't there. So buddy open <slug> <name>
mints its own id, tracked under state.code[<name>], with its pane tagged
@buddy_code and its hook events routed to that sub-slot. The sidebar shows them
as child rows under the workspace.
Importing an existing thread
Most threads worth a workspace already exist as a chain of docs, so the CLI does the mechanics and a skill does the judgment.
buddy import scan "<query>" ranks docs by slug, title, tag and body hits, then
takes one hop along wiki-links in both directions at a discount. Link adjacency
is what actually reconstructs a thread — a chain usually cites its predecessor
even when the wording drifts. buddy import doc <slug> --doc a --doc b builds
the workspace, unions the source tags, and seeds the brief. buddy import session <slug> --last adopts an existing Claude Code conversation rooted in this vault
so the next launch resumes it rather than starting cold.
The buddy-import skill covers what the CLI can't: deciding where the thread
actually stops, noticing it has already split into two, and turning "what's
still open" into three to seven seeded tasks.
Shape
flowchart TB
subgraph tmux["tmux session: research"]
panel["buddy window<br/>sidebar (Ink) + staged pane"]
arb["ws-arbiter<br/>+ buddy-arbiter plugin"]
w1["ws-economics<br/>+ buddy-runner plugin"]
w2["ws-economics/edge-llm<br/>worktree, own session id"]
end
panel -->|join-pane / break-pane| w1
panel -.->|staged one at a time| arb
arb -->|buddy.sh start / say / stage| w1
w1 -.->|buddy open| w2
w1 -->|5 lifecycle hooks| ev["workspace .buddy/<br/>state.json + events.jsonl<br/>gitignored"]
w1 -->|writes| tr["session transcript .jsonl<br/>mtime = heartbeat<br/>tail = current tool"]
ev --> panel
tr --> panel
ws["workspace.json<br/>tasks, docs, tags<br/>committed"] --> panel
ws --> w1
The sidebar polls rather than watches: half the signal it needs (tmux liveness, transcript mtime) is not file-change driven anyway, so one cheap sweep every 800ms beats a watcher plus a timer.
Using it
scripts/buddy.sh install # once — symlinks ~/bin/buddy
buddy # start or attach the tmux session
buddy arbiter --start --stage # open the seat that runs the board
buddy new "AI capex mask" # scaffold a container + BRIEF.md
buddy import scan "<topic>" # find the docs that make up a thread
buddy ls # what's running, what's done
Named buddy — a sibling to the cubby system at work, which does the same
job for code repos.
Exiting a session closes its pane and returns to the dashboard; a session that
dies non-zero keeps its pane so the error is readable. Closing the sidebar
destroys its window, so buddy recreates the window rather than assuming it can
split back into it.
In the sidebar: j/k move, ↵ stages (launching if needed), n new
workspace, a add task, tab moves focus to the task list where ↵ cycles
todo → doing → done, i types a line into the staged session, s stops, r
relaunches resuming, R relaunches fresh, ? for the rest.
Every session is launched with a standing brief telling it which workspace it is
in, that deliverables go in the workspace folder, and to keep the board honest
with buddy task doing|done as it works — the point of the board is that the
shape of a thread is legible without reading the transcript.
Known edges
- The trust dialog fires once.
hasTrustDialogAcceptedis false for this vault, so the first session launched shows the folder-trust prompt. Answer it once and it never returns. - No web view yet. State is plain JSON, so a
/buddypage intools/vaultis a small add if the sidebar turns out to want a phone-shaped sibling. - Nothing auto-starts.
buddy upbrings up the board, not the sessions.