Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e8d0d4ca | ||
|
|
50f2b5a7cb | ||
|
|
19d7a591df | ||
|
|
48b6ba6452 | ||
|
|
9fd321043f | ||
|
|
462d6561e1 | ||
|
|
c231f8c18c | ||
|
|
5c5843e62d | ||
|
|
66967167bc |
14
.claude-plugin/marketplace.json
Normal file
14
.claude-plugin/marketplace.json
Normal 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
259
README.md
259
README.md
@@ -1,41 +1,60 @@
|
|||||||
# ClaudeMailbox
|
# 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
|
/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox
|
||||||
(X-Mailbox: backend) (X-Mailbox: frontend) (CLI / UI / hook)
|
/plugin install claude-mailbox@claude-mailbox
|
||||||
| | |
|
/claude-mailbox:mailbox-doctor
|
||||||
| 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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
```sh
|
||||||
# one-time per machine: point the @kuns scope at the public Gitea npm registry
|
# 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/
|
npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
|
||||||
|
|
||||||
# install
|
# install + autostart
|
||||||
npm install -g @kuns/claude-mailbox
|
npm install -g @kuns/claude-mailbox
|
||||||
|
claude-mailbox install-autostart
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the bootstrap one-liner:
|
Or the bootstrap one-liner:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Windows
|
# 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
|
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
|
```json
|
||||||
brew install kuns/tap/claude-mailbox
|
{
|
||||||
|
"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
|
```sh
|
||||||
claude-mailbox install-autostart # per-user, no admin
|
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 status # Running | Stopped | NotInstalled
|
||||||
claude-mailbox uninstall-autostart [--purge]
|
claude-mailbox uninstall-autostart [--purge]
|
||||||
```
|
```
|
||||||
@@ -68,68 +131,18 @@ claude-mailbox uninstall-autostart [--purge]
|
|||||||
| macOS | launchd LaunchAgent in `~/Library/LaunchAgents/` | n/a |
|
| macOS | launchd LaunchAgent in `~/Library/LaunchAgents/` | n/a |
|
||||||
| Linux | systemd `--user` unit in `~/.config/systemd/user/` | n/a |
|
| Linux | systemd `--user` unit in `~/.config/systemd/user/` | n/a |
|
||||||
|
|
||||||
### Config precedence
|
---
|
||||||
|
|
||||||
```
|
## MCP tools
|
||||||
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). 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`.
|
The plugin's SessionStart announcement tells Claude exactly which name to pass for the current session, so the args are filled in automatically.
|
||||||
|
|
||||||
### 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 |
|
|
||||||
|
|
||||||
### Suggested CLAUDE.md snippet for poll discipline
|
### 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.
|
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 send --from <mailbox> --to <mailbox> --body <text>
|
||||||
claude-mailbox peek --name <mailbox> [--url ...]
|
claude-mailbox peek --name <mailbox>
|
||||||
claude-mailbox check --name <mailbox> [--url ...]
|
claude-mailbox check --name <mailbox> [--hook]
|
||||||
claude-mailbox list [--url ...]
|
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
|
## REST surface
|
||||||
|
|
||||||
| Method | Path | Requires `X-Mailbox` | Purpose |
|
| Method | Path | `X-Mailbox` required | Purpose |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/health` | no | `{ status, version, dbPath }` |
|
| `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 |
|
| `GET` | `/v1/peek?name=<mailbox>` | no | read-only status |
|
||||||
| `POST` | `/v1/check-inbox?name=<mailbox>` | yes (must match `name`) | consume inbox |
|
| `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
|
## 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 build
|
||||||
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj
|
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj
|
||||||
dotnet run --project src/ClaudeMailbox -- serve
|
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
|
## Scope
|
||||||
|
|
||||||
- Loopback bind only (v1). Cross-machine coordination is a future extension — swap the middleware for token auth and change the bind address.
|
- 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 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.
|
||||||
|
|||||||
@@ -17,4 +17,36 @@ Then:
|
|||||||
claude-mailbox install-autostart # registers per-OS autostart, no admin needed by default
|
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.
|
||||||
|
|||||||
4
node/package-lock.json
generated
4
node/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kuns/claude-mailbox",
|
"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.",
|
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"pretest": "npm run build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"start": "node dist/cli.js serve",
|
"start": "node dist/cli.js serve",
|
||||||
|
|||||||
186
node/src/cli.ts
186
node/src/cli.ts
@@ -1,11 +1,27 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js";
|
import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js";
|
||||||
import { startServer } from "./server.js";
|
import { startServer } from "./server.js";
|
||||||
import { autostartManager } from "./autostart/index.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 {
|
function readVersion(): string {
|
||||||
try {
|
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
|
program
|
||||||
.command("check")
|
.command("check")
|
||||||
.description("Pull pending messages and mark delivered.")
|
.description(
|
||||||
.requiredOption("--name <name>", "Mailbox name (also sent as X-Mailbox)")
|
"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)
|
.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 {
|
try {
|
||||||
const out = await callJson(
|
const out = await callJson(
|
||||||
"POST",
|
"POST",
|
||||||
`${opts.url}/v1/check-inbox?name=${encodeURIComponent(opts.name)}`,
|
`${opts.url}/v1/check-inbox?name=${encodeURIComponent(name)}`,
|
||||||
{ headers: { "X-Mailbox": opts.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));
|
console.log(JSON.stringify(out, null, 2));
|
||||||
} catch (err) {
|
} 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);
|
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
|
program
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List known mailboxes.")
|
.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
|
program
|
||||||
.command("install-autostart")
|
.command("install-autostart")
|
||||||
.description(
|
.description(
|
||||||
|
|||||||
203
node/src/hook.ts
Normal file
203
node/src/hook.ts
Normal 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" };
|
||||||
|
}
|
||||||
@@ -5,35 +5,51 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { MailboxStore, rowToMessage } from "./db.js";
|
import { MailboxStore, rowToMessage } from "./db.js";
|
||||||
import { HEADER_NAME } from "./server.js";
|
import { HEADER_NAME } from "./server.js";
|
||||||
|
|
||||||
function buildMcpServer(store: MailboxStore): McpServer {
|
function headerFallback(extra: unknown): string {
|
||||||
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
|
|
||||||
|
|
||||||
const requireSender = (extra: unknown): string => {
|
|
||||||
const headers =
|
const headers =
|
||||||
(extra as { requestInfo?: { headers?: Record<string, string | string[] | undefined> } })
|
(extra as { requestInfo?: { headers?: Record<string, string | string[] | undefined> } })
|
||||||
?.requestInfo?.headers ?? {};
|
?.requestInfo?.headers ?? {};
|
||||||
const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"];
|
const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"];
|
||||||
const value = (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
|
return (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
|
||||||
if (!value) {
|
}
|
||||||
throw new Error(`Missing ${HEADER_NAME} header. Set it in your .mcp.json under headers.`);
|
|
||||||
}
|
export function resolveIdentity(
|
||||||
return value;
|
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(
|
server.registerTool(
|
||||||
"send",
|
"send",
|
||||||
{
|
{
|
||||||
title: "Send mail",
|
title: "Send mail",
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
to: z.string().describe("Name of the recipient mailbox."),
|
to: z.string().describe("Name of the recipient mailbox."),
|
||||||
body: z.string().describe("Message body (plain text or markdown)."),
|
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) => {
|
async ({ to, body, from }, extra) => {
|
||||||
const from = requireSender(extra);
|
const sender = resolveIdentity(from, extra, "from");
|
||||||
const r = store.send(from, to, body);
|
const r = store.send(sender, to, body);
|
||||||
const out = { id: r.id, queuedAt: r.queuedAt.toISOString() };
|
const out = { id: r.id, queuedAt: r.queuedAt.toISOString() };
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(out) }],
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
@@ -47,12 +63,19 @@ function buildMcpServer(store: MailboxStore): McpServer {
|
|||||||
{
|
{
|
||||||
title: "Check inbox",
|
title: "Check inbox",
|
||||||
description:
|
description:
|
||||||
"Pull all undelivered messages for the current mailbox and mark them delivered. Returns an empty array when the inbox is empty.",
|
"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: {},
|
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);
|
async ({ name }, extra) => {
|
||||||
const messages = store.checkInbox(name).map((m) => {
|
const me = resolveIdentity(name, extra, "name");
|
||||||
|
const messages = store.checkInbox(me).map((m) => {
|
||||||
const x = rowToMessage(m);
|
const x = rowToMessage(m);
|
||||||
return { id: x.id, from: x.from, body: x.body, sentAt: x.sentAt.toISOString() };
|
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",
|
title: "Peek inbox",
|
||||||
description:
|
description:
|
||||||
"Non-consuming check of the current mailbox. Returns pending count and oldest pending timestamp.",
|
"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: {},
|
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);
|
async ({ name }, extra) => {
|
||||||
const status = store.peek(name);
|
const me = resolveIdentity(name, extra, "name");
|
||||||
|
const status = store.peek(me);
|
||||||
const out = { pending: status.pending, oldestAt: status.oldestAt?.toISOString() ?? null };
|
const out = { pending: status.pending, oldestAt: status.oldestAt?.toISOString() ?? null };
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(out) }],
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
@@ -86,12 +116,20 @@ function buildMcpServer(store: MailboxStore): McpServer {
|
|||||||
"list_mailboxes",
|
"list_mailboxes",
|
||||||
{
|
{
|
||||||
title: "List mailboxes",
|
title: "List mailboxes",
|
||||||
description: "Discover known mailboxes and how many messages each has waiting for you.",
|
description:
|
||||||
inputSchema: {},
|
"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);
|
async ({ name }, extra) => {
|
||||||
const list = store.listMailboxes(name).map((m) => ({
|
const me = resolveIdentity(name, extra, "name");
|
||||||
|
const list = store.listMailboxes(me).map((m) => ({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
lastSeenAt: m.lastSeenAt.toISOString(),
|
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||||
pendingForYou: m.pendingForYou,
|
pendingForYou: m.pendingForYou,
|
||||||
|
|||||||
135
node/tests/cli-hook.test.ts
Normal file
135
node/tests/cli-hook.test.ts
Normal 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
365
node/tests/hook.test.ts
Normal 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
44
node/tests/mcp.test.ts
Normal 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`/);
|
||||||
|
});
|
||||||
|
});
|
||||||
11
plugin/.claude-plugin/plugin.json
Normal file
11
plugin/.claude-plugin/plugin.json
Normal 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
8
plugin/.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mailbox": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:47822/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
plugin/README.md
Normal file
86
plugin/README.md
Normal 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
|
||||||
|
```
|
||||||
92
plugin/commands/mailbox-doctor.md
Normal file
92
plugin/commands/mailbox-doctor.md
Normal 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>."
|
||||||
23
plugin/commands/mailbox-status.md
Normal file
23
plugin/commands/mailbox-status.md
Normal 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.`
|
||||||
56
plugin/commands/mailbox-update.md
Normal file
56
plugin/commands/mailbox-update.md
Normal 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
24
plugin/hooks/hooks.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox session-announce"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox check --hook"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user