10 Commits

Author SHA1 Message Date
Mika Kuns
43e8d0d4ca fix(ci): add pretest hook so vitest sees built dist/cli.js
All checks were successful
CI (Node) / build-test (push) Successful in 8s
Release / release (push) Successful in 8s
Release (Node) / release (push) Successful in 13s
2026-05-19 13:12:59 +02:00
Mika Kuns
50f2b5a7cb docs(readme): restructure for getting-started flow
Some checks failed
CI (Node) / build-test (push) Failing after 5s
Release (Node) / release (push) Failing after 5s
Release / release (push) Successful in 7s
2026-05-19 12:56:27 +02:00
Mika Kuns
19d7a591df chore(node): release v0.1.0 2026-05-19 12:01:41 +02:00
Mika Kuns
48b6ba6452 feat(plugin): SessionStart hook discovers and announces active peers
session-announce now calls /v1/list with the session's X-Mailbox header,
which both registers the session with the daemon and returns all known
mailboxes in one round-trip. The output appends an "Active peers" block
listing mailboxes seen within the last hour (configurable via
--peer-window-minutes), capped at 10 entries by default. Self is
filtered out; the list is sorted most-recent-first.

So when the user says "I started a second session, coordinate with it",
Claude already has the peer's mailbox name in context — no manual
list_mailboxes call needed.

The peer-formatting logic is extracted into formatActivePeerList for
unit testing; CLI tests now pin --url to an unreachable port to keep
assertions stable on machines that have a real daemon running.
2026-05-19 11:59:31 +02:00
Mika Kuns
9fd321043f feat(mcp): identity-via-arg + plugin ships .mcp.json
MCP tools (send/check_inbox/peek_inbox/list_mailboxes) now accept the
caller's mailbox name as an explicit argument (from/name), falling back
to the X-Mailbox header for legacy single-session HTTP setups. This
unblocks multi-session coordination through a shared .mcp.json — each
Claude session passes its own session-derived name on every call,
instead of relying on a single transport header that all sessions
would share.

The plugin now ships .mcp.json (no header), and the SessionStart
announcement spells out the exact args to pass to each mcp__mailbox__*
tool so Claude wires it up automatically.
2026-05-19 11:50:58 +02:00
Mika Kuns
462d6561e1 feat(plugin): per-session mailbox identity + mailbox-update command
The hook now derives a unique mailbox name from the session_id supplied
on hook stdin, so two parallel Claude Code sessions in the same project
get distinct mailboxes (e.g. `claude-a8b3c1d2`, `claude-d4e5f6a7`)
instead of colliding on a shared env value. An optional
CLAUDE_MAILBOX_NAME base prefix flavors the names as `<base>-<sid>`.

Adds:
- `claude-mailbox session-announce` subcommand for the new SessionStart
  hook, which prints the current session's mailbox name to context
- `/claude-mailbox:mailbox-update` slash command for `npm update` +
  daemon restart
- stdin parsing helpers (parseHookStdin, deriveSessionName) with unit
  tests; the doctor no longer needs a mandatory name prompt
2026-05-19 11:39:14 +02:00
Mika Kuns
c231f8c18c feat(plugin): add /claude-mailbox:mailbox-doctor for one-command setup
The doctor command runs entirely inside Claude Code and walks through:
binary install via npm, daemon autostart, mailbox-name prompt with write
to per-project `.claude/settings.json` env, and a self → self smoke test.
Also adds `/claude-mailbox:mailbox-status` as a read-only health check.

Reduces the colleague onboarding to: add marketplace, install plugin,
run the doctor — no terminal context-switch required.
2026-05-19 10:55:05 +02:00
Mika Kuns
5c5843e62d feat(plugin): ship Claude Code plugin + marketplace manifest
Adds a /plugin marketplace at the repo root and a `claude-mailbox` plugin under
plugin/ that wires the UserPromptSubmit hook without needing the per-user
`install-hook` step. The hook command (`claude-mailbox check --hook`) now reads
the mailbox name from $CLAUDE_MAILBOX_NAME when --name is omitted and emits a
one-line setup hint when the daemon is unreachable, so a missing daemon is loud
instead of invisible.

The plugin only contains the Claude Code glue — the daemon binary is still a
separate prerequisite (`npm i -g @kuns/claude-mailbox` + install-autostart),
and the plugin/README plus main README spell out the three-step setup.
2026-05-19 10:49:36 +02:00
Mika Kuns
66967167bc feat(node): add Claude Code UserPromptSubmit hook for auto inbox-check
Adds `install-hook` / `uninstall-hook` subcommands that idempotently patch
~/.claude/settings.json (or .claude/settings.json with --project), plus a
`--hook` flag on `check` that emits human-readable output and stays silent
on empty inbox or unreachable daemon.
2026-05-19 10:09:30 +02:00
a5a2895725 fix(ci): use NPM_PUBLISH_TOKEN for Gitea npm registry auth
All checks were successful
Release / release (push) Successful in 7s
Release (Node) / release (push) Successful in 10s
The auto-generated secrets.GITEA_TOKEN lacks write:package scope,
causing npm publish to fail with E401. Use a dedicated repo secret
NPM_PUBLISH_TOKEN with a personal access token that has write:package.
2026-04-30 12:20:51 +00:00
19 changed files with 1514 additions and 139 deletions

View File

@@ -0,0 +1,14 @@
{
"name": "claude-mailbox",
"owner": {
"name": "Mika Kuns"
},
"description": "Plugins for the Claude-Mailbox project.",
"plugins": [
{
"name": "claude-mailbox",
"source": "./plugin",
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and injects pending messages."
}
]
}

View File

@@ -58,9 +58,13 @@ jobs:
- name: Configure npm auth for Gitea
env:
NPM_TOKEN: ${{ secrets.GITEA_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
run: |
set -euo pipefail
if [ -z "${NPM_TOKEN:-}" ]; then
echo "::error::NPM_PUBLISH_TOKEN secret is not set (needs Gitea token with write:package scope)" >&2
exit 1
fi
echo "@kuns:registry=https://${NPM_REGISTRY_HOST}" > .npmrc
echo "//${NPM_REGISTRY_HOST}:_authToken=${NPM_TOKEN}" >> .npmrc

259
README.md
View File

@@ -1,41 +1,60 @@
# ClaudeMailbox
A standalone MCP mail server that lets parallel Claude sessions coordinate with each other. Any Claude session (plain terminal, ClaudeDo worktree, anything that consumes `.mcp.json`) can send messages to a peer session's inbox, check for pending messages, and discover other active mailboxes.
A standalone MCP mail server that lets parallel Claude sessions coordinate with each other. Messages are queued in a tiny SQLite database via a local HTTP daemon. Any Claude session — Claude Code, ClaudeDo worktree, plain MCP client — can send to a peer's inbox, check for pending messages, and discover other active mailboxes.
Not a substitute for `run_in_background: true` — that handles single-session responsiveness. This handles **session-to-session** coordination.
Not a substitute for `run_in_background: true` (which handles single-session responsiveness). This handles **session-to-session** coordination.
## Architecture
---
One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` and a small REST API at `/v1/*`, and persists state in a single SQLite file. Sessions declare themselves via an `X-Mailbox` header in their `.mcp.json`.
## Getting started
Pick one path. Most users want path A.
### A. Claude Code plugin (recommended — three prompts)
Inside Claude Code:
```
session-backend session-frontend external sender
(X-Mailbox: backend) (X-Mailbox: frontend) (CLI / UI / hook)
| | |
| HTTP | |
+--------------+-----------------+--------------------------+
v
claude-mailbox serve (ASP.NET Core + Kestrel)
/mcp MCP tools
/v1/* REST for non-MCP senders
/health
v
~/.claude-mailbox/mailbox.db (SQLite WAL)
/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox
/plugin install claude-mailbox@claude-mailbox
/claude-mailbox:mailbox-doctor
```
## Install
The doctor command does the rest:
The recommended path is the npm package — it works on Windows, macOS, and Linux.
1. installs the daemon binary via `npm install -g @kuns/claude-mailbox` if missing (asks first)
2. registers the daemon for autostart and starts it
3. optionally lets you pick a base prefix (e.g. `backend`, `frontend`); without one, mailbox names are anonymous (`claude-a8b3c1d2`)
4. runs a self → self smoke test
After that, every Claude Code session automatically:
- gets a **unique mailbox identity** derived from its session UUID (so two parallel sessions never collide),
- announces that identity and the **list of currently active peers** at session start,
- pulls unread mailbox messages into context before every prompt.
You can then say things like:
> "I started a second session, coordinate with it on the refactor."
Claude already has the peer's mailbox name in context from the SessionStart announcement, so it calls `mcp__mailbox__send(from="<my-name>", to="<peer>", body="...")` directly.
See [`plugin/README.md`](./plugin/README.md) for the full walkthrough, including the `mailbox-status` and `mailbox-update` slash commands.
### B. Manual install (no Claude Code plugin)
If you're using a different MCP client, scripts, or you don't want the plugin:
```sh
# one-time per machine: point the @kuns scope at the public Gitea npm registry
npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
# install
# install + autostart
npm install -g @kuns/claude-mailbox
claude-mailbox install-autostart
```
Or use the bootstrap one-liner:
Or the bootstrap one-liner:
```powershell
# Windows
@@ -47,17 +66,61 @@ irm https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.ps1 | ie
curl -fsSL https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.sh | sh
```
macOS users can also install via Homebrew once the tap is published:
Then drop this into your project's `.mcp.json`:
```sh
brew install kuns/tap/claude-mailbox
```json
{
"mcpServers": {
"mailbox": {
"type": "http",
"url": "http://127.0.0.1:47822/mcp"
}
}
}
```
### Autostart
Optionally add a static identity (so your client doesn't need to pass `from` / `name` on every call):
```json
"headers": { "X-Mailbox": "backend" }
```
### C. Build the .NET binary from source
The original .NET 8 implementation lives in `src/ClaudeMailbox/`. Wire-compatible with the npm build (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema).
```powershell
dotnet publish src/ClaudeMailbox -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
```
Put the resulting `claude-mailbox.exe` on `PATH`. Windows-only `install-service` verbs (admin shell):
```
claude-mailbox install-service [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
claude-mailbox uninstall-service [--purge]
```
---
## How identity works
Every Claude Code session gets a unique mailbox name derived from its UUID:
| Setup | Resulting mailbox name |
|---|---|
| Default | `claude-<8-hex-of-session-id>` |
| `CLAUDE_MAILBOX_NAME=backend` (in `.claude/settings.json` env) | `backend-<8-hex>` |
| Manual `.mcp.json` with `X-Mailbox: backend` header (no plugin) | `backend` (legacy mode) |
The plugin's `SessionStart` hook prints the session's identity and the list of peers active in the last hour into the conversation context, so Claude knows who it is and who's around without needing to call any tools first.
---
## Autostart
```sh
claude-mailbox install-autostart # per-user, no admin
claude-mailbox install-autostart --service # Windows only: register as a Windows Service (admin)
claude-mailbox install-autostart --service # Windows only: Windows Service (admin)
claude-mailbox status # Running | Stopped | NotInstalled
claude-mailbox uninstall-autostart [--purge]
```
@@ -68,68 +131,18 @@ claude-mailbox uninstall-autostart [--purge]
| macOS | launchd LaunchAgent in `~/Library/LaunchAgents/` | n/a |
| Linux | systemd `--user` unit in `~/.config/systemd/user/` | n/a |
### Config precedence
---
```
CLI flag > mailbox.json > built-in defaults
```
## MCP tools
`mailbox.json` is searched at `~/.claude-mailbox/mailbox.json` (per-user), and on Windows additionally at `%ProgramData%\ClaudeMailbox\mailbox.json` (machine-wide, written by `--service` install). Pass `--config <path>` to override.
| Tool | Required args | Purpose |
|---|---|---|
| `mcp__mailbox__send` | `to`, `body`, `from` | Send a message. `from` falls back to X-Mailbox header. |
| `mcp__mailbox__check_inbox` | `name` | Pull all pending messages and mark delivered. Falls back to header. |
| `mcp__mailbox__peek_inbox` | `name` | Non-consuming `{ pending, oldestAt }`. Falls back to header. |
| `mcp__mailbox__list_mailboxes` | `name` | Discover known mailboxes + `pendingForYou`. Falls back to header. |
Defaults: port `47822`, bind `127.0.0.1`, database at `~/.claude-mailbox/mailbox.db`.
### Smoke test
```sh
claude-mailbox install-autostart
claude-mailbox status
curl http://127.0.0.1:47822/health
claude-mailbox uninstall-autostart --purge
```
### Build the .NET binary (alternative)
The original .NET 8 implementation still lives in `src/ClaudeMailbox/`. Build a self-contained Windows exe with:
```powershell
dotnet publish src/ClaudeMailbox -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
```
Put the resulting `claude-mailbox.exe` on your `PATH` and use the legacy `install-service` verbs (Windows-only, admin shell):
```
claude-mailbox install-service [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
claude-mailbox uninstall-service [--purge]
```
The .NET and Node builds are wire-compatible (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema), so a `.mcp.json` configured against one works against the other.
## Use from a Claude session
Drop this into your project's `.mcp.json` (one per session, different `X-Mailbox` values):
```json
{
"mcpServers": {
"mailbox": {
"type": "http",
"url": "http://127.0.0.1:47822/mcp",
"headers": {
"X-Mailbox": "backend"
}
}
}
}
```
Four MCP tools are exposed:
| Tool | Purpose |
|---|---|
| `mcp__mailbox__send(to, body)` | Send a message to another mailbox |
| `mcp__mailbox__check_inbox()` | Pull all pending messages for this mailbox (marks delivered) |
| `mcp__mailbox__peek_inbox()` | Non-consuming check — returns `{ pending, oldestAt }` |
| `mcp__mailbox__list_mailboxes()` | Discover known mailboxes and who has mail for you |
The plugin's SessionStart announcement tells Claude exactly which name to pass for the current session, so the args are filled in automatically.
### Suggested CLAUDE.md snippet for poll discipline
@@ -139,41 +152,93 @@ after each subagent completes. If pending > 0, call mcp__mailbox__check_inbox
and treat the messages as input with priority over the current plan.
```
## CLI client mode
---
Any external process (scripts, UIs, hooks) can talk to a running daemon without needing MCP:
## CLI
Any external process — scripts, UIs, manual debugging — can talk to a running daemon directly:
```
claude-mailbox send --to <mailbox> --from <mailbox> --body <text> [--url http://127.0.0.1:47822]
claude-mailbox peek --name <mailbox> [--url ...]
claude-mailbox check --name <mailbox> [--url ...]
claude-mailbox list [--url ...]
claude-mailbox send --from <mailbox> --to <mailbox> --body <text>
claude-mailbox peek --name <mailbox>
claude-mailbox check --name <mailbox> [--hook]
claude-mailbox list
claude-mailbox status
claude-mailbox session-announce # hook helper, reads stdin JSON
claude-mailbox install-hook --name <mailbox> [--user|--project]
claude-mailbox uninstall-hook [--user|--project]
```
The CLI subcommands are thin HTTP clients against the `/v1/*` endpoints.
All subcommands accept `--url <url>` to target a non-default daemon address.
---
## REST surface
| Method | Path | Requires `X-Mailbox` | Purpose |
| Method | Path | `X-Mailbox` required | Purpose |
|---|---|---|---|
| `GET` | `/health` | no | `{ status, version, dbPath }` |
| `POST` | `/v1/send` | yes (sender) | `{ to, body }` |
| `POST` | `/v1/send` | yes (sender) | body: `{ to, body }` |
| `GET` | `/v1/peek?name=<mailbox>` | no | read-only status |
| `POST` | `/v1/check-inbox?name=<mailbox>` | yes (must match `name`) | consume inbox |
| `GET` | `/v1/list` | no | list all mailboxes |
| `GET` | `/v1/list` | optional (presence registers caller) | list all mailboxes |
---
## Config precedence
```
CLI flag > mailbox.json > built-in defaults
```
`mailbox.json` is searched at `~/.claude-mailbox/mailbox.json` (per-user), and on Windows additionally at `%ProgramData%\ClaudeMailbox\mailbox.json` (machine-wide, written by `--service` install). Override with `--config <path>`.
Defaults: port `47822`, bind `127.0.0.1`, database at `~/.claude-mailbox/mailbox.db`.
---
## Architecture
One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` and a small REST API at `/v1/*`, and persists state in a single SQLite file.
```
session-A session-B external sender
mailbox: claude-a8b3c1d2 mailbox: claude-d4e5f6a7 (CLI / UI / script)
| | |
| HTTP | |
+--------------+-----------------+--------------------------+
v
claude-mailbox serve (npm: Fastify; .NET: Kestrel)
/mcp MCP tools
/v1/* REST for non-MCP senders
/health
v
~/.claude-mailbox/mailbox.db (SQLite WAL)
```
---
## Development
```
```sh
# Node port (the recommended runtime)
cd node
npm install
npm run build
npm test
# .NET 8 port (wire-compatible alternative)
dotnet build
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj
dotnet run --project src/ClaudeMailbox -- serve
```
Test suite covers end-to-end coordination, concurrent `check_inbox` race safety, and schema idempotency.
The test suites cover end-to-end coordination, concurrent `check_inbox` race safety, schema idempotency, hook stdin parsing, session-id derivation, and settings-file patching.
---
## Scope
- Loopback bind only (v1). Cross-machine coordination is a future extension — swap the middleware for token auth and change the bind address.
- No auth on loopback. Local filesystem permissions are the trust boundary.
- No message expiry or cleanup. Delivered messages stay as a timeline/audit log.
- No message expiry. Delivered messages remain as an audit log.

View File

@@ -17,4 +17,36 @@ Then:
claude-mailbox install-autostart # registers per-OS autostart, no admin needed by default
```
See the repository [README](../README.md) for the full architecture, MCP tool reference, and `.mcp.json` snippet.
See the [repository README](https://git.kuns.dev/releases/ClaudeMailbox/src/branch/main/README.md) for the full architecture, MCP tool reference, and `.mcp.json` snippet.
## Claude Code hook (auto-check inbox)
Register a `UserPromptSubmit` hook so Claude pulls pending mailbox messages before every prompt:
```sh
claude-mailbox install-hook --name alice # patches ~/.claude/settings.json
claude-mailbox install-hook --name alice --project # patches <cwd>/.claude/settings.json
claude-mailbox uninstall-hook # remove again
```
The hook is idempotent (running `install-hook` twice does nothing the second time) and only touches the `UserPromptSubmit` block — other hooks and settings are preserved.
Under the hood the hook runs `claude-mailbox check --name <mailbox> --hook`, which:
- prints unread messages in a Claude-friendly format,
- silently exits 0 if the inbox is empty or the daemon is unreachable (no context noise),
- marks the messages delivered so they aren't injected again next prompt.
Cost: one local HTTP round-trip plus Node coldstart per prompt (~100ms on Windows).
## Troubleshooting
`npm install` returns `401 Unauthorized`
: The Gitea registry usually serves the `releases` scope publicly, but if your instance requires auth you'll need a read token:
```sh
npm config set //git.kuns.dev/api/packages/releases/npm/:_authToken=<token>
```
`gyp ERR! find VS` on Windows during install
: `better-sqlite3` ships prebuilt binaries for current Node LTS versions. If yours isn't covered, npm falls back to building from source and needs the Visual Studio Build Tools. Either install them or pin to a Node version with a matching prebuild.

View File

@@ -1,12 +1,12 @@
{
"name": "@kuns/claude-mailbox",
"version": "0.0.0",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@kuns/claude-mailbox",
"version": "0.0.0",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@kuns/claude-mailbox",
"version": "0.0.0",
"version": "0.1.0",
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
"type": "module",
"bin": {
@@ -13,6 +13,7 @@
],
"scripts": {
"build": "tsc -p tsconfig.json",
"pretest": "npm run build",
"test": "vitest run",
"test:watch": "vitest",
"start": "node dist/cli.js serve",

View File

@@ -1,11 +1,27 @@
#!/usr/bin/env node
import { Command } from "commander";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js";
import { startServer } from "./server.js";
import { autostartManager } from "./autostart/index.js";
import {
applyInstall,
applyUninstall,
buildHookCommand,
deriveSessionName,
formatActivePeerList,
formatMessagesForHook,
parseHookStdin,
readSettings,
readStdinIfPiped,
settingsPathFor,
writeSettings,
type HookMessage,
type HookScope,
type PeerEntry,
} from "./hook.js";
function readVersion(): string {
try {
@@ -114,24 +130,128 @@ program
}
});
function resolveHookMailboxName(explicit: string | undefined): string | null {
if (explicit && explicit.trim()) return explicit.trim();
const stdin = parseHookStdin(readStdinIfPiped());
const sid = stdin?.session_id?.trim();
if (sid) {
const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
return deriveSessionName(sid, base);
}
const envName = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim();
return envName || null;
}
program
.command("check")
.description("Pull pending messages and mark delivered.")
.requiredOption("--name <name>", "Mailbox name (also sent as X-Mailbox)")
.description(
"Pull pending messages and mark delivered. In --hook mode the name is auto-derived from the SessionStart/UserPromptSubmit stdin (session_id), optionally flavored by $CLAUDE_MAILBOX_NAME.",
)
.option(
"--name <name>",
"Explicit mailbox name. Overrides hook stdin and $CLAUDE_MAILBOX_NAME.",
)
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.action(async (opts: { name: string; url: string }) => {
.option(
"--hook",
"Hook mode: human-readable output, silent when no mailbox configured or daemon unreachable. Emits a one-line setup hint when name resolves but daemon is unreachable.",
)
.action(async (opts: { name?: string; url: string; hook?: boolean }) => {
const name = opts.hook
? resolveHookMailboxName(opts.name)
: (opts.name ?? process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
if (!name) {
if (opts.hook) return;
console.error("Missing --name (or set CLAUDE_MAILBOX_NAME).");
process.exit(1);
}
try {
const out = await callJson(
"POST",
`${opts.url}/v1/check-inbox?name=${encodeURIComponent(opts.name)}`,
{ headers: { "X-Mailbox": opts.name } },
`${opts.url}/v1/check-inbox?name=${encodeURIComponent(name)}`,
{ headers: { "X-Mailbox": name } },
);
if (opts.hook) {
const messages = (Array.isArray(out) ? out : []) as HookMessage[];
const text = formatMessagesForHook(name, messages);
if (text) process.stdout.write(text);
return;
}
console.log(JSON.stringify(out, null, 2));
} catch (err) {
if (opts.hook) {
const msg = err instanceof Error ? err.message : String(err);
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
process.stdout.write(
`[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.\n`,
);
}
return;
}
reportClientError(err, opts.url);
}
});
program
.command("session-announce")
.description(
"SessionStart-hook helper: derives the session's mailbox name from stdin session_id, registers it with the daemon, and announces the identity + currently active peers to context.",
)
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.option(
"--peer-window-minutes <minutes>",
"Only show peers seen within this many minutes (default 60)",
(v) => parseInt(v, 10),
60,
)
.option(
"--max-peers <n>",
"Maximum number of peers to list (default 10)",
(v) => parseInt(v, 10),
10,
)
.action(async (opts: { url: string; peerWindowMinutes: number; maxPeers: number }) => {
const stdin = parseHookStdin(readStdinIfPiped());
const sid = stdin?.session_id?.trim();
if (!sid) return;
const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
const name = deriveSessionName(sid, base);
const lines = [
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
`When using mcp__mailbox__* tools, ALWAYS pass this name explicitly:`,
` - mcp__mailbox__send: from="${name}"`,
` - mcp__mailbox__check_inbox: name="${name}"`,
` - mcp__mailbox__peek_inbox: name="${name}"`,
` - mcp__mailbox__list_mailboxes: name="${name}"`,
`Peers reach you with: mcp__mailbox__send(from="<their-name>", to="${name}", body="...")`,
];
try {
const out = await callJson("GET", `${opts.url}/v1/list`, {
headers: { "X-Mailbox": name },
});
const all = (Array.isArray(out) ? out : []) as PeerEntry[];
lines.push(
"",
...formatActivePeerList(all, name, {
windowMinutes: opts.peerWindowMinutes,
maxPeers: opts.maxPeers,
}),
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
lines.push(
"",
`[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`,
);
}
}
lines.push("");
process.stdout.write(lines.join("\n"));
});
program
.command("list")
.description("List known mailboxes.")
@@ -145,6 +265,60 @@ program
}
});
program
.command("install-hook")
.description(
"Install a Claude Code UserPromptSubmit hook that checks the mailbox on every prompt. Idempotent.",
)
.requiredOption("--name <name>", "Mailbox name to check")
.option("--user", "Patch ~/.claude/settings.json (default)")
.option("--project", "Patch <cwd>/.claude/settings.json")
.option("--url <url>", "Daemon base URL to embed in the hook command")
.action(async (opts: { name: string; user?: boolean; project?: boolean; url?: string }) => {
if (opts.user && opts.project) {
console.error("Pick either --user or --project, not both.");
process.exit(1);
}
const scope: HookScope = opts.project ? "project" : "user";
const path = settingsPathFor(scope);
const settings = readSettings(path);
const command = buildHookCommand(opts.name, opts.url);
const result = applyInstall(settings, command);
if (result.changed) {
writeSettings(path, settings);
console.log(`Hook installed in ${path}`);
console.log(`Command: ${command}`);
} else {
console.log(`Hook already present in ${path}; nothing to do.`);
}
});
program
.command("uninstall-hook")
.description("Remove the claude-mailbox UserPromptSubmit hook from Claude Code settings.")
.option("--user", "Patch ~/.claude/settings.json (default)")
.option("--project", "Patch <cwd>/.claude/settings.json")
.action(async (opts: { user?: boolean; project?: boolean }) => {
if (opts.user && opts.project) {
console.error("Pick either --user or --project, not both.");
process.exit(1);
}
const scope: HookScope = opts.project ? "project" : "user";
const path = settingsPathFor(scope);
if (!existsSync(path)) {
console.log(`No settings file at ${path}; nothing to remove.`);
return;
}
const settings = readSettings(path);
const result = applyUninstall(settings);
if (result.changed) {
writeSettings(path, settings);
console.log(`Hook removed from ${path}`);
} else {
console.log(`No claude-mailbox hook found in ${path}; nothing to remove.`);
}
});
program
.command("install-autostart")
.description(

203
node/src/hook.ts Normal file
View File

@@ -0,0 +1,203 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
export interface HookStdinPayload {
session_id?: string;
hook_event_name?: string;
[key: string]: unknown;
}
export function parseHookStdin(raw: string | null | undefined): HookStdinPayload | null {
if (!raw || !raw.trim()) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object") return parsed as HookStdinPayload;
return null;
} catch {
return null;
}
}
export function readStdinIfPiped(): string | null {
if (process.stdin.isTTY) return null;
try {
return readFileSync(0, "utf8");
} catch {
return null;
}
}
export function shortSessionId(sessionId: string): string {
const hex = sessionId.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
if (hex.length >= 8) return hex.slice(0, 8);
return sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 8) || "00000000";
}
export function deriveSessionName(sessionId: string, base?: string | null): string {
const short = shortSessionId(sessionId);
const trimmed = (base ?? "").trim();
if (trimmed) return `${trimmed}-${short}`;
return `claude-${short}`;
}
export interface PeerEntry {
name: string;
lastSeenAt: string;
}
export function formatActivePeerList(
peers: PeerEntry[],
selfName: string,
options: { windowMinutes: number; maxPeers: number; now?: number },
): string[] {
const others = peers.filter((p) => p.name !== selfName);
const cutoff = (options.now ?? Date.now()) - options.windowMinutes * 60_000;
const active = others
.filter((p) => {
const t = new Date(p.lastSeenAt).getTime();
return Number.isFinite(t) && t >= cutoff;
})
.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
.slice(0, options.maxPeers);
if (active.length === 0) {
return [
`No other mailboxes seen within the last ${options.windowMinutes} minutes (${others.length} total registered).`,
];
}
const lines = [
`Active peers (seen within last ${options.windowMinutes} min, ${active.length} of ${others.length} total):`,
];
for (const p of active) {
lines.push(` - ${p.name} (last seen ${p.lastSeenAt})`);
}
return lines;
}
export interface HookMessage {
id: number;
from: string;
body: string;
sentAt: string;
}
export function formatMessagesForHook(name: string, messages: HookMessage[]): string {
if (messages.length === 0) return "";
const header =
messages.length === 1
? `You have 1 new mailbox message for "${name}":`
: `You have ${messages.length} new mailbox messages for "${name}":`;
const lines: string[] = [header, ""];
for (const m of messages) {
lines.push(`[#${m.id}] from ${m.from} (${m.sentAt}):`);
for (const bodyLine of m.body.split(/\r?\n/)) {
lines.push(` ${bodyLine}`);
}
lines.push("");
}
return lines.join("\n").trimEnd() + "\n";
}
export type HookScope = "user" | "project";
export function settingsPathFor(scope: HookScope, cwd: string = process.cwd()): string {
if (scope === "user") return join(homedir(), ".claude", "settings.json");
return join(cwd, ".claude", "settings.json");
}
interface ClaudeHookCommand {
type: "command";
command: string;
timeout?: number;
}
interface ClaudeHookGroup {
matcher?: string;
hooks: ClaudeHookCommand[];
}
interface ClaudeSettings {
hooks?: Record<string, ClaudeHookGroup[]>;
[key: string]: unknown;
}
const HOOK_EVENT = "UserPromptSubmit";
export function buildHookCommand(name: string, url?: string): string {
const parts = ["claude-mailbox", "check", "--name", quoteIfNeeded(name), "--hook"];
if (url) parts.push("--url", quoteIfNeeded(url));
return parts.join(" ");
}
function quoteIfNeeded(value: string): string {
if (/^[A-Za-z0-9._:/@\-]+$/.test(value)) return value;
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
}
function isOurHookCommand(command: string): boolean {
const c = command.trim();
return /(^|\W)claude-mailbox\s+check\s/.test(c) && /(^|\s)--hook(\s|$)/.test(c);
}
export function readSettings(path: string): ClaudeSettings {
if (!existsSync(path)) return {};
const raw = readFileSync(path, "utf8");
if (!raw.trim()) return {};
return JSON.parse(raw) as ClaudeSettings;
}
export function writeSettings(path: string, settings: ClaudeSettings): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
}
export interface PatchResult {
changed: boolean;
reason: "added" | "already-present" | "removed" | "not-present";
}
export function applyInstall(settings: ClaudeSettings, command: string): PatchResult {
settings.hooks ??= {};
settings.hooks[HOOK_EVENT] ??= [];
const groups = settings.hooks[HOOK_EVENT];
for (const group of groups) {
for (const hook of group.hooks) {
if (hook.command.trim() === command.trim()) {
return { changed: false, reason: "already-present" };
}
}
}
let target = groups.find((g) => (g.matcher ?? "") === "");
if (!target) {
target = { matcher: "", hooks: [] };
groups.push(target);
}
target.hooks.push({ type: "command", command });
return { changed: true, reason: "added" };
}
export function applyUninstall(settings: ClaudeSettings): PatchResult {
const groups = settings.hooks?.[HOOK_EVENT];
if (!groups || groups.length === 0) return { changed: false, reason: "not-present" };
let removed = false;
for (const group of groups) {
const before = group.hooks.length;
group.hooks = group.hooks.filter((h) => !isOurHookCommand(h.command));
if (group.hooks.length !== before) removed = true;
}
settings.hooks![HOOK_EVENT] = groups.filter((g) => g.hooks.length > 0);
if (settings.hooks![HOOK_EVENT].length === 0) {
delete settings.hooks![HOOK_EVENT];
}
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
delete settings.hooks;
}
return removed ? { changed: true, reason: "removed" } : { changed: false, reason: "not-present" };
}

View File

@@ -5,35 +5,51 @@ import type { FastifyInstance } from "fastify";
import { MailboxStore, rowToMessage } from "./db.js";
import { HEADER_NAME } from "./server.js";
function buildMcpServer(store: MailboxStore): McpServer {
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
const requireSender = (extra: unknown): string => {
function headerFallback(extra: unknown): string {
const headers =
(extra as { requestInfo?: { headers?: Record<string, string | string[] | undefined> } })
?.requestInfo?.headers ?? {};
const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"];
const value = (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
if (!value) {
throw new Error(`Missing ${HEADER_NAME} header. Set it in your .mcp.json under headers.`);
}
return value;
};
return (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
}
export function resolveIdentity(
argValue: string | undefined,
extra: unknown,
argName: "from" | "name",
): string {
const explicit = (argValue ?? "").trim();
if (explicit) return explicit;
const fallback = headerFallback(extra);
if (fallback) return fallback;
throw new Error(
`Pass \`${argName}\` (your mailbox name from the SessionStart announcement) or set the X-Mailbox header in .mcp.json.`,
);
}
function buildMcpServer(store: MailboxStore): McpServer {
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
server.registerTool(
"send",
{
title: "Send mail",
description:
"Send a message to another mailbox. The sender is the current session's X-Mailbox name.",
"Send a message to another mailbox. Pass `from` with your own mailbox name (see the SessionStart announcement); falls back to the X-Mailbox header for single-session HTTP setups.",
inputSchema: {
to: z.string().describe("Name of the recipient mailbox."),
body: z.string().describe("Message body (plain text or markdown)."),
from: z
.string()
.optional()
.describe(
"Your mailbox name (the sender). Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
),
},
},
async ({ to, body }, extra) => {
const from = requireSender(extra);
const r = store.send(from, to, body);
async ({ to, body, from }, extra) => {
const sender = resolveIdentity(from, extra, "from");
const r = store.send(sender, to, body);
const out = { id: r.id, queuedAt: r.queuedAt.toISOString() };
return {
content: [{ type: "text", text: JSON.stringify(out) }],
@@ -47,12 +63,19 @@ function buildMcpServer(store: MailboxStore): McpServer {
{
title: "Check inbox",
description:
"Pull all undelivered messages for the current mailbox and mark them delivered. Returns an empty array when the inbox is empty.",
inputSchema: {},
"Pull all undelivered messages for your mailbox and mark them delivered. Pass `name` with your own mailbox name (from the SessionStart announcement); falls back to X-Mailbox header.",
inputSchema: {
name: z
.string()
.optional()
.describe(
"Your mailbox name. Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
),
},
async (_args, extra) => {
const name = requireSender(extra);
const messages = store.checkInbox(name).map((m) => {
},
async ({ name }, extra) => {
const me = resolveIdentity(name, extra, "name");
const messages = store.checkInbox(me).map((m) => {
const x = rowToMessage(m);
return { id: x.id, from: x.from, body: x.body, sentAt: x.sentAt.toISOString() };
});
@@ -68,12 +91,19 @@ function buildMcpServer(store: MailboxStore): McpServer {
{
title: "Peek inbox",
description:
"Non-consuming check of the current mailbox. Returns pending count and oldest pending timestamp.",
inputSchema: {},
"Non-consuming check of your mailbox. Returns pending count and oldest pending timestamp. Pass `name` with your own mailbox name; falls back to X-Mailbox header.",
inputSchema: {
name: z
.string()
.optional()
.describe(
"Your mailbox name. Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
),
},
async (_args, extra) => {
const name = requireSender(extra);
const status = store.peek(name);
},
async ({ name }, extra) => {
const me = resolveIdentity(name, extra, "name");
const status = store.peek(me);
const out = { pending: status.pending, oldestAt: status.oldestAt?.toISOString() ?? null };
return {
content: [{ type: "text", text: JSON.stringify(out) }],
@@ -86,12 +116,20 @@ function buildMcpServer(store: MailboxStore): McpServer {
"list_mailboxes",
{
title: "List mailboxes",
description: "Discover known mailboxes and how many messages each has waiting for you.",
inputSchema: {},
description:
"Discover known mailboxes and how many messages each has waiting for you. Pass `name` with your own mailbox name to get accurate `pendingForYou` counts; falls back to X-Mailbox header.",
inputSchema: {
name: z
.string()
.optional()
.describe(
"Your mailbox name. Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
),
},
async (_args, extra) => {
const name = requireSender(extra);
const list = store.listMailboxes(name).map((m) => ({
},
async ({ name }, extra) => {
const me = resolveIdentity(name, extra, "name");
const list = store.listMailboxes(me).map((m) => ({
name: m.name,
lastSeenAt: m.lastSeenAt.toISOString(),
pendingForYou: m.pendingForYou,

135
node/tests/cli-hook.test.ts Normal file
View File

@@ -0,0 +1,135 @@
import { describe, it, expect, beforeAll } from "vitest";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
const cliPath = resolve(__dirname, "..", "dist", "cli.js");
function runCli(
args: string[],
opts: { env?: Record<string, string | undefined>; stdin?: string } = {},
): { status: number; stdout: string; stderr: string } {
const r = spawnSync(process.execPath, [cliPath, ...args], {
encoding: "utf8",
env: { ...process.env, ...(opts.env ?? {}) },
input: opts.stdin,
});
return {
status: r.status ?? -1,
stdout: typeof r.stdout === "string" ? r.stdout : "",
stderr: typeof r.stderr === "string" ? r.stderr : "",
};
}
const HOOK_STDIN = JSON.stringify({
session_id: "abc12345-de67-89f0-1234-567890abcdef",
hook_event_name: "UserPromptSubmit",
cwd: "/tmp",
prompt: "test",
});
describe("`check --hook` CLI behavior", () => {
beforeAll(() => {
if (!existsSync(cliPath)) {
throw new Error(`CLI not built. Run \`npm run build\` first. Missing: ${cliPath}`);
}
});
it("exits 0 silently when no stdin, no --name, no env", () => {
const r = runCli(["check", "--hook"], { env: { CLAUDE_MAILBOX_NAME: undefined } });
expect(r.status).toBe(0);
expect(r.stdout).toBe("");
expect(r.stderr).toBe("");
});
it("derives session-id-based name from stdin and emits daemon hint when down", () => {
const r = runCli(["check", "--hook", "--url", "http://127.0.0.1:1"], {
env: { CLAUDE_MAILBOX_NAME: undefined },
stdin: HOOK_STDIN,
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
});
it("uses base prefix from CLAUDE_MAILBOX_NAME when both env and stdin present", () => {
// We can't directly assert the name from --hook output (it's only in the unreachable hint URL).
// The hint always contains the URL we passed, so this just confirms the path runs without error.
const r = runCli(["check", "--hook", "--url", "http://127.0.0.1:1"], {
env: { CLAUDE_MAILBOX_NAME: "backend" },
stdin: HOOK_STDIN,
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
});
it("explicit --name overrides session-id derivation", () => {
const r = runCli(
["check", "--hook", "--name", "explicit", "--url", "http://127.0.0.1:1"],
{ env: { CLAUDE_MAILBOX_NAME: "ignored" }, stdin: HOOK_STDIN },
);
expect(r.status).toBe(0);
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
});
it("non-hook mode errors out when no name resolved", () => {
const r = runCli(["check"], { env: { CLAUDE_MAILBOX_NAME: undefined } });
expect(r.status).not.toBe(0);
expect(r.stderr).toContain("CLAUDE_MAILBOX_NAME");
});
});
describe("`session-announce` CLI behavior", () => {
const UNREACHABLE = "http://127.0.0.1:1";
beforeAll(() => {
if (!existsSync(cliPath)) {
throw new Error(`CLI not built. Run \`npm run build\` first. Missing: ${cliPath}`);
}
});
it("prints the derived mailbox name from a SessionStart payload", () => {
const r = runCli(["session-announce", "--url", UNREACHABLE], {
env: { CLAUDE_MAILBOX_NAME: undefined },
stdin: HOOK_STDIN,
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("`claude-abc12345`");
expect(r.stdout).toContain("mcp__mailbox__send");
expect(r.stdout).toContain(`from="claude-abc12345"`);
});
it("uses base prefix when set", () => {
const r = runCli(["session-announce", "--url", UNREACHABLE], {
env: { CLAUDE_MAILBOX_NAME: "backend" },
stdin: HOOK_STDIN,
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("`backend-abc12345`");
});
it("emits daemon-not-reachable hint when daemon is down", () => {
const r = runCli(["session-announce", "--url", UNREACHABLE], {
env: { CLAUDE_MAILBOX_NAME: undefined },
stdin: HOOK_STDIN,
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
});
it("stays silent when no session_id in stdin", () => {
const r = runCli(["session-announce", "--url", UNREACHABLE], {
env: { CLAUDE_MAILBOX_NAME: undefined },
stdin: JSON.stringify({ hook_event_name: "SessionStart" }),
});
expect(r.status).toBe(0);
expect(r.stdout).toBe("");
});
it("stays silent when no stdin at all", () => {
const r = runCli(["session-announce", "--url", UNREACHABLE], {
env: { CLAUDE_MAILBOX_NAME: undefined },
});
expect(r.status).toBe(0);
expect(r.stdout).toBe("");
});
});

365
node/tests/hook.test.ts Normal file
View File

@@ -0,0 +1,365 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
applyInstall,
applyUninstall,
buildHookCommand,
deriveSessionName,
formatActivePeerList,
formatMessagesForHook,
parseHookStdin,
readSettings,
shortSessionId,
writeSettings,
type PeerEntry,
} from "../src/hook.js";
describe("formatMessagesForHook", () => {
it("returns empty string for empty inbox", () => {
expect(formatMessagesForHook("bob", [])).toBe("");
});
it("formats a single message", () => {
const out = formatMessagesForHook("bob", [
{ id: 1, from: "alice", body: "hi bob", sentAt: "2026-05-19T10:00:00.000Z" },
]);
expect(out).toContain("1 new mailbox message");
expect(out).toContain("[#1] from alice (2026-05-19T10:00:00.000Z):");
expect(out).toContain(" hi bob");
});
it("formats multiple messages with plural header", () => {
const out = formatMessagesForHook("bob", [
{ id: 1, from: "alice", body: "one", sentAt: "2026-05-19T10:00:00.000Z" },
{ id: 2, from: "carol", body: "two", sentAt: "2026-05-19T10:01:00.000Z" },
]);
expect(out).toContain("2 new mailbox messages");
expect(out).toContain("[#1] from alice");
expect(out).toContain("[#2] from carol");
});
it("preserves multi-line bodies with indentation", () => {
const out = formatMessagesForHook("bob", [
{ id: 1, from: "alice", body: "line1\nline2", sentAt: "2026-05-19T10:00:00.000Z" },
]);
expect(out).toContain(" line1");
expect(out).toContain(" line2");
});
});
describe("buildHookCommand", () => {
it("builds a basic command", () => {
expect(buildHookCommand("alice")).toBe("claude-mailbox check --name alice --hook");
});
it("appends --url when provided", () => {
expect(buildHookCommand("alice", "http://127.0.0.1:9000")).toBe(
"claude-mailbox check --name alice --hook --url http://127.0.0.1:9000",
);
});
it("quotes names with spaces", () => {
const cmd = buildHookCommand("my mailbox");
expect(cmd).toContain('--name "my mailbox"');
});
});
describe("applyInstall", () => {
it("creates hooks structure from empty settings", () => {
const s: Record<string, unknown> = {};
const r = applyInstall(s, "claude-mailbox check --name bob --hook");
expect(r).toEqual({ changed: true, reason: "added" });
expect(s).toMatchObject({
hooks: {
UserPromptSubmit: [
{
matcher: "",
hooks: [{ type: "command", command: "claude-mailbox check --name bob --hook" }],
},
],
},
});
});
it("is idempotent — does not duplicate the same command", () => {
const s: Record<string, unknown> = {};
applyInstall(s, "claude-mailbox check --name bob --hook");
const r = applyInstall(s, "claude-mailbox check --name bob --hook");
expect(r).toEqual({ changed: false, reason: "already-present" });
const groups = (s.hooks as { UserPromptSubmit: { hooks: unknown[] }[] }).UserPromptSubmit;
expect(groups[0]!.hooks).toHaveLength(1);
});
it("preserves existing unrelated hooks", () => {
const s: Record<string, unknown> = {
hooks: {
UserPromptSubmit: [
{
matcher: "",
hooks: [{ type: "command", command: "echo something-else" }],
},
],
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "logger" }] }],
},
};
applyInstall(s, "claude-mailbox check --name bob --hook");
const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] };
expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(2);
expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else");
expect(hooks.UserPromptSubmit[0]!.hooks[1]!.command).toBe(
"claude-mailbox check --name bob --hook",
);
expect((s.hooks as Record<string, unknown>).PostToolUse).toBeDefined();
});
it("adds a new empty-matcher group when only non-empty matchers exist", () => {
const s: Record<string, unknown> = {
hooks: {
UserPromptSubmit: [
{ matcher: "Bash", hooks: [{ type: "command", command: "echo bash-only" }] },
],
},
};
applyInstall(s, "claude-mailbox check --name bob --hook");
const groups = (s.hooks as { UserPromptSubmit: { matcher?: string }[] }).UserPromptSubmit;
expect(groups).toHaveLength(2);
expect(groups[1]!.matcher).toBe("");
});
});
describe("applyUninstall", () => {
it("removes the hook and cleans up empty structures", () => {
const s: Record<string, unknown> = {};
applyInstall(s, "claude-mailbox check --name bob --hook");
const r = applyUninstall(s);
expect(r).toEqual({ changed: true, reason: "removed" });
expect(s.hooks).toBeUndefined();
});
it("preserves unrelated hooks in the same group", () => {
const s: Record<string, unknown> = {
hooks: {
UserPromptSubmit: [
{
matcher: "",
hooks: [
{ type: "command", command: "echo something-else" },
{ type: "command", command: "claude-mailbox check --name bob --hook" },
],
},
],
},
};
applyUninstall(s);
const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] };
expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(1);
expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else");
});
it("returns not-present when there is nothing to remove", () => {
const s: Record<string, unknown> = {
hooks: { UserPromptSubmit: [{ matcher: "", hooks: [{ type: "command", command: "x" }] }] },
};
const r = applyUninstall(s);
expect(r).toEqual({ changed: false, reason: "not-present" });
});
it("removes hooks installed with --url arg", () => {
const s: Record<string, unknown> = {};
applyInstall(s, "claude-mailbox check --name bob --hook --url http://x");
applyUninstall(s);
expect(s.hooks).toBeUndefined();
});
});
describe("parseHookStdin", () => {
it("returns null for empty or whitespace input", () => {
expect(parseHookStdin(null)).toBeNull();
expect(parseHookStdin("")).toBeNull();
expect(parseHookStdin(" \n ")).toBeNull();
});
it("returns null for non-JSON input", () => {
expect(parseHookStdin("not json")).toBeNull();
expect(parseHookStdin("{")).toBeNull();
});
it("returns null for JSON primitives (only objects allowed)", () => {
expect(parseHookStdin("42")).toBeNull();
expect(parseHookStdin("\"foo\"")).toBeNull();
expect(parseHookStdin("null")).toBeNull();
});
it("parses a hook payload", () => {
const out = parseHookStdin(
JSON.stringify({
session_id: "abc12345-de67-89f0-1234-567890abcdef",
hook_event_name: "UserPromptSubmit",
prompt: "hi",
}),
);
expect(out?.session_id).toBe("abc12345-de67-89f0-1234-567890abcdef");
expect(out?.hook_event_name).toBe("UserPromptSubmit");
});
});
describe("shortSessionId / deriveSessionName", () => {
it("takes first 8 hex chars from a UUID", () => {
expect(shortSessionId("abc12345-de67-89f0-1234-567890abcdef")).toBe("abc12345");
});
it("normalizes case and ignores hyphens", () => {
expect(shortSessionId("ABC12345-DE67-89F0-1234-567890ABCDEF")).toBe("abc12345");
});
it("falls back to a sanitized prefix for non-hex ids", () => {
expect(shortSessionId("session-Test123")).toBe("sessiont");
});
it("derives anonymous name when no base", () => {
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef")).toBe("claude-abc12345");
});
it("prepends base prefix when given", () => {
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", "backend")).toBe(
"backend-abc12345",
);
});
it("treats whitespace-only base as no base", () => {
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", " ")).toBe(
"claude-abc12345",
);
});
it("derives different names for different sessions with the same base", () => {
const a = deriveSessionName("aaaa1111-de67-89f0-1234-567890abcdef", "shared");
const b = deriveSessionName("bbbb2222-de67-89f0-1234-567890abcdef", "shared");
expect(a).not.toBe(b);
});
});
describe("formatActivePeerList", () => {
const NOW = new Date("2026-05-19T12:00:00.000Z").getTime();
const peer = (name: string, isoOffsetMinutes: number): PeerEntry => ({
name,
lastSeenAt: new Date(NOW - isoOffsetMinutes * 60_000).toISOString(),
});
it("excludes self from the list", () => {
const out = formatActivePeerList(
[peer("self", 1), peer("alice", 1)],
"self",
{ windowMinutes: 60, maxPeers: 10, now: NOW },
);
const joined = out.join("\n");
expect(joined).not.toContain("self");
expect(joined).toContain("alice");
});
it("filters out peers older than the window", () => {
const out = formatActivePeerList(
[peer("recent", 5), peer("stale", 120)],
"self",
{ windowMinutes: 60, maxPeers: 10, now: NOW },
);
const joined = out.join("\n");
expect(joined).toContain("recent");
expect(joined).not.toContain("stale");
expect(out[0]).toContain("1 of 2 total");
});
it("returns a no-peers message when nothing is active", () => {
const out = formatActivePeerList(
[peer("ancient", 9999)],
"self",
{ windowMinutes: 60, maxPeers: 10, now: NOW },
);
expect(out).toHaveLength(1);
expect(out[0]).toMatch(/No other mailboxes seen within the last 60 minutes/);
expect(out[0]).toContain("1 total registered");
});
it("caps at maxPeers and sorts most-recent first", () => {
const out = formatActivePeerList(
[peer("p1", 30), peer("p2", 20), peer("p3", 10)],
"self",
{ windowMinutes: 60, maxPeers: 2, now: NOW },
);
const joined = out.join("\n");
expect(joined).toContain("p3");
expect(joined).toContain("p2");
expect(joined).not.toContain("p1");
expect(out[0]).toContain("2 of 3 total");
expect(joined.indexOf("p3")).toBeLessThan(joined.indexOf("p2"));
});
it("ignores peers with invalid lastSeenAt", () => {
const out = formatActivePeerList(
[{ name: "garbage", lastSeenAt: "not-a-date" }, peer("ok", 5)],
"self",
{ windowMinutes: 60, maxPeers: 10, now: NOW },
);
const joined = out.join("\n");
expect(joined).toContain("ok");
expect(joined).not.toContain("garbage");
});
});
describe("readSettings / writeSettings roundtrip", () => {
it("survives an install → write → read cycle", () => {
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
try {
const path = join(dir, "settings.json");
const s = readSettings(path);
expect(s).toEqual({});
applyInstall(s, "claude-mailbox check --name bob --hook");
writeSettings(path, s);
const reloaded = readSettings(path);
expect(reloaded.hooks?.UserPromptSubmit?.[0]?.hooks[0]?.command).toBe(
"claude-mailbox check --name bob --hook",
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("creates parent .claude directory when missing", () => {
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
try {
const path = join(dir, "nested", ".claude", "settings.json");
writeSettings(path, { hooks: {} });
expect(readFileSync(path, "utf8")).toContain('"hooks"');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("preserves non-hook settings keys", () => {
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
try {
const path = join(dir, "settings.json");
mkdirSync(dir, { recursive: true });
writeFileSync(
path,
JSON.stringify({ model: "sonnet", permissions: { allow: ["Bash"] } }, null, 2),
);
const s = readSettings(path);
applyInstall(s, "claude-mailbox check --name bob --hook");
writeSettings(path, s);
const reloaded = readSettings(path) as {
model?: string;
permissions?: { allow?: string[] };
hooks?: unknown;
};
expect(reloaded.model).toBe("sonnet");
expect(reloaded.permissions?.allow).toEqual(["Bash"]);
expect(reloaded.hooks).toBeDefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

44
node/tests/mcp.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { resolveIdentity } from "../src/mcp.js";
function fakeExtra(header?: string): unknown {
if (header === undefined) return {};
return { requestInfo: { headers: { "x-mailbox": header } } };
}
describe("resolveIdentity", () => {
it("prefers the explicit argument when present", () => {
expect(resolveIdentity("alice", fakeExtra("bob"), "from")).toBe("alice");
});
it("falls back to X-Mailbox header when arg missing", () => {
expect(resolveIdentity(undefined, fakeExtra("bob"), "from")).toBe("bob");
});
it("trims whitespace from explicit arg and header", () => {
expect(resolveIdentity(" alice ", fakeExtra(), "from")).toBe("alice");
expect(resolveIdentity(undefined, fakeExtra(" bob "), "name")).toBe("bob");
});
it("treats empty arg as missing and falls back", () => {
expect(resolveIdentity("", fakeExtra("bob"), "name")).toBe("bob");
expect(resolveIdentity(" ", fakeExtra("bob"), "name")).toBe("bob");
});
it("throws with a helpful message when neither is provided", () => {
expect(() => resolveIdentity(undefined, fakeExtra(), "from")).toThrow(
/Pass `from`.*SessionStart announcement/i,
);
});
it("throws referencing the correct arg name", () => {
expect(() => resolveIdentity(undefined, fakeExtra(), "name")).toThrow(
/Pass `name`/,
);
});
it("handles extra without requestInfo", () => {
expect(() => resolveIdentity(undefined, {}, "from")).toThrow(/Pass `from`/);
expect(() => resolveIdentity(undefined, null, "from")).toThrow(/Pass `from`/);
});
});

View File

@@ -0,0 +1,11 @@
{
"name": "claude-mailbox",
"version": "0.1.0",
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and injects pending messages into the conversation context.",
"author": {
"name": "Mika Kuns"
},
"homepage": "https://git.kuns.dev/releases/ClaudeMailbox",
"license": "MIT",
"keywords": ["mailbox", "ipc", "coordination", "mcp"]
}

8
plugin/.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"mailbox": {
"type": "http",
"url": "http://127.0.0.1:47822/mcp"
}
}
}

86
plugin/README.md Normal file
View File

@@ -0,0 +1,86 @@
# claude-mailbox plugin
Lets Claude Code pull unread messages from a local `claude-mailbox` daemon before every prompt and inject them into the conversation context. Each Claude session gets a **unique mailbox identity** auto-derived from its session id, so two sessions in the same project never collide.
## Setup (three prompts, all inside Claude Code)
```
/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox
/plugin install claude-mailbox@claude-mailbox
/claude-mailbox:mailbox-doctor
```
The doctor walks the rest:
1. installs the `claude-mailbox` binary via `npm install -g @kuns/claude-mailbox` if missing (asks first)
2. registers the daemon for autostart and starts it if needed
3. health-probes `http://127.0.0.1:47822/health`
4. optionally lets you set a **base prefix** (e.g., `backend`) — without one, mailbox names are anonymous (`claude-XXXXXXXX`)
5. runs a self → self smoke test
Restart Claude Code only if step 4 wrote a new prefix. After that, every prompt auto-pulls unread messages.
## Mailbox identity (the important bit)
Each Claude Code session gets its own mailbox name, derived from the session's UUID:
| Configuration | Resulting mailbox name |
|---|---|
| No `CLAUDE_MAILBOX_NAME` set | `claude-a8b3c1d2` (first 8 hex chars of session_id) |
| `CLAUDE_MAILBOX_NAME=backend` in `.claude/settings.json` env | `backend-a8b3c1d2` |
So if you open two Claude Code sessions in the same project, they'll be e.g. `backend-a8b3c1d2` and `backend-d4e5f6a7` — distinct, addressable, no manual setup.
The `SessionStart` hook announces the current session's mailbox name in the conversation context on startup. Peers discover each other via `claude-mailbox list` or the `mcp__mailbox__list_mailboxes` MCP tool.
## What the hooks do
| Hook | Command | Effect |
|---|---|---|
| `SessionStart` | `claude-mailbox session-announce` | Registers the session with the daemon, then prints (a) this session's mailbox name, (b) the exact `from` / `name` args to pass to MCP tools, and (c) a list of other mailboxes active in the last hour — so Claude knows who's around without needing to call `list_mailboxes` first. |
| `UserPromptSubmit` | `claude-mailbox check --hook` | Pulls unread messages for the session's mailbox and injects them as context. Silent on empty inbox; emits a one-line setup hint when the daemon is unreachable. |
Cost: one local HTTP round-trip per prompt + Node coldstart (~100ms on Windows).
## MCP tools
The plugin also ships a `.mcp.json` so Claude has direct access to the mailbox via tool calls. Because the X-Mailbox header would be the same for two parallel sessions sharing one `.mcp.json`, **each MCP tool takes the caller's mailbox name as an explicit argument** (from the SessionStart announcement):
| Tool | Required args | Purpose |
|---|---|---|
| `mcp__mailbox__send` | `from`, `to`, `body` | Send a message to another mailbox. |
| `mcp__mailbox__check_inbox` | `name` | Pull all undelivered messages for your mailbox (marks delivered). |
| `mcp__mailbox__peek_inbox` | `name` | Non-consuming count of pending messages. |
| `mcp__mailbox__list_mailboxes` | `name` | Discover known mailboxes and `pendingForYou` counts. |
The SessionStart announcement spells out the exact args to pass, so Claude picks them up automatically.
## Slash commands
| Command | What it does |
|---|---|
| `/claude-mailbox:mailbox-doctor` | Diagnose + auto-fix the full setup. |
| `/claude-mailbox:mailbox-status` | Read-only health check. No changes. |
| `/claude-mailbox:mailbox-update` | Update the daemon to the latest npm version and restart it. |
## Coordinating two Claude Code sessions
1. Open two Claude Code sessions in the same (or different) project.
2. Each session's SessionStart hook registers itself with the daemon and prints both its own mailbox name and the **list of currently active peers** into context.
3. In session A you can simply say: *"I started a second session, coordinate with it."* Because the peer's mailbox name is already in context, Claude can call `mcp__mailbox__send(from="<my-name>", to="<peer-name>", body="...")` straight away — no manual `list_mailboxes` step needed.
4. Session B's `UserPromptSubmit` hook pulls the message on its next prompt and injects it as context.
You can also send from any shell:
```sh
claude-mailbox list
claude-mailbox send --from probe --to backend-a8b3c1d2 --body "hi"
```
## Uninstall
```
/plugin uninstall claude-mailbox@claude-mailbox
npm uninstall -g @kuns/claude-mailbox
claude-mailbox uninstall-autostart # if you registered it
```

View File

@@ -0,0 +1,92 @@
---
description: Diagnose and auto-fix the Claude-Mailbox setup (binary install, daemon autostart, smoke test, optional base-prefix).
allowed-tools: Bash, Read, Edit, Write
---
You are running the **Claude-Mailbox doctor**. Walk through these checks in order. After each step, print a one-line `✓` / `✗` with the action you took. End with a summary block.
Use `Bash` only for `claude-mailbox` subcommands, `npm`, `where`/`which`, and HTTP probes. Use `Read`/`Edit`/`Write` for `.claude/settings.json`. Never run `sudo` automatically — if elevation is needed, stop and ask.
## Step 1 — daemon binary on PATH
Run: `claude-mailbox --version`
- **Exit 0** → ✓ record the version. Continue.
- **Command not found** → binary missing. Install path:
| Platform | Command |
|---|---|
| Windows | `npm install -g @kuns/claude-mailbox` (no admin) |
| macOS / Linux | `npm install -g @kuns/claude-mailbox` (may fail with EACCES — never run sudo automatically; ask the user) |
Prerequisite: `npm config get @kuns:registry` must point at `https://git.kuns.dev/api/packages/releases/npm/`. If not:
```
npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
```
After install, re-run `claude-mailbox --version`. If it still fails, stop and report.
## Step 2 — daemon autostart and running state
Run: `claude-mailbox status`
- `Running` → ✓ continue.
- `Stopped` → `claude-mailbox start`, re-check.
- `NotInstalled` → `claude-mailbox install-autostart`, then `claude-mailbox start`, re-check.
If status doesn't reach `Running`, stop and report.
## Step 3 — health probe
Hit `http://127.0.0.1:47822/health`. Expect a JSON body with `"status":"ok"`. If unreachable, stop and report — the daemon claims it's running but isn't accepting connections.
## Step 4 — mailbox identity
**No prompt by default.** Each Claude Code session now gets a unique mailbox name auto-derived from its `session_id` (e.g., `claude-a8b3c1d2`), so two parallel sessions can never collide.
Read `.claude/settings.json` in the current working directory and look for `env.CLAUDE_MAILBOX_NAME`.
- If set → ✓ this is a **base prefix**. The real name will be `<base>-<short_session_id>`. Tell the user "Mailbox prefix is set to `X`."
- If unset → ✓ tell the user "Mailbox name will be auto-derived (`claude-<short_session_id>`)."
Then **ask** the user (one question, not a deep prompt):
> "Want to flavor your mailbox names with a memorable prefix like `backend` or `frontend`? (yes / no / change to `<x>`)"
If they say yes or give a value:
1. Read `.claude/settings.json` (empty `{}` if missing).
2. Merge `env.CLAUDE_MAILBOX_NAME` = chosen value, preserving anything else.
3. Write back with 2-space indentation.
4. Mark this as `restart_needed = true`.
If they say no or skip → leave as-is.
## Step 5 — smoke test
Use two ephemeral names (`doctor-probe-a` / `doctor-probe-b`) — we don't need the real session name here, we just need to prove the daemon round-trips:
```
claude-mailbox send --from doctor-probe-a --to doctor-probe-b --body "ping from doctor"
claude-mailbox check --name doctor-probe-b
```
The `check` output must be a JSON array with one message: `from: doctor-probe-a`, body matches. If yes ✓. If no ✗ and report what came back.
## Step 6 — summary
```
Claude-Mailbox doctor
binary: <version>
daemon: Running (and what you did, if anything)
health: ok
base prefix: <name from settings, or "auto-derived (anonymous)">
smoke test: passed | failed
restart hint: yes if restart_needed, otherwise no
```
End with one of:
- All ✓ and no restart needed → "Setup is healthy. Your mailbox name this session will be revealed by the SessionStart hook on next session start (or run `claude-mailbox list` to see active mailboxes)."
- All ✓ and restart needed → "Restart Claude Code (or open a new session) so the SessionStart hook picks up the new base prefix."
- Anything ✗ → "Setup incomplete: <first failure>."

View File

@@ -0,0 +1,23 @@
---
description: Read-only Claude-Mailbox health check. No changes, no installs — just report.
allowed-tools: Bash, Read
---
Report the Claude-Mailbox setup status without making any changes. If something is wrong, **tell** the user but **do not** fix it — suggest `/claude-mailbox:mailbox-doctor` for that.
Print exactly this block, filling in each line:
```
Claude-Mailbox status
binary: <output of `claude-mailbox --version`, or "not installed">
daemon: <output of `claude-mailbox status`>
health: <"ok" if GET http://127.0.0.1:47822/health returns 200, else "unreachable">
mailbox name: <value of env.CLAUDE_MAILBOX_NAME in ./.claude/settings.json, or "unset"; also note if ~/.claude/settings.json has a value>
pending: <integer count from `claude-mailbox peek --name <resolved-name>` if name is set, else "n/a">
```
End with one line:
- All good → `Status: OK`
- Missing daemon or unset name → `Status: Setup incomplete. Run /claude-mailbox:mailbox-doctor to fix.`
- Daemon installed but stopped → `Status: Daemon is not running. Try \`claude-mailbox start\` or run /claude-mailbox:mailbox-doctor.`

View File

@@ -0,0 +1,56 @@
---
description: Update the Claude-Mailbox daemon to the latest published npm version and restart it.
allowed-tools: Bash
---
You are running the **Claude-Mailbox update** command. Update the `@kuns/claude-mailbox` npm package and restart the daemon. Never run `sudo` automatically — if elevation is needed, stop and ask.
## Step 1 — current version
Run: `claude-mailbox --version`
- Exit 0 → record the version string as `CURRENT`.
- Non-zero → tell the user the daemon binary is not installed yet. Suggest `/claude-mailbox:mailbox-doctor` to do a full setup and stop.
## Step 2 — latest published version
Run: `npm view @kuns/claude-mailbox version`
If the npm registry config is missing, the call may fail with a 404. Fall back to:
```
npm view --registry=https://git.kuns.dev/api/packages/releases/npm/ @kuns/claude-mailbox version
```
Record the result as `LATEST`.
## Step 3 — compare
- If `CURRENT === LATEST`: print "Already up to date (vX.Y.Z)." and stop. Do not run any further steps.
- Otherwise: tell the user `CURRENT``LATEST` and ask for confirmation before proceeding.
## Step 4 — perform the update
On user confirmation, run these in order. Stop on the first failure and report it:
1. `claude-mailbox stop`
2. `npm install -g @kuns/claude-mailbox@latest`
- On Linux/macOS this may fail with EACCES. **Do not run sudo automatically.** Ask the user how they want to proceed (e.g., `sudo npm install -g …`, or switch to a user-scoped Node setup with nvm/fnm).
3. `claude-mailbox start`
4. `claude-mailbox --version` to verify the upgrade landed.
5. `claude-mailbox status` to verify the daemon is `Running`.
## Step 5 — summary
Print exactly this block:
```
Claude-Mailbox update
previous version: <CURRENT>
new version: <whatever --version now reports>
daemon: Running | Stopped | NotInstalled
pending messages survived: <count of messages still in inbox via `claude-mailbox list`, if applicable>
```
If the new version matches `LATEST` and daemon is `Running`, end with: "Update complete."
Otherwise, end with the first thing that went wrong.

24
plugin/hooks/hooks.json Normal file
View File

@@ -0,0 +1,24 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "claude-mailbox session-announce"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "claude-mailbox check --hook"
}
]
}
]
}
}