Skip to main content
Glama
exalsch

claude-session-bus

by exalsch
README.md
# claude-session-bus

A tiny Claude Code **plugin** that lets separate Claude sessions working the same
project pass each other short messages: "holding the dev server on :1420", "editing
dynamics.rs", "branch X pushed, safe to rebase".

- **Send** = an MCP tool (`broadcast`) the calling session invokes.
- **Receive** = a `UserPromptSubmit` hook that prepends new peer messages to the other
  session's context before its next turn.
- **Transport** = append-only JSONL mailboxes, one per scope.

Broadcast-to-all, one-way, fire-and-forget. Advisory: it announces, it does not enforce.
The sender picks how far each message travels - the same project, a group of projects,
or every session on the machine.

Full design: [`docs/specs/2026-06-29-claude-session-bus-design.md`](docs/specs/2026-06-29-claude-session-bus-design.md).

## Layout

```
claude-session-bus/
├── .claude-plugin/
│   ├── plugin.json         # plugin manifest: hooks + mcpServers
│   └── marketplace.json    # lets you add this dir as a local marketplace
├── server/index.mjs        # MCP stdio server: broadcast(), inbox(), set_group()
├── scripts/drain.mjs       # UserPromptSubmit hook (the receive half)
├── scripts/selftest.mjs    # offline smoke test of the mailbox primitives
├── lib/bus.mjs             # shared: boxes, group registry, identity, locking, message shape
├── hooks/hooks.json        # declares the UserPromptSubmit hook
└── package.json            # type:module, no runtime deps
```

## Scopes

Every `broadcast` takes a `scope`. Each scope is a separate mailbox with its own
sequence numbers and per-session cursors, so the feeds never interfere:

| scope | reaches | mailbox |
|---|---|---|
| `project` (default) | sessions in this project, git worktrees included | `<repo root>/.claude/.session-bus/` |
| `group` | sessions in every project that joined this project's group | `~/.claude/.session-bus/groups/<name>/` |
| `global` | every Claude Code session on this machine | `~/.claude/.session-bus/global/` |

**Git worktrees share the main checkout's box.** A session in
`<repo>/.claude/worktrees/feat-x` resolves to `<repo>` via `git rev-parse
--git-common-dir`, so it reaches the main checkout and every sibling worktree at plain
`project` scope, and its group membership is inherited rather than needing a `set_group`
call per throwaway directory. Working from a subdirectory of a repo resolves the same
way. A directory that is not a git repo keeps its own box. The per-message label still
comes from the directory the session actually works in, so worktrees are told apart by
their branch:

```
- 14:32 main#a1b2c3d4: pushed the release branch
- 14:33 feat/entry-field-visibility#9f8e7d6c [lock]: rebasing, hold off on app/
```

Anything wider than `project` is tagged with its scope and the project it came from,
since the reader is sitting in a different repo:

```
- 14:32 master#a1b2c3d4: editing dynamics.rs
- 14:33 [group] my-app/feat-auth#9f8e7d6c [lock]: holding :1420
- 14:35 [global] infra-scripts/master#5a4b3c2d: rebooting the NAS
```

**Groups** are opt-in and named by you, one per project. The first time a session tries
to send at group scope from an ungrouped project, the tool refuses and tells the session
to ask you which group the project belongs to (offering the names that already exist);
it then calls `set_group` with your answer. Membership lives machine-wide in
`~/.claude/.session-bus/projects.json` - nothing about the bus is written into your repo.

```
you>     tell the other repos the API contract changed
claude>  broadcast(..., scope="group")
         -> refused: no group set for C:/PROGRAMMING/my-app.
            Existing groups: infra. Ask the user, then call set_group.
claude>  This project isn't in a session-bus group yet. "infra" exists -
         join it, or name a new one?
you>     tauri-work
claude>  set_group("tauri-work") -> joined. broadcast resent.
```

## Quick start

1. **Clone the repo:**

   ```
   git clone https://github.com/exalsch/claude-session-bus.git
   cd claude-session-bus
   ```

2. **Smoke-test the primitives** (no Claude needed):

   ```
   node scripts/selftest.mjs    # expect: selftest OK - { ... }
   ```

3. **Install as a local plugin**, then enable it:

   ```
   /plugin marketplace add /path/to/claude-session-bus    # the directory you just cloned
   /plugin install claude-session-bus@session-bus-dev
   ```

   Restart Claude Code so the MCP server + hook load. Verify the `broadcast` and
   `inbox` tools appear, and that the `session-bus` MCP server is connected.

4. **Use it.** From any session:

   - `broadcast("holding the dev server on :1420", kind="lock")` - this project.
   - `broadcast("api contract changed", scope="group")` - every project in this
     project's group (it will ask you to name one the first time).
   - `broadcast("rebooting the NAS", scope="global")` - every session on the machine.
   - `inbox()` to re-read peer messages on demand, across all three scopes.

   Receiving sessions see the message prepended to their context the next time you
   submit a prompt there.

## End-to-end test

Open **two** terminals, both `cd` into the *same* project (e.g. `~/projects/your-app`):

1. In session A: ask it to `broadcast("test from A")`.
2. In session B: submit any prompt. B should report a `[session-bus]` line from A.
3. In session A: submit any prompt. A should **not** see its own message (origin filter).

If A *does* see its own message, the `ppid` correlation did not hold (see Risks in the
spec) - the fallback is to render own posts tagged "(you)" instead of filtering them.

For the wider scopes, open a third terminal in a **different** project:

4. In session C: ask it to `broadcast("test from C", scope="global")`. A and B should
   see it on their next prompt, tagged `[global] <project>/<branch>`.
5. In session C: ask it to broadcast at `scope="group"`. It should stop and ask which
   group the project belongs to. Answer, then do the same in A. Once both projects name
   the same group, group messages flow between them and no further asking happens.

## Config

- `SESSION_BUS_BACKFILL_MIN` (default `0`) - minutes of backlog a session sees on its
  first prompt. `0` = forward-only (a new session only sees what arrives after it joins).
  Applies per scope.
- `SESSION_BUS_RECEIVE` (default `project,group,global`) - which scopes reach this
  session. Set it to `project` to go back to v0.1 behaviour, or `project,group` to stay
  out of the machine-wide feed.
- `SESSION_BUS_HOME` (default `CLAUDE_CONFIG_DIR`, else `~/.claude`) - where the group
  and global mailboxes and the group registry live. Mainly for tests and for running two
  isolated buses side by side.
- Retention (`MAX_LINES`, `MAX_AGE_MS`) lives in `lib/bus.mjs`.

## Status & next steps

v0.2 adds the three scopes above. v0.1 behaviour is the default: a `broadcast` with no
`scope` still goes to the project mailbox and nothing else, and existing project
mailboxes and cursors keep working untouched.

The v0.1 headline risk - **`ppid` correlation on Windows** - was tested and did **not**
hold: Claude Code spawns the `UserPromptSubmit` hook through a shell, so the hook's
parent PID is a transient shell, never the directly-spawned MCP server's PID. Sessions
correlate on the **session id** instead.

`CLAUDE_CODE_SESSION_ID` alone turned out to be insufficient (fixed in v0.2.1). That
variable is frozen when a process is spawned, and the MCP server is long-lived: across
a resume or a plugin reload its copy goes stale, and a stale sender id means a session
no longer recognises its own posts and receives them back. Observed live - a server
stamping `292becbc…` while its own hook half was `c0430fa1…`, with `292becbc…`
belonging to no session at all. `originId()` now reads Claude Code's live process
registry at `<claude-home>/sessions/<pid>.json` first, keyed by the caller's parent
pid: the MCP server's parent is `claude.exe`, so it gets the *current* id, while the
hook's parent is a transient shell with no entry and keeps resolving by env var, which
for the hook is accurate. Both halves land on the same id, and the env var remains the
fallback. Covered by `scripts/test-origin.mjs` (unit) and `scripts/test-drain.mjs`
(integration); `npm test` runs the full suite.

## Cost of the receive hook

The hook runs on **every prompt** and blocks it, under a 10s timeout, so its budget is
the one number worth watching. v0.2.3 removed the last subprocess from that path.

Both halves used to shell out to git - `rev-parse --git-common-dir` to find the repo
family, `rev-parse --abbrev-ref HEAD` for the branch label. On Windows a process launch
is scanned, so those metadata lookups were priced like program launches. git writes all
of it to disk anyway (`.git`, the `gitdir:` pointer in a linked worktree, and
`<gitdir>/commondir`), so the bus reads the files directly. Measured on the dev machine:

| | before | after |
|---|---|---|
| resolve the project root | 229ms | **0.5ms** |
| in-process work, whole hook | 249ms | **13ms** |
| end to end, one hook | 318ms | **152ms** |
| end to end, 16 hooks at once | 910ms | **418ms** |

What remains is node's own startup plus ~13ms of file IO, and nothing in the receive path
spawns a process - so the bus now also works where git is not on PATH at all
(`scripts/test-nogit.mjs` proves it by resolving with `PATH` stripped, checked against
real git's answers for a checkout, a subdirectory, a linked worktree, a bare repo and a
non-repo).

**A timeout no longer costs you messages.** Claude Code discards the output of a hook
that overruns, and the hook used to advance its cursors before writing that output - so
each timeout silently ate the peer messages it had just consumed. Cursors are now
committed only after the payload is delivered, making the worst case a message shown
twice rather than one lost.

Still out of scope: multiple groups per project, directed project-to-project addressing,
group discovery beyond the registry, and any reply path.

This is dev tooling - it must never live inside a product repo.