claudemesh
v0.2.0
Published
Local side-channel between Claude Code instances: discover, message, and read other claude CLI sessions on the same machine.
Maintainers
Readme
claudemesh
Let your local Claude Code sessions find and talk to each other — in realtime, with zero compromises to the Claude Code CLI you already use.
Run claude in three folders. Today they don't know the others exist. Install claudemesh and they do: each session gets a stable ID, can list its peers, send them tasks that wake them up the instant they arrive, and read each other's conversation history — all over a tiny localhost daemon. No cloud, no network exposure, no auth, no behavior changes inside claude itself. Stop using it any time and the original claude binary is exactly as it was.
claudemesh # launch claude inside the realtime supervisor
claudemesh -c # claude -c (continue most recent session)
claudemesh --resume # any other claude flag works the sameAnything that isn't a recognized subcommand (list, send, install, …) is forwarded straight to the wrapped claude binary, so claudemesh is a drop-in alternative entrypoint to claude — no alias, no shadow, no plugin install inside Claude. You get every feature of Claude Code (sessions, hooks, plugins, MCP, sub-agents, slash commands, IDE integration) plus the inter-instance side channel.
Status: v0.2 — realtime PTY supervisor with body injection, unix-socket daemon, folder-name addressing, idle tracking.
Why
You probably run claude in several folders at once. They have no idea about each other. Claudemesh gives every session a stable identity and a realtime side channel:
- Find peers: list which other Claudes are running on this machine, where, and for how long.
- Hand off work:
claudemesh send api-service "rerun the migration check"— the recipient wakes up and acts on it within a second if it was launched viaclaudemesh, or on its next user prompt otherwise. - Inspect: peek at another instance's history (recent turns, search, fetch a specific turn) when you need cross-session context.
- Notice: the receiver's status line ticks up (
✉2) when messages are waiting.
Everything is purely additive. Hooks and MCP are installed at the user level (~/.claude/), so existing project-level Claude Code config is untouched. Removing claudemesh is one command (claudemesh uninstall) and leaves no trace inside claude itself.
How it works
There are two layers, and you can use either independently:
┌─────────────── one machine ───────────────┐
│ │
│ ┌── claudemesh (PTY supervisor) ────────┐ │ ← Layer 2: realtime injection
│ │ ↑↓ proxy user terminal ↔ claude │ │ (when launched via `claudemesh`)
│ │ subscribes to daemon /events │ │
│ │ pastes the message into the PTY │ │
│ └─┬─────────────────────────────────┬─┘ │
│ │ (PTY pair) │ │
│ ┌─▼──────────────── claude ──────────▼─┐ │ ← Layer 1: hooks + MCP
│ │ SessionStart/End hooks │ │ (works with bare `claude` too)
│ │ UserPromptSubmit hook (drains inbox)│ │
│ │ Stop hook (blocks on tasks) │ │
│ │ MCP server: claudemesh │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ claudemesh daemon │ │
│ │ HTTP @ ~/.claudemesh/ │ │
│ │ daemon.sock (or TCP) │ │
│ │ • registry + idle flag │ │
│ │ • inboxes │ │
│ │ • events long-poll │ │
│ │ • liveness sweep │ │
│ └─────────────────────────┘ │
└────────────────────────────────────────────┘The daemon listens on a 0600 unix socket in ~/.claudemesh/ by default (TCP on loopback is opt-in, see Configuration). State persists to ~/.claudemesh/.
Realtime via the supervisor
claudemesh spawns claude inside a PTY (via node-pty) and proxies your terminal byte-for-byte to/from it — so it looks and feels exactly like running claude directly. Any flag you pass to claudemesh (e.g. claudemesh -c, claudemesh --resume <id>) is forwarded to claude. In parallel the supervisor long-polls the daemon for new inbox messages addressed to this session. When one arrives:
- Wait for the daemon's
idleflag on this session to betrue(theStophook sets it when claude finishes a turn; theUserPromptSubmithook clears it). - Wait for ≥500ms of stdin silence and no half-typed draft (so we don't corrupt or submit your typing).
- Check the inbox still holds a
task(a hook may have delivered it first; notes alone never wake). - Drain the inbox and paste the messages into the PTY as one bracketed paste, each prefixed with
[claudemesh message from <id> (<folder>) — reply with send_message to "<id>"], then Enter.
The message is the prompt: claude treats it exactly like something you typed, the attribution line tells it who to answer, and UserPromptSubmit finds an empty inbox so nothing is duplicated. Multi-line bodies are safe because the whole thing goes through in a single paste. Measured end to end on a real session: ~250ms from send to the text landing in claude's input, and claude starting work immediately after.
Set "inject_mode": "sentinel" in config.json to get the older behaviour instead: the supervisor types [inbox] and the UserPromptSubmit hook attaches the messages as additional context.
Without the supervisor (bare claude)
If you keep running claude directly, the inbox + hooks still do most of the work:
- Messages still queue in the inbox.
- Status line still shows
✉N. - The
Stophook still blocks on pending tasks if it happens to fire while messages are queued. UserPromptSubmitstill drains messages into the next turn.
What you lose is the wake-up for fully-idle claude: messages that arrive after Stop has already fired sit in the inbox until you submit any prompt. With the supervisor, that gap goes away.
Status line
[abc12345 · 3 peers · ✉2]abc12345— this session's ID.3 peers— count of other live Claudes (drops out at zero).✉2— unread inbox messages (drops out at zero).
Install
Requires Node ≥ 20. The supervisor needs
node-pty(native, prebuilt for darwin/linux/win).
npm i -g claudemesh
claudemesh installclaudemesh install patches two files (each backed up first):
~/.claude/settings.json— adds the four hooks and the status-line script.~/.claude.json— adds theclaudemeshMCP server undermcpServers.
After this, just use claudemesh everywhere you'd type claude:
claudemesh # interactive session in the supervisor
claudemesh -c # resume the most recent session
claudemesh --resume xyz # resume a specific session idThe original claude binary is untouched — both work side by side. Use claudemesh when you want realtime inbox delivery; use claude when you don't (you still get the inbox + ✉ status line, just no auto-wake).
If you'd rather invoke the supervisor under a shorter name, drop a tiny script anywhere on your PATH:
mkdir -p ~/.local/bin
cat > ~/.local/bin/clc <<'EOF'
#!/bin/sh
exec claudemesh "$@"
EOF
chmod +x ~/.local/bin/clcNow clc -c is claudemesh -c is claude -c-with-supervisor.
claudemesh uninstall # reverses the installIf node-pty fails to install (older toolchain), bare claudemesh errors out with a fix-it message but the subcommands (claudemesh list, claudemesh send, etc.) still work.
Adding the badge to an existing status line
If you already have a custom statusLine script in ~/.claude/settings.json, claudemesh install detects it and leaves it alone rather than clobbering your customizations. To add the [abc12345 · 3 peers · ✉2] badge yourself, capture stdin once and pipe it to claudemesh statusline:
#!/bin/bash
input=$(cat)
# ... your existing parts ...
out="$host | $model | $ctx | $folder"
# Claudemesh badge (only renders if claudemesh is installed and a session is registered)
if command -v claudemesh >/dev/null 2>&1; then
badge=$(printf '%s' "$input" | claudemesh statusline 2>/dev/null)
[ -n "$badge" ] && out="$out | $badge"
fi
printf '%s\n' "$out"claudemesh statusline reads the same JSON Claude Code already pipes to your script (it needs session_id), prints [id · peers · ✉N], and exits silently with no output if the daemon is down or the session isn't registered yet.
CLI
claudemesh [claude args...] launch claude inside the realtime supervisor
(e.g. `claudemesh -c`, `claudemesh --resume <id>`)
claudemesh list show all live Claude sessions
claudemesh whoami the claude session this shell runs under (walks the process tree)
claudemesh send <target> <message> send a task message; target = id or folder name
(from = your own session when run inside claude's Bash tool)
claudemesh history <target> [-n N] tail another session's transcript
claudemesh search <target> <query> search another session's transcript
claudemesh status daemon health + instance count
claudemesh install / uninstall patch / unpatch ~/.claude config
claudemesh help show subcommand help
claudemesh run [claude args...] explicit form of the default supervisor launch
claudemesh daemon / mcp / hook / statusline internal (used by Claude Code)Anything that's not a recognized subcommand from this list is treated as args for the supervisor and forwarded to claude — that's how claudemesh -c and friends work.
CLAUDEMESH_CLAUDE_BIN overrides which binary the supervisor launches (default: claude on PATH). Useful if you have multiple Claude Code builds installed.
Addressing sessions: id or folder name
Anywhere a target is expected — CLI commands and MCP tools alike — you can pass either the 8-char claude_id or the basename of that session's working directory. The resolver:
- Tries an exact
claude_idmatch against the live registry. - Falls back to a case-insensitive match on
basename(cwd).
# These two are equivalent when one live session is running in /Users/me/dev/api-service
claudemesh send 7k3p9q2x "rerun the migration check"
claudemesh send api-service "rerun the migration check"If the folder name matches more than one live session (e.g. ~/orgA/api and ~/orgB/api both running), the resolver errors out with both claude_ids and full paths so you can disambiguate by id. If nothing matches, it prints all live sessions.
MCP tools
The claudemesh MCP server exposes the following inside every claude session. Wherever a tool takes a target session, you can pass either an 8-char claude_id or a folder basename (see Addressing sessions).
| Tool | Purpose |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| whoami | Your own claude_id, cwd, started_at. |
| list_instances | All live sessions: id, cwd, last_active, pending count, idle. |
| send_message(to, body, kind?) | Send to another session. kind: "task" (default) wakes the recipient via the supervisor / Stop hook; kind: "note" is silent until next prompt. |
| read_inbox(drain?) | Pull pending messages for the current session (defaults to draining). |
| read_history(id, last_n_turns?, redact?) | Recent turns from another session, redacted by default; pass redact: false for raw. |
| search_history(id, query, options?) | Substring/regex search over another session's transcript with surrounding context. |
| get_turn(id, turn_index) | Pinpoint full-content fetch of one turn after read_history / search_history flagged it. |
Message delivery
Two kinds:
- task (default) — receiver acts on the body as if you typed it. Three delivery paths, in priority order:
- Supervisor injection (realtime) — if the receiver was launched via
claudemesh, the supervisor pastes the message into the PTY the moment it arrives and the receiver is idle, not typing, and has no pending draft. Sub-second wake-up. - Stop hook (between turns) — if the receiver is just finishing a turn when the message lands, the
Stophook blocks the stop and feeds the message in as the continuation directive. - UserPromptSubmit (next prompt) — if neither of the above caught it (bare
claude, fully idle), the message sits in the inbox until the user submits any prompt to that receiver, at which point it's appended as additional context for that turn.
- Supervisor injection (realtime) — if the receiver was launched via
- note — surfaces only via path #3 (next prompt), or rides along when a task wakes the session. Doesn't block stops, doesn't wake the model.
Inbox is at-least-once and drained on read.
Desktop notification fallback. When a task lands in a session that is idle and has no supervisor attached (bare claude), nothing can wake the model, so the daemon posts a desktop notification (osascript on macOS, notify-send on Linux) naming the target folder and the sender. Disable with "notify": false.
Idle tracking
The daemon stores an idle: boolean per session, kept in sync by the hooks:
SessionStart(or first register) →idle: falseUserPromptSubmit(user just submitted) →idle: falseStop, when allowing the stop (no inbox tasks pending) →idle: true
The supervisor reads this flag before injecting, ensuring it never types into a session that's mid-turn.
Configuration
~/.claudemesh/config.json (all keys optional):
{
"transport": "unix",
"socket_path": "~/.claudemesh/daemon.sock",
"host": "127.0.0.1",
"port": 7878,
"token": null,
"inject_mode": "body",
"notify": true,
"redact": { "enabled": true, "line_byte_cap": 4096 },
"statusline": { "show_peers": true, "show_inbox": true }
}transport:"unix"(default on macOS/Linux) or"tcp"(default on Windows). Switching restarts the daemon on the next command.inject_mode:"body"(default) pastes the message text into claude;"sentinel"types[inbox]and lets the hook attach the messages as context.notify: desktop notification for tasks that reach an idle, unsupervised session (defaulttrue).token: shared secret sent as a bearer header. Required whenhostis not loopback. Cross-machine use is not supported yet; docs/transport.md explains what is missing.CLAUDEMESH_HOMEenv var relocates the whole state directory (used by the tests).
Privacy & security
- Daemon listens on a per-user
0600unix socket by default, so other users on the same machine cannot reach it. TCP is opt-in; binding a non-loopback address is refused unless a sharedtokenis configured. See docs/transport.md. claude_idis stable per claude process:/clear(new session_id, same pid) keeps the id, so peers' addresses and pending inbox messages survive it.- The daemon reports its version on
/healthz; a CLI/daemon version mismatch after an upgrade restarts the daemon automatically. read_historyredacts large tool results / file contents above a per-line byte cap by default. Passredact: false(or useget_turn) for raw.- Hooks fail open: any daemon error is logged and the hook exits 0. Claude Code keeps working with no claudemesh integration if the daemon is down.
- The supervisor never writes to the PTY unless (a) the idle flag is set, (b) the inbox is still non-empty (a hook may have delivered the message first), (c) ≥500ms of stdin silence, and (d) no half-typed draft is pending (printable keys since the last Enter / ctrl-c / ctrl-u). Escape sequences such as arrow keys never count as a draft.
Development
git clone [email protected]:ktamas77/claudemesh.git
cd claudemesh
npm install
npm run build # tsc → dist/ (also chmod +x the bin)
npm run dev # tsc --watch
npm run lint # eslint
npm run format # prettier --write
npm run typecheck # tsc --noEmit
npm test # vitest run (unit + a real daemon over a unix socket in a temp dir)
node scripts/smoke.mjs # end-to-end through dist/: daemon spawn, hooks, statusline, /clearPre-commit (husky) runs typecheck → test → lint-staged. A broken type or failing test blocks the commit.
Layout
src/
├── bin/claudemesh.ts # entrypoint, dispatches all subcommands
├── cli/
│ ├── run.ts # PTY supervisor (default `claudemesh` entrypoint)
│ ├── inject.ts # pure "may we type into claude now?" decision + paste rendering (tested)
│ ├── send.ts, history.ts, search.ts, list.ts, whoami.ts, status.ts
│ ├── install.ts, statusline.ts
│ └── ...
├── daemon/
│ ├── server.ts # HTTP routes incl. /events long-poll, PATCH idle; unix socket or TCP
│ ├── registry.ts # id ↔ session ↔ pid ↔ cwd, idle flag
│ ├── inbox.ts # per-recipient jsonl
│ ├── waiters.ts # pub-sub for the events long-poll (+ "is this session supervised?")
│ ├── notify.ts # desktop notification fallback for idle, unsupervised sessions
│ └── liveness.ts # prune dead PIDs
├── mcp/server.ts # stdio MCP server
├── hooks/ # session-start, session-end, user-prompt-submit, stop
└── shared/ # paths, ids, http client, transcript reader, resolver, process-tree self-lookupRoadmap
- [x] Architecture & repo scaffold (TS + ESLint + Prettier + Husky)
- [x] Phase 1 — daemon + register/list + status line
- [x] Phase 2 — MCP tools (
whoami,list_instances,send_message,read_inbox) - [x] Phase 3 — task-mode wake-up,
read_history/search_history/get_turn - [x] Phase 4 — liveness sweep + lazy daemon autostart
- [x] Phase 5 — folder-name addressing, sharper imperative inbox prompts
- [x] Phase 6 —
claudemeshPTY supervisor for realtime injection (with subcommand passthrough) - [x] Unix-socket transport by default, TCP opt-in, bearer token for non-loopback, daemon version check on upgrade
- [x] Process-stable
claude_idacross/clear; supervisor skips injection when a hook already delivered, and never submits a half-typed draft - [ ] Real-world soak across many concurrent supervisor sessions
- [ ] Network mode: heartbeat-based liveness + remote transcript access (see docs/transport.md)
- [ ] Publish 0.2.0 to npm (registry is still at 0.1.1)
- [x] Reply chains:
fromis the sender's own session in both the MCP tool and the CLI (which walks the process tree from inside claude's Bash tool); pasted messages carry areply with send_message to "<id>"line - [x] Desktop notification fallback for un-supervised idle sessions (
notify) - [x] Inject the message body itself instead of an
[inbox]sentinel — bracketed paste, sender attribution,inject_mode: "sentinel"kept as fallback. Verified live: ~250ms send→paste, model acts on it - [x] Notes never wake a session on their own (matches the contract above; previously they did)
Why not tmux?
tmux's send-keys / list-panes could replace the daemon and the PTY supervisor outright. We considered it and kept the current design because it would require every session to run inside tmux, and the parts tmux cannot replace (idle hooks, transcript reader) are the ones that matter for safety. Full pro/con in docs/why-not-tmux.md.
License
MIT © Tamas Kalman
