Compare commits
50 Commits
v0.2.1
...
debc6287e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
debc6287e2 | ||
|
|
fed74dc8f0 | ||
|
|
3ebf54e75d | ||
|
|
840a3e32c8 | ||
|
|
1f7585152e | ||
|
|
7b58db771a | ||
|
|
6592d428b7 | ||
|
|
22824bd35f | ||
|
|
951fb4f021 | ||
|
|
c1fc863047 | ||
|
|
8c8be67a98 | ||
|
|
307e15b05b | ||
|
|
efdc752890 | ||
|
|
9f8c1d9e9d | ||
|
|
1c2c1d2f7e | ||
|
|
bc53daf6e6 | ||
|
|
8169ebf4fe | ||
|
|
b05e6f2bd7 | ||
|
|
b74e969229 | ||
|
|
31584fe623 | ||
|
|
407f3a8f16 | ||
|
|
75a180279e | ||
|
|
9438b1d8dc | ||
|
|
f4539eb2c9 | ||
|
|
4b93641cf4 | ||
|
|
2cadc3a867 | ||
|
|
0c06e2cf4b | ||
|
|
06a2ea6b7b | ||
|
|
01c22ff9a3 | ||
|
|
7b65545600 | ||
|
|
b10ac36ed0 | ||
|
|
8832eab6c7 | ||
|
|
8747d638fb | ||
|
|
d456f29138 | ||
|
|
d37d2419d6 | ||
|
|
ee0b72f43b | ||
|
|
d3abc762fd | ||
|
|
d0eb2af183 | ||
|
|
42237149a1 | ||
|
|
ac626f678b | ||
|
|
73a49e405f | ||
|
|
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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
name: CI (.NET)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "src/**"
|
|
||||||
- "tests/**"
|
|
||||||
- "ClaudeMailbox.slnx"
|
|
||||||
- "global.json"
|
|
||||||
- ".gitea/workflows/ci-dotnet.yml"
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "src/**"
|
|
||||||
- "tests/**"
|
|
||||||
- "ClaudeMailbox.slnx"
|
|
||||||
- "global.json"
|
|
||||||
- ".gitea/workflows/ci-dotnet.yml"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
DOTNET_ROOT: /home/mika/.dotnet
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: https://github.com/actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
export PATH="$DOTNET_ROOT:$PATH"
|
|
||||||
dotnet build tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj -c Release
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
export PATH="$DOTNET_ROOT:$PATH"
|
|
||||||
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj \
|
|
||||||
-c Release --no-build --logger "console;verbosity=normal"
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
DOTNET_ROOT: /home/mika/.dotnet
|
|
||||||
GITEA_API: https://git.kuns.dev/api/v1
|
|
||||||
REPO: releases/ClaudeMailbox
|
|
||||||
steps:
|
|
||||||
- name: Checkout tag
|
|
||||||
uses: https://github.com/actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Resolve version
|
|
||||||
id: ver
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TAG="${{ github.ref_name }}"
|
|
||||||
VERSION="${TAG#v}"
|
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Building version: $VERSION (tag: $TAG)"
|
|
||||||
|
|
||||||
- name: Publish (win-x64, self-contained, single-file)
|
|
||||||
env:
|
|
||||||
VERSION: ${{ steps.ver.outputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
export PATH="$DOTNET_ROOT:$PATH"
|
|
||||||
dotnet publish src/ClaudeMailbox/ClaudeMailbox.csproj \
|
|
||||||
-c Release -r win-x64 --self-contained true \
|
|
||||||
/p:MinVerVersionOverride=$VERSION \
|
|
||||||
/p:PublishSingleFile=true \
|
|
||||||
/p:IncludeNativeLibrariesForSelfExtract=true \
|
|
||||||
-o out/app
|
|
||||||
|
|
||||||
- name: Package assets
|
|
||||||
env:
|
|
||||||
VERSION: ${{ steps.ver.outputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p assets
|
|
||||||
|
|
||||||
EXE_SRC=$(ls out/app/*.exe | head -n 1)
|
|
||||||
if [ -z "$EXE_SRC" ]; then
|
|
||||||
echo "::error::No .exe produced by publish" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
EXE_NAME="claude-mailbox-${VERSION}-win-x64.exe"
|
|
||||||
cp "$EXE_SRC" "assets/${EXE_NAME}"
|
|
||||||
|
|
||||||
( cd assets && sha256sum "${EXE_NAME}" > checksums.txt )
|
|
||||||
|
|
||||||
echo "--- assets ---"
|
|
||||||
ls -la assets
|
|
||||||
|
|
||||||
- name: Create Gitea Release
|
|
||||||
id: release
|
|
||||||
env:
|
|
||||||
TAG: ${{ steps.ver.outputs.tag }}
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
BODY=$(jq -n \
|
|
||||||
--arg tag "$TAG" \
|
|
||||||
--arg name "$TAG" \
|
|
||||||
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
|
|
||||||
RESP=$(curl -sS -X POST \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$BODY" \
|
|
||||||
"${GITEA_API}/repos/${REPO}/releases")
|
|
||||||
RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
|
|
||||||
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
|
||||||
echo "::error::Release creation failed" >&2
|
|
||||||
echo "$RESP" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Created release id=$RELEASE_ID for tag=$TAG"
|
|
||||||
|
|
||||||
- name: Upload release assets
|
|
||||||
env:
|
|
||||||
VERSION: ${{ steps.ver.outputs.version }}
|
|
||||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd assets
|
|
||||||
for f in \
|
|
||||||
"claude-mailbox-${VERSION}-win-x64.exe" \
|
|
||||||
"checksums.txt"
|
|
||||||
do
|
|
||||||
echo "Uploading: $f"
|
|
||||||
curl -sS --fail-with-body -X POST \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-F "attachment=@${f}" \
|
|
||||||
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${f}" \
|
|
||||||
> /dev/null
|
|
||||||
done
|
|
||||||
echo "All assets uploaded."
|
|
||||||
@@ -36,7 +36,17 @@ jobs:
|
|||||||
- name: Set package version
|
- name: Set package version
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.ver.outputs.version }}
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
run: npm version --no-git-tag-version "$VERSION"
|
run: npm version --no-git-tag-version --allow-same-version "$VERSION"
|
||||||
|
|
||||||
|
- name: Sync plugin.json version
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
jq --arg v "$VERSION" '.version = $v' plugin/.claude-plugin/plugin.json > plugin/.claude-plugin/plugin.json.tmp
|
||||||
|
mv plugin/.claude-plugin/plugin.json.tmp plugin/.claude-plugin/plugin.json
|
||||||
|
cat plugin/.claude-plugin/plugin.json
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -44,9 +54,6 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Pack
|
- name: Pack
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.ver.outputs.version }}
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
@@ -78,7 +85,7 @@ jobs:
|
|||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Try to find an existing release for this tag (the .NET workflow may have created it).
|
# Try to find an existing release for this tag (idempotent re-runs).
|
||||||
EXISTING=$(curl -sS \
|
EXISTING=$(curl -sS \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
"${GITEA_API}/repos/${REPO}/releases/tags/${TAG}" || echo "")
|
"${GITEA_API}/repos/${REPO}/releases/tags/${TAG}" || echo "")
|
||||||
|
|||||||
109
CLAUDE.md
Normal file
109
CLAUDE.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Repo layout (important)
|
||||||
|
|
||||||
|
The active codebase lives entirely in **`node/`** (TypeScript, Node 24+). The top-level `src/ClaudeMailbox/` and `tests/ClaudeMailbox.Tests/` directories are stale .NET build artifacts (`bin/`, `obj/` only) left over from an abandoned C# prototype — ignore them, don't build them, don't grep them.
|
||||||
|
|
||||||
|
Other top-level dirs:
|
||||||
|
|
||||||
|
- `plugin/` — the Claude Code plugin (`hooks/hooks.json`, slash `commands/`, the `mailbox-collaborate` skill). Loaded via `.claude-plugin/marketplace.json`.
|
||||||
|
- `homebrew/`, `install.ps1`, `install.sh` — distribution shims for the published npm package `@kuns/claude-mailbox`.
|
||||||
|
- `docs/superpowers/` — design specs and plans (history, not runtime).
|
||||||
|
|
||||||
|
## Development commands
|
||||||
|
|
||||||
|
All commands run from `node/`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd node
|
||||||
|
npm install
|
||||||
|
npm run build # tsc → dist/
|
||||||
|
npm test # pretest builds, then vitest run
|
||||||
|
npm run test:watch # vitest in watch mode
|
||||||
|
npx vitest run tests/cli-watch.test.ts # single test file
|
||||||
|
npx vitest run -t "rename" # by test name pattern
|
||||||
|
npm start # node dist/cli.js serve (run daemon in foreground)
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm test` runs `pretest` (build) first — vitest executes against `dist/`, not via a TS transformer, so always rebuild after editing `src/` if running vitest directly.
|
||||||
|
|
||||||
|
Vitest config: `tests/**/*.test.ts`, `pool: "forks"`, 15 s timeout. Tests spin up real Fastify servers and real SQLite files in temp dirs — they are integration tests, not unit tests with mocks.
|
||||||
|
|
||||||
|
## Runtime CLI surface (after `npm run build`)
|
||||||
|
|
||||||
|
`dist/cli.js` is the single entry point (bin name `claude-mailbox`):
|
||||||
|
|
||||||
|
```
|
||||||
|
serve | send | peek | check [--hook] | watch --block | list | status
|
||||||
|
session-announce # SessionStart hook helper, reads stdin JSON
|
||||||
|
session-end # SessionEnd hook helper, deletes mailbox if empty
|
||||||
|
install-hook / uninstall-hook # patch settings.json
|
||||||
|
install-autostart / uninstall-autostart [--service] # OS autostart registration
|
||||||
|
mcp-stdio # stdio MCP wrapper that proxies to the HTTP daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
All subcommands accept `--url <url>`; `CLAUDE_MAILBOX_URL` env overrides the default `http://127.0.0.1:37849`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
One long-running daemon, single SQLite file, multiple thin clients.
|
||||||
|
|
||||||
|
```
|
||||||
|
clients (Claude sessions, CLI, scripts)
|
||||||
|
│ HTTP loopback
|
||||||
|
▼
|
||||||
|
claude-mailbox serve (Fastify, port 37849)
|
||||||
|
├─ /mcp MCP Streamable HTTP transport (registerMcp)
|
||||||
|
├─ /v1/send /peek /check-inbox /watch /list REST
|
||||||
|
└─ /health
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
~/.claude-mailbox/mailbox.db (SQLite WAL, two tables: mailboxes, messages)
|
||||||
|
```
|
||||||
|
|
||||||
|
Module map under `node/src/`:
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `cli.ts` | Commander CLI; dispatches to every other module. Single entry. |
|
||||||
|
| `server.ts` | Fastify app, request hook that enforces `X-Mailbox` header on non-anonymous paths, REST routes. |
|
||||||
|
| `mcp.ts` | Registers MCP tools (`send`, `check_inbox`, `peek_inbox`, `list_mailboxes`, `rename`) on the same Fastify app at `/mcp`. |
|
||||||
|
| `mcp-stdio.ts` | Stdio MCP wrapper used by the plugin's `.mcp.json` — proxies tool calls to the HTTP daemon (workaround for Claude Code not yet supporting env-var substitution in HTTP MCP URLs). |
|
||||||
|
| `db.ts` | `MailboxStore` — all SQL via `node:sqlite` (no ORM). Owns DDL idempotency, atomic `check_inbox`, rename-with-message-transfer, long-poll `wait` for push delivery. |
|
||||||
|
| `hook.ts` | Helpers shared by `session-announce` / `check --hook` / `install-hook`: stdin parsing, identity derivation (`<project>-<8hex>` from `session_id`), settings.json patching, peer formatting. |
|
||||||
|
| `config.ts` | Config precedence: CLI flag > `mailbox.json` > defaults. Looks in `~/.claude-mailbox/mailbox.json` and on Windows also `%ProgramData%\ClaudeMailbox\mailbox.json`. |
|
||||||
|
| `autostart/{windows,darwin,linux}.ts` | Per-OS autostart: Scheduled Task / HKCU Run / `node-windows` service / launchd LaunchAgent / systemd `--user`. Selected via `autostart/index.ts`. |
|
||||||
|
|
||||||
|
### Identity derivation
|
||||||
|
|
||||||
|
`hook.ts::deriveSessionName` builds the mailbox name from the SessionStart hook's stdin JSON (`session_id`, `cwd`): `<sanitized-project>-<first-8-hex-of-session-id>`. Project name is the git repo basename if inside a repo, else the cwd basename, sanitized (lowercased, non-alphanumerics → `-`, capped at 40 chars). Without a cwd it degrades to `claude-<8hex>`. This is how two parallel sessions in the same project stay distinct.
|
||||||
|
|
||||||
|
### Push delivery (`watch --block`)
|
||||||
|
|
||||||
|
Long-poll on `GET /v1/watch?name=…&timeout=…`. The daemon parks the request on an in-memory waiter list keyed by mailbox; `send` wakes the first waiter atomically (FIFO winner across concurrent watchers). Exit codes: `0` delivered (or `Mailbox renamed to '<new>'` on stdout), `2` daemon unreachable, `3` timeout. Push is **opt-in** — the plugin's `SessionStart` hook does *not* launch the watcher automatically; users invoke the `mailbox-collaborate` skill (or `/collaborate`) to enter collaboration mode. The pull path via `UserPromptSubmit` / `SubagentStop` hooks remains the always-on fallback.
|
||||||
|
|
||||||
|
### Plugin hooks (what runs without you doing anything)
|
||||||
|
|
||||||
|
`plugin/hooks/hooks.json` wires five hooks to the installed `claude-mailbox` binary:
|
||||||
|
|
||||||
|
- `SessionStart` → `claude-mailbox session-announce` (prints identity + active peers into context, registers session with daemon)
|
||||||
|
- `UserPromptSubmit` → `claude-mailbox check --hook` (drains inbox, injects messages)
|
||||||
|
- `SubagentStop` → `claude-mailbox check --hook` (same, when a subagent finishes)
|
||||||
|
- `TaskCompleted` → `claude-mailbox check --hook` (same, when Claude marks a TaskCreate task completed — gives mid-run sync points between todo items)
|
||||||
|
- `SessionEnd` → `claude-mailbox session-end` (deletes the session's mailbox if no pending messages — same semantics as the sweeper, just immediate; renamed mailboxes are preserved because the auto-derived name no longer exists)
|
||||||
|
|
||||||
|
`install-hook` / `uninstall-hook` patch the same five events into `settings.json` for users not using the plugin. The manual installer is multi-event aware — adding/removing a hook in `plugin/hooks/hooks.json` should be mirrored by `MANAGED_HOOK_EVENTS` and `buildPluginHookCommands` in `hook.ts`.
|
||||||
|
|
||||||
|
## Conventions worth knowing
|
||||||
|
|
||||||
|
- ES modules with `NodeNext` resolution — relative imports must use the `.js` extension even in `.ts` source (e.g. `import { … } from "./db.js"`).
|
||||||
|
- `tsconfig.json` is strict including `noUnusedLocals` / `noUnusedParameters` — dead identifiers fail the build.
|
||||||
|
- `node:sqlite` is used directly (no `better-sqlite3`) — Node 24+ requirement comes from this.
|
||||||
|
- The daemon **never authenticates**: loopback bind + filesystem perms are the trust boundary. `X-Mailbox` header is identity, not auth — anything on loopback can claim any name. Don't add code that assumes otherwise.
|
||||||
|
- `*.db`, `*.db-shm`, `*.db-wal` are gitignored — never commit a real mailbox DB.
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
`node/package.json` is the published artifact (`@kuns/claude-mailbox`, registry `https://git.kuns.dev/api/packages/releases/npm/`). Version bumps land as `chore(release): X.Y.Z` commits (see git log). The plugin and the npm package version-lockstep — `mailbox-doctor` and `mailbox-update` slash commands run `npm install -g @kuns/claude-mailbox` against that registry.
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<Solution>
|
|
||||||
<Folder Name="/src/">
|
|
||||||
<Project Path="src/ClaudeMailbox/ClaudeMailbox.csproj" />
|
|
||||||
</Folder>
|
|
||||||
<Folder Name="/tests/">
|
|
||||||
<Project Path="tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj" />
|
|
||||||
</Folder>
|
|
||||||
</Solution>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<MinVerTagPrefix>v</MinVerTagPrefix>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="MinVer" Version="5.0.0" PrivateAssets="all" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<configuration>
|
|
||||||
<packageSources>
|
|
||||||
<clear />
|
|
||||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
|
||||||
</packageSources>
|
|
||||||
</configuration>
|
|
||||||
298
README.md
298
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.git
|
||||||
(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,89 +66,79 @@ 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
|
|
||||||
brew install kuns/tap/claude-mailbox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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 status # Running | Stopped | NotInstalled
|
|
||||||
claude-mailbox uninstall-autostart [--purge]
|
|
||||||
```
|
|
||||||
|
|
||||||
| Platform | Default mechanism | `--service` mechanism |
|
|
||||||
|---|---|---|
|
|
||||||
| Windows | Scheduled Task at logon (no admin) | Windows Service (admin, via `node-windows`) |
|
|
||||||
| 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
|
|
||||||
```
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
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
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"mailbox": {
|
"mailbox": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://127.0.0.1:47822/mcp",
|
"url": "http://127.0.0.1:37849/mcp"
|
||||||
"headers": {
|
|
||||||
"X-Mailbox": "backend"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Four MCP tools are exposed:
|
Optionally add a static identity (so your client doesn't need to pass `from` / `name` on every call):
|
||||||
|
|
||||||
| Tool | Purpose |
|
```json
|
||||||
|
"headers": { "X-Mailbox": "backend" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How identity works
|
||||||
|
|
||||||
|
Every Claude Code session gets a unique mailbox name automatically derived as `<project>-<8-hex-of-session-id>`:
|
||||||
|
|
||||||
|
| Setup | Resulting mailbox name |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `mcp__mailbox__send(to, body)` | Send a message to another mailbox |
|
| Inside a git repo | `<repo-basename>-<8-hex>` (e.g. `claude-mailbox-a3f91b2c`) |
|
||||||
| `mcp__mailbox__check_inbox()` | Pull all pending messages for this mailbox (marks delivered) |
|
| Outside a git repo | `<cwd-basename>-<8-hex>` |
|
||||||
| `mcp__mailbox__peek_inbox()` | Non-consuming check — returns `{ pending, oldestAt }` |
|
| No cwd available (rare) | `claude-<8-hex>` |
|
||||||
| `mcp__mailbox__list_mailboxes()` | Discover known mailboxes and who has mail for you |
|
| Manual `.mcp.json` with `X-Mailbox: backend` header (no plugin) | `backend` (legacy mode) |
|
||||||
|
|
||||||
|
Project names are sanitized (lowercased, non-alphanumerics → dashes, capped at 40 chars). 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.
|
||||||
|
|
||||||
|
### Renaming at runtime
|
||||||
|
|
||||||
|
Claude can refine its own mailbox name during the session — useful when a session focuses on a specific area (e.g. only frontend work):
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__mailbox__rename(current_name="claude-mailbox-a3f91b2c", new_name="claude-mailbox-frontend-a3f91b2c")
|
||||||
|
```
|
||||||
|
|
||||||
|
Pending messages are transferred to the new name in a single transaction. The old name is removed — peers using it must re-discover via `list_mailboxes`. The endpoint returns `409` if the target name is already in use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Autostart
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude-mailbox install-autostart # per-user, no admin
|
||||||
|
claude-mailbox install-autostart --service # Windows only: Windows Service (admin)
|
||||||
|
claude-mailbox status # Running | Stopped | NotInstalled
|
||||||
|
claude-mailbox uninstall-autostart [--purge]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Platform | Default mechanism | `--service` mechanism |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows | Scheduled Task at logon (no admin); falls back to HKCU Run-key if Group Policy blocks schtasks | Windows Service (admin, via `node-windows`) |
|
||||||
|
| macOS | launchd LaunchAgent in `~/Library/LaunchAgents/` | n/a |
|
||||||
|
| Linux | systemd `--user` unit in `~/.config/systemd/user/` | n/a |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP tools
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
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
|
### Suggested CLAUDE.md snippet for poll discipline
|
||||||
|
|
||||||
@@ -139,41 +148,118 @@ 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:
|
## Push delivery (watch)
|
||||||
|
|
||||||
|
The `watch --block` subcommand turns mail delivery from pull (poll between turns) into push (the receiver reacts as soon as a peer sends). It's a long-poll that exits the moment one message arrives.
|
||||||
|
|
||||||
```
|
```
|
||||||
claude-mailbox send --to <mailbox> --from <mailbox> --body <text> [--url http://127.0.0.1:47822]
|
claude-mailbox watch --block --name <mailbox> [--timeout 25] [--url <daemon>]
|
||||||
claude-mailbox peek --name <mailbox> [--url ...]
|
|
||||||
claude-mailbox check --name <mailbox> [--url ...]
|
|
||||||
claude-mailbox list [--url ...]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI subcommands are thin HTTP clients against the `/v1/*` endpoints.
|
Intended use: a Claude Code background bash task. The plugin's `SessionStart` hook now tells Claude to start one on its first turn, so peers can `mcp__mailbox__send` to it and Claude reacts mid-session via `BashOutput` — no user prompt needed. After every exit Claude relaunches the watcher in the background.
|
||||||
|
|
||||||
|
| Exit code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `0` | One message delivered (or mailbox renamed — stdout disambiguates) |
|
||||||
|
| `1` | Generic error (e.g. missing `--name`) |
|
||||||
|
| `2` | Daemon unreachable |
|
||||||
|
| `3` | Timeout reached with no message |
|
||||||
|
|
||||||
|
The CLI consumes exactly one message per cycle (single-delivery, FIFO winner across concurrent watchers on the same mailbox). Backlog drains one message per reconnect (~100 ms turnaround).
|
||||||
|
|
||||||
|
Cross-process semantics:
|
||||||
|
- **Concurrent watchers on the same mailbox:** the first to register wins each individual message; others continue waiting.
|
||||||
|
- **Rename mid-watch:** the open `watch` exits 0 with a `Mailbox renamed to '<new>'` notice; relaunch with the new `--name`.
|
||||||
|
- **Daemon restart:** all watchers see exit 2; back off and retry.
|
||||||
|
- **Session end:** Claude Code reaps background bash on exit; the `fetch` aborts and the daemon-side waiter is cleaned up.
|
||||||
|
|
||||||
|
**When push helps:** during active turns where the receiver is busy with tool calls — `BashOutput` notifications surface between tool calls, so peer messages arrive mid-turn. **When push degrades to pull:** when the receiver is idle between turns, BashOutput is buffered until the next user prompt, at which point the existing `UserPromptSubmit` poll hook delivers the same message. The two channels coexist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
Any external process — scripts, UIs, manual debugging — can talk to a running daemon directly:
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-mailbox send --from <mailbox> --to <mailbox> --body <text>
|
||||||
|
claude-mailbox peek --name <mailbox>
|
||||||
|
claude-mailbox check --name <mailbox> [--hook]
|
||||||
|
claude-mailbox watch --block --name <mailbox> [--timeout 25]
|
||||||
|
claude-mailbox list
|
||||||
|
claude-mailbox status
|
||||||
|
claude-mailbox session-announce # hook helper, reads stdin JSON
|
||||||
|
claude-mailbox install-hook [--user|--project] [--url <url>]
|
||||||
|
claude-mailbox uninstall-hook [--user|--project]
|
||||||
|
```
|
||||||
|
|
||||||
|
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/watch?name=<mailbox>&timeout=<sec>` | yes (must match `name`) | long-poll one message: `200` + body / `204` timeout / `409 { reason: "renamed", to }` |
|
||||||
|
| `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 `37849`, 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 (Fastify)
|
||||||
|
/mcp MCP tools
|
||||||
|
/v1/* REST for non-MCP senders
|
||||||
|
/health
|
||||||
|
v
|
||||||
|
~/.claude-mailbox/mailbox.db (SQLite WAL)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```
|
```sh
|
||||||
dotnet build
|
cd node
|
||||||
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj
|
npm install
|
||||||
dotnet run --project src/ClaudeMailbox -- serve
|
npm run build
|
||||||
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Test suite covers end-to-end coordination, concurrent `check_inbox` race safety, and schema idempotency.
|
The test suite covers 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.
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"sdk": {
|
|
||||||
"version": "8.0.418",
|
|
||||||
"rollForward": "latestFeature"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# @kuns/claude-mailbox
|
# @kuns/claude-mailbox
|
||||||
|
|
||||||
Standalone MCP mail server that lets parallel Claude sessions coordinate with each other. TypeScript / Node port of the .NET `claude-mailbox` daemon — wire-compatible (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema).
|
Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -17,4 +17,56 @@ 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 hooks (auto-check inbox)
|
||||||
|
|
||||||
|
Register the full plugin-equivalent hook set so Claude pulls pending mailbox messages at every natural sync point:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude-mailbox install-hook # patches ~/.claude/settings.json
|
||||||
|
claude-mailbox install-hook --project # patches <cwd>/.claude/settings.json
|
||||||
|
claude-mailbox uninstall-hook # remove again
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs five hooks:
|
||||||
|
|
||||||
|
| Event | Command | When it fires |
|
||||||
|
|---|---|---|
|
||||||
|
| `SessionStart` | `session-announce` | Announces this session's mailbox identity + active peers. |
|
||||||
|
| `UserPromptSubmit` | `check --hook` | Before each user prompt. |
|
||||||
|
| `SubagentStop` | `check --hook` | When a subagent finishes. |
|
||||||
|
| `TaskCompleted` | `check --hook` | When Claude marks a `TaskCreate` task completed — gives mid-run sync points. |
|
||||||
|
| `SessionEnd` | `session-end` | Cleans up the auto-derived mailbox if empty. |
|
||||||
|
|
||||||
|
The mailbox name is auto-derived from the session-id stdin payload — no `--name` required. Install is idempotent and only touches our own commands; other hooks and settings are preserved.
|
||||||
|
|
||||||
|
`check --hook`:
|
||||||
|
|
||||||
|
- 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 fire (~100ms on Windows).
|
||||||
|
|
||||||
|
## Push delivery (watch)
|
||||||
|
|
||||||
|
For long-running autonomous sessions, run the watcher as a background bash task so peer messages surface immediately via `BashOutput`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude-mailbox watch --block --name <mailbox>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit codes: `0` delivered or renamed, `1` error, `2` daemon unreachable, `3` timeout. See the [repository README](https://git.kuns.dev/releases/ClaudeMailbox/src/branch/main/README.md#push-delivery-watch) for the full contract.
|
||||||
|
|
||||||
|
## 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>
|
||||||
|
```
|
||||||
|
|
||||||
|
`Cannot find module 'node:sqlite'` or similar
|
||||||
|
: claude-mailbox uses Node's built-in `node:sqlite`, stable since Node 24. On Node 22.5–23.x it works only with `--experimental-sqlite`. Upgrade to Node 24 LTS or newer: `nvm install 24 && nvm use 24` (or `winget install OpenJS.NodeJS.LTS` on Windows).
|
||||||
|
|||||||
2005
node/package-lock.json
generated
2005
node/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "0.0.0",
|
"version": "1.5.6",
|
||||||
"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,17 +13,17 @@
|
|||||||
],
|
],
|
||||||
"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",
|
||||||
"prepack": "npm run build"
|
"prepack": "npm run build"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"better-sqlite3": "^11.3.0",
|
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"zod": "^3.25.0"
|
"zod": "^3.25.0"
|
||||||
@@ -32,10 +32,9 @@
|
|||||||
"node-windows": "^1.0.0-beta.8"
|
"node-windows": "^1.0.0-beta.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
|
||||||
"@types/node": "^22.7.4",
|
"@types/node": "^22.7.4",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vitest": "^2.1.1"
|
"vitest": "^4.1.6"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mcp",
|
"mcp",
|
||||||
|
|||||||
@@ -1,10 +1,41 @@
|
|||||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
|
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
|
||||||
import { userConfigPath } from "../config.js";
|
import { userConfigPath } from "../config.js";
|
||||||
|
|
||||||
|
function markerPath(): string {
|
||||||
|
return join(dirname(userConfigPath()), MARKER_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readActiveMode(): "task" | "run-key" | null {
|
||||||
|
const path = markerPath();
|
||||||
|
if (!existsSync(path)) return null;
|
||||||
|
const raw = readFileSync(path, "utf8").trim();
|
||||||
|
if (raw === "task" || raw === "run-key") return raw;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeActiveMode(mode: "task" | "run-key"): void {
|
||||||
|
const path = markerPath();
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, mode, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveMode(): void {
|
||||||
|
const path = markerPath();
|
||||||
|
if (existsSync(path)) rmSync(path, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAccessDenied(stderr: string): boolean {
|
||||||
|
return /access is denied|0x80070005/i.test(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
const TASK_NAME = "ClaudeMailbox";
|
const TASK_NAME = "ClaudeMailbox";
|
||||||
const SERVICE_NAME = "ClaudeMailbox";
|
const SERVICE_NAME = "ClaudeMailbox";
|
||||||
|
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
||||||
|
const RUN_VALUE = "ClaudeMailbox";
|
||||||
|
const MARKER_FILE = "autostart-mode";
|
||||||
|
const LAUNCHER_FILE = "autostart-launcher.vbs";
|
||||||
|
|
||||||
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
|
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
|
||||||
const path = userConfigPath();
|
const path = userConfigPath();
|
||||||
@@ -24,10 +55,14 @@ function buildServeCommand(): { node: string; script: string; configPath: string
|
|||||||
return { node, script, configPath: userConfigPath() };
|
return { node, script, configPath: userConfigPath() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduledTaskInstall(opts: AutostartInstallOpts): void {
|
function buildServeCommandString(configPath: string): string {
|
||||||
const configPath = ensureConfigSeeded(opts);
|
|
||||||
const { node, script } = buildServeCommand();
|
const { node, script } = buildServeCommand();
|
||||||
const tr = `"${node}" "${script}" serve --config "${configPath}"`;
|
return `"${node}" "${script}" serve --config "${configPath}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryScheduledTaskInstall(opts: AutostartInstallOpts): { ok: boolean; stderr: string } {
|
||||||
|
const configPath = ensureConfigSeeded(opts);
|
||||||
|
const tr = buildServeCommandString(configPath);
|
||||||
const r = run("schtasks.exe", [
|
const r = run("schtasks.exe", [
|
||||||
"/Create",
|
"/Create",
|
||||||
"/SC",
|
"/SC",
|
||||||
@@ -40,28 +75,136 @@ function scheduledTaskInstall(opts: AutostartInstallOpts): void {
|
|||||||
"LIMITED",
|
"LIMITED",
|
||||||
"/F",
|
"/F",
|
||||||
]);
|
]);
|
||||||
|
if (r.status !== 0) return { ok: false, stderr: r.stderr || r.stdout };
|
||||||
|
run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
|
||||||
|
return { ok: true, stderr: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runKeyLauncherPath(): string {
|
||||||
|
return join(dirname(userConfigPath()), LAUNCHER_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRunKeyLauncher(configPath: string): string {
|
||||||
|
const { node, script } = buildServeCommand();
|
||||||
|
const cmd = `"${node}" "${script}" serve --config "${configPath}"`;
|
||||||
|
const escaped = cmd.replace(/"/g, '""');
|
||||||
|
const vbs =
|
||||||
|
`Set WshShell = CreateObject("WScript.Shell")\r\n` +
|
||||||
|
`WshShell.Run "${escaped}", 0, False\r\n` +
|
||||||
|
`Set WshShell = Nothing\r\n`;
|
||||||
|
const path = runKeyLauncherPath();
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, vbs, "utf8");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRunKeyLauncher(): void {
|
||||||
|
const path = runKeyLauncherPath();
|
||||||
|
if (existsSync(path)) rmSync(path, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function runKeyInstall(opts: AutostartInstallOpts): void {
|
||||||
|
const configPath = ensureConfigSeeded(opts);
|
||||||
|
const launcher = writeRunKeyLauncher(configPath);
|
||||||
|
const cmd = `wscript.exe "${launcher}"`;
|
||||||
|
const r = run("reg.exe", [
|
||||||
|
"add",
|
||||||
|
RUN_KEY,
|
||||||
|
"/v",
|
||||||
|
RUN_VALUE,
|
||||||
|
"/t",
|
||||||
|
"REG_SZ",
|
||||||
|
"/d",
|
||||||
|
cmd,
|
||||||
|
"/f",
|
||||||
|
]);
|
||||||
if (r.status !== 0) {
|
if (r.status !== 0) {
|
||||||
throw new Error(`schtasks /Create failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
throw new Error(`reg add (HKCU Run) failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
||||||
}
|
}
|
||||||
const start = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
|
spawnRunKeyDaemon(configPath);
|
||||||
if (start.status !== 0) {
|
}
|
||||||
console.warn(`Task created but /Run returned ${start.status}: ${start.stderr.trim()}`);
|
|
||||||
|
function spawnRunKeyDaemon(configPath: string): void {
|
||||||
|
if (runKeyDaemonRunning()) return;
|
||||||
|
const { node, script } = buildServeCommand();
|
||||||
|
const ps = `Start-Process -WindowStyle Hidden -FilePath "${node}" -ArgumentList @('"${script}"','serve','--config','"${configPath}"')`;
|
||||||
|
run("powershell.exe", ["-NoProfile", "-Command", ps]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runKeyDaemonRunning(): boolean {
|
||||||
|
const ps = `Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*claude-mailbox*serve*' } | Select-Object -First 1 -ExpandProperty ProcessId`;
|
||||||
|
const r = run("powershell.exe", ["-NoProfile", "-Command", ps]);
|
||||||
|
return r.status === 0 && r.stdout.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function killRunKeyDaemon(): void {
|
||||||
|
const ps = `Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*claude-mailbox*serve*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`;
|
||||||
|
run("powershell.exe", ["-NoProfile", "-Command", ps]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runKeyUninstall(): void {
|
||||||
|
const r = run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
|
||||||
|
if (r.status !== 0 && !/unable to find/i.test(r.stderr)) {
|
||||||
|
throw new Error(`reg delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
||||||
}
|
}
|
||||||
|
removeRunKeyLauncher();
|
||||||
|
killRunKeyDaemon();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduledTaskInstall(opts: AutostartInstallOpts): void {
|
||||||
|
const attempt = tryScheduledTaskInstall(opts);
|
||||||
|
if (attempt.ok) {
|
||||||
|
writeActiveMode("task");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAccessDenied(attempt.stderr)) {
|
||||||
|
console.warn(
|
||||||
|
"schtasks /Create denied by Windows policy — falling back to HKCU Run-key autostart (per-user, no admin).",
|
||||||
|
);
|
||||||
|
runKeyInstall(opts);
|
||||||
|
writeActiveMode("run-key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`schtasks /Create failed: ${attempt.stderr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduledTaskUninstall(purge: boolean): void {
|
function scheduledTaskUninstall(purge: boolean): void {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
runKeyUninstall();
|
||||||
|
clearActiveMode();
|
||||||
|
if (purge) purgeData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Default to task uninstall, also clean up Run-key in case of mixed state
|
||||||
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
|
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
|
||||||
const r = run("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"]);
|
const r = run("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"]);
|
||||||
if (r.status !== 0 && !/cannot find/i.test(r.stderr)) {
|
if (r.status !== 0 && !/cannot find/i.test(r.stderr) && !/does not exist/i.test(r.stderr)) {
|
||||||
throw new Error(`schtasks /Delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
// Fall through — try Run-key cleanup anyway
|
||||||
}
|
}
|
||||||
|
// Best-effort Run-key cleanup
|
||||||
|
run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
|
||||||
|
removeRunKeyLauncher();
|
||||||
|
killRunKeyDaemon();
|
||||||
|
clearActiveMode();
|
||||||
if (purge) purgeData();
|
if (purge) purgeData();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" {
|
function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
const r = run("reg.exe", ["query", RUN_KEY, "/v", RUN_VALUE]);
|
||||||
|
if (r.status !== 0) return "NotInstalled";
|
||||||
|
return runKeyDaemonRunning() ? "Running" : "Stopped";
|
||||||
|
}
|
||||||
const r = run("schtasks.exe", ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]);
|
const r = run("schtasks.exe", ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]);
|
||||||
if (r.status !== 0) {
|
if (r.status !== 0) {
|
||||||
if (/cannot find/i.test(r.stderr) || /does not exist/i.test(r.stderr)) return "NotInstalled";
|
if (/cannot find/i.test(r.stderr) || /does not exist/i.test(r.stderr)) {
|
||||||
|
// Maybe a Run-key install happened without a marker (legacy / manual). Check reg.
|
||||||
|
const reg = run("reg.exe", ["query", RUN_KEY, "/v", RUN_VALUE]);
|
||||||
|
if (reg.status === 0) return runKeyDaemonRunning() ? "Running" : "Stopped";
|
||||||
|
return "NotInstalled";
|
||||||
|
}
|
||||||
return "Stopped";
|
return "Stopped";
|
||||||
}
|
}
|
||||||
if (/Status:\s*Running/i.test(r.stdout)) return "Running";
|
if (/Status:\s*Running/i.test(r.stdout)) return "Running";
|
||||||
@@ -69,11 +212,22 @@ function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduledTaskRun(): void {
|
function scheduledTaskRun(): void {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
const cfgPath = userConfigPath();
|
||||||
|
spawnRunKeyDaemon(cfgPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const r = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
|
const r = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
|
||||||
if (r.status !== 0) throw new Error(`schtasks /Run failed: ${r.stderr || r.stdout}`);
|
if (r.status !== 0) throw new Error(`schtasks /Run failed: ${r.stderr || r.stdout}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduledTaskEnd(): void {
|
function scheduledTaskEnd(): void {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
killRunKeyDaemon();
|
||||||
|
return;
|
||||||
|
}
|
||||||
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
|
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
350
node/src/cli.ts
350
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 { autostartManager } from "./autostart/index.js";
|
import { autostartManager } from "./autostart/index.js";
|
||||||
|
import { runStdioMcp } from "./mcp-stdio.js";
|
||||||
|
import {
|
||||||
|
applyInstall,
|
||||||
|
applyUninstall,
|
||||||
|
buildPluginHookCommands,
|
||||||
|
buildSessionAnnounceLines,
|
||||||
|
deriveSessionName,
|
||||||
|
formatMessagesForHook,
|
||||||
|
parseHookStdin,
|
||||||
|
readSettings,
|
||||||
|
readStdinIfPiped,
|
||||||
|
settingsPathFor,
|
||||||
|
writeSettings,
|
||||||
|
type HookMessage,
|
||||||
|
type HookScope,
|
||||||
|
type PeerEntry,
|
||||||
|
} from "./hook.js";
|
||||||
|
|
||||||
function readVersion(): string {
|
function readVersion(): string {
|
||||||
try {
|
try {
|
||||||
@@ -19,7 +35,9 @@ function readVersion(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
|
const HARDCODED_DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
|
||||||
|
const ENV_URL = (process.env["CLAUDE_MAILBOX_URL"] ?? "").trim();
|
||||||
|
const DEFAULT_URL = ENV_URL || HARDCODED_DEFAULT_URL;
|
||||||
|
|
||||||
async function callJson(
|
async function callJson(
|
||||||
method: string,
|
method: string,
|
||||||
@@ -60,9 +78,33 @@ program
|
|||||||
.option("--bind <address>", "Bind address")
|
.option("--bind <address>", "Bind address")
|
||||||
.option("--db-path <path>", "SQLite database path")
|
.option("--db-path <path>", "SQLite database path")
|
||||||
.option("--config <path>", "Path to mailbox.json")
|
.option("--config <path>", "Path to mailbox.json")
|
||||||
.action(async (opts: { port?: number; bind?: string; dbPath?: string; config?: string }) => {
|
.option(
|
||||||
|
"--hide-after-minutes <n>",
|
||||||
|
"Hide mailboxes idle longer than N minutes from list responses (0 = disabled)",
|
||||||
|
(v) => parseInt(v, 10),
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--delete-after-minutes <n>",
|
||||||
|
"Hard-delete mailboxes idle longer than N minutes (0 = disabled)",
|
||||||
|
(v) => parseInt(v, 10),
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--sweep-interval-minutes <n>",
|
||||||
|
"Stale-mailbox sweep interval in minutes (0 = disabled)",
|
||||||
|
(v) => parseInt(v, 10),
|
||||||
|
)
|
||||||
|
.action(async (opts: {
|
||||||
|
port?: number;
|
||||||
|
bind?: string;
|
||||||
|
dbPath?: string;
|
||||||
|
config?: string;
|
||||||
|
hideAfterMinutes?: number;
|
||||||
|
deleteAfterMinutes?: number;
|
||||||
|
sweepIntervalMinutes?: number;
|
||||||
|
}) => {
|
||||||
const cfg = resolveConfig(opts);
|
const cfg = resolveConfig(opts);
|
||||||
try {
|
try {
|
||||||
|
const { startServer } = await import("./server.js");
|
||||||
const { app } = await startServer(cfg);
|
const { app } = await startServer(cfg);
|
||||||
app.log.info(`ClaudeMailbox listening on ${baseUrl(cfg)} (db: ${cfg.dbPath})`);
|
app.log.info(`ClaudeMailbox listening on ${baseUrl(cfg)} (db: ${cfg.dbPath})`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -114,24 +156,136 @@ 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) return null;
|
||||||
|
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||||
|
return deriveSessionName(sid, cwd);
|
||||||
|
}
|
||||||
|
|
||||||
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: <project>-<session-short>, where <project> is the git-repo or cwd basename from stdin.",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--name <name>",
|
||||||
|
"Explicit mailbox name. Overrides hook stdin auto-derivation.",
|
||||||
|
)
|
||||||
.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 ?? "").trim() || null;
|
||||||
|
if (!name) {
|
||||||
|
if (opts.hook) return;
|
||||||
|
console.error("Missing --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 cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||||
|
const name = deriveSessionName(sid, cwd);
|
||||||
|
|
||||||
|
let peers: PeerEntry[] = [];
|
||||||
|
let daemonError: string | null = null;
|
||||||
|
try {
|
||||||
|
const out = await callJson("GET", `${opts.url}/v1/list`, {
|
||||||
|
headers: { "X-Mailbox": name },
|
||||||
|
});
|
||||||
|
peers = (Array.isArray(out) ? out : []) as PeerEntry[];
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
|
||||||
|
daemonError = `[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = buildSessionAnnounceLines({
|
||||||
|
name,
|
||||||
|
peers,
|
||||||
|
windowMinutes: opts.peerWindowMinutes,
|
||||||
|
maxPeers: opts.maxPeers,
|
||||||
|
daemonError: daemonError ?? undefined,
|
||||||
|
});
|
||||||
|
lines.push("");
|
||||||
|
process.stdout.write(lines.join("\n"));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("session-end")
|
||||||
|
.description(
|
||||||
|
"SessionEnd-hook helper: derives the session's mailbox name from stdin session_id and asks the daemon to delete it if empty (no pending messages either direction). Silent on all errors — the daemon sweeper is the safety net.",
|
||||||
|
)
|
||||||
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||||
|
.action(async (opts: { url: string }) => {
|
||||||
|
const stdin = parseHookStdin(readStdinIfPiped());
|
||||||
|
const sid = stdin?.session_id?.trim();
|
||||||
|
if (!sid) return;
|
||||||
|
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||||
|
const name = deriveSessionName(sid, cwd);
|
||||||
|
try {
|
||||||
|
await callJson("POST", `${opts.url}/v1/session-end`, {
|
||||||
|
headers: { "X-Mailbox": name },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Daemon unreachable or other error — sweeper will clean up later.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List known mailboxes.")
|
.description("List known mailboxes.")
|
||||||
@@ -145,6 +299,136 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("watch")
|
||||||
|
.description(
|
||||||
|
"Block until one message arrives for --name, print it, and exit. Designed to be run as a Claude Code background bash task so its output surfaces via BashOutput.",
|
||||||
|
)
|
||||||
|
.requiredOption("--name <name>", "Mailbox to watch")
|
||||||
|
.option("--block", "Long-poll for a message (default behavior; flag accepted for clarity)")
|
||||||
|
.option(
|
||||||
|
"--timeout <seconds>",
|
||||||
|
"Long-poll timeout in seconds (1..300, default 25)",
|
||||||
|
(v) => parseInt(v, 10),
|
||||||
|
25,
|
||||||
|
)
|
||||||
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||||
|
.action(async (opts: { name: string; block?: boolean; timeout: number; url: string }) => {
|
||||||
|
const url = `${opts.url}/v1/watch?name=${encodeURIComponent(opts.name)}&timeout=${opts.timeout}`;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { headers: { "X-Mailbox": opts.name, Accept: "application/json" } });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`Could not reach daemon at ${opts.url}: ${msg}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
const body = (await res.json()) as { from: string; body: string; sentAt: string };
|
||||||
|
process.stdout.write(`[Claude-Mailbox] Mail from ${body.from}:\n${body.body}\n`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 409) {
|
||||||
|
const body = (await res.json().catch(() => ({}))) as { to?: string };
|
||||||
|
const newName = body.to ?? "<unknown>";
|
||||||
|
process.stdout.write(
|
||||||
|
`[Claude-Mailbox] Mailbox renamed to '${newName}'. Restart watcher with --name ${newName}.\n`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
console.error(`watch failed: HTTP ${res.status}${text ? ` — ${text}` : ""}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("mcp-stdio")
|
||||||
|
.description(
|
||||||
|
"Run a stdio MCP server that proxies tool calls to the local daemon's REST API. The daemon URL comes from $CLAUDE_MAILBOX_URL (default http://127.0.0.1:37849). Used by the Claude Code plugin's .mcp.json so the URL is configurable per machine without env-substitution in the URL field.",
|
||||||
|
)
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
await runStdioMcp();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("install-hook")
|
||||||
|
.description(
|
||||||
|
"Install the full set of Claude Code hooks that mirror the plugin's hooks.json: SessionStart announces identity, UserPromptSubmit/SubagentStop/TaskCompleted drain the inbox, SessionEnd cleans up. Mailbox name is auto-derived from session stdin. Idempotent.",
|
||||||
|
)
|
||||||
|
.option("--user", "Patch ~/.claude/settings.json (default)")
|
||||||
|
.option("--project", "Patch <cwd>/.claude/settings.json")
|
||||||
|
.option("--url <url>", "Daemon base URL to embed in each hook command")
|
||||||
|
.action(async (opts: { 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 hooks = buildPluginHookCommands(opts.url);
|
||||||
|
|
||||||
|
const added: string[] = [];
|
||||||
|
const alreadyPresent: string[] = [];
|
||||||
|
for (const h of hooks) {
|
||||||
|
const r = applyInstall(settings, h.event, h.command);
|
||||||
|
if (r.changed) added.push(h.event);
|
||||||
|
else alreadyPresent.push(h.event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added.length > 0) {
|
||||||
|
writeSettings(path, settings);
|
||||||
|
console.log(`Hooks installed in ${path}:`);
|
||||||
|
for (const event of added) console.log(` + ${event}`);
|
||||||
|
if (alreadyPresent.length > 0) {
|
||||||
|
console.log("Already present:");
|
||||||
|
for (const event of alreadyPresent) console.log(` · ${event}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`All hooks already present in ${path}; nothing to do.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("uninstall-hook")
|
||||||
|
.description(
|
||||||
|
"Remove all claude-mailbox hooks (SessionStart, UserPromptSubmit, SubagentStop, TaskCompleted, SessionEnd) 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(
|
||||||
@@ -154,11 +438,49 @@ program
|
|||||||
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
|
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
|
||||||
.option("--bind <address>", "Bind address")
|
.option("--bind <address>", "Bind address")
|
||||||
.option("--db-path <path>", "SQLite database path")
|
.option("--db-path <path>", "SQLite database path")
|
||||||
.action(async (opts: { service?: boolean; port?: number; bind?: string; dbPath?: string }) => {
|
.option(
|
||||||
const mgr = await autostartManager(opts.service ? "service" : "default");
|
"--skip-port-check",
|
||||||
await mgr.install({ port: opts.port, bind: opts.bind, dbPath: opts.dbPath });
|
"Skip the pre-install probe for a foreign occupant on the daemon's port",
|
||||||
console.log("Autostart installed.");
|
)
|
||||||
});
|
.action(
|
||||||
|
async (opts: {
|
||||||
|
service?: boolean;
|
||||||
|
port?: number;
|
||||||
|
bind?: string;
|
||||||
|
dbPath?: string;
|
||||||
|
skipPortCheck?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!opts.skipPortCheck) {
|
||||||
|
const cfg = resolveConfig({ port: opts.port, bind: opts.bind, dbPath: opts.dbPath });
|
||||||
|
const probeUrl = `http://${cfg.bind}:${cfg.port}/health`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(probeUrl, { headers: { Accept: "application/json" } });
|
||||||
|
const text = await res.text();
|
||||||
|
let parsed: { status?: string; version?: string } | null = null;
|
||||||
|
try {
|
||||||
|
parsed = text.length ? (JSON.parse(text) as { status?: string; version?: string }) : null;
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
if (res.ok && parsed?.status === "ok" && parsed.version) {
|
||||||
|
console.log(
|
||||||
|
`Port ${cfg.port} already serves a claude-mailbox daemon (version ${parsed.version}). Autostart will manage that one.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Port ${cfg.port} is held by a non-claude-mailbox service (status ${res.status}). Pick a free port via \`--port <n>\` or write {"port": <n>} to ~/.claude-mailbox/mailbox.json. Use --skip-port-check to bypass.`,
|
||||||
|
);
|
||||||
|
process.exit(4);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Connection refused or similar — port is free, proceed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mgr = await autostartManager(opts.service ? "service" : "default");
|
||||||
|
await mgr.install({ port: opts.port, bind: opts.bind, dbPath: opts.dbPath });
|
||||||
|
console.log("Autostart installed.");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("uninstall-autostart")
|
.command("uninstall-autostart")
|
||||||
|
|||||||
@@ -2,19 +2,28 @@ import { existsSync, readFileSync } from "node:fs";
|
|||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
export const DEFAULT_PORT = 47822;
|
export const DEFAULT_PORT = 37849;
|
||||||
export const DEFAULT_BIND = "127.0.0.1";
|
export const DEFAULT_BIND = "127.0.0.1";
|
||||||
|
export const DEFAULT_HIDE_AFTER_MINUTES = 60 * 24;
|
||||||
|
export const DEFAULT_DELETE_AFTER_MINUTES = 60 * 24 * 7;
|
||||||
|
export const DEFAULT_SWEEP_INTERVAL_MINUTES = 60;
|
||||||
|
|
||||||
export interface FileConfig {
|
export interface FileConfig {
|
||||||
port?: number;
|
port?: number;
|
||||||
bind?: string;
|
bind?: string;
|
||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
|
hideAfterMinutes?: number;
|
||||||
|
deleteAfterMinutes?: number;
|
||||||
|
sweepIntervalMinutes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DaemonConfig {
|
export interface DaemonConfig {
|
||||||
port: number;
|
port: number;
|
||||||
bind: string;
|
bind: string;
|
||||||
dbPath: string;
|
dbPath: string;
|
||||||
|
hideAfterMinutes: number;
|
||||||
|
deleteAfterMinutes: number;
|
||||||
|
sweepIntervalMinutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultDbPath(): string {
|
export function defaultDbPath(): string {
|
||||||
@@ -65,6 +74,12 @@ export function loadFileConfig(explicitPath?: string): FileConfig {
|
|||||||
port: typeof parsed.port === "number" ? parsed.port : undefined,
|
port: typeof parsed.port === "number" ? parsed.port : undefined,
|
||||||
bind: typeof parsed.bind === "string" ? parsed.bind : undefined,
|
bind: typeof parsed.bind === "string" ? parsed.bind : undefined,
|
||||||
dbPath: typeof parsed.dbPath === "string" ? parsed.dbPath : undefined,
|
dbPath: typeof parsed.dbPath === "string" ? parsed.dbPath : undefined,
|
||||||
|
hideAfterMinutes:
|
||||||
|
typeof parsed.hideAfterMinutes === "number" ? parsed.hideAfterMinutes : undefined,
|
||||||
|
deleteAfterMinutes:
|
||||||
|
typeof parsed.deleteAfterMinutes === "number" ? parsed.deleteAfterMinutes : undefined,
|
||||||
|
sweepIntervalMinutes:
|
||||||
|
typeof parsed.sweepIntervalMinutes === "number" ? parsed.sweepIntervalMinutes : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +91,9 @@ export interface ServeOverrides {
|
|||||||
bind?: string;
|
bind?: string;
|
||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
config?: string;
|
config?: string;
|
||||||
|
hideAfterMinutes?: number;
|
||||||
|
deleteAfterMinutes?: number;
|
||||||
|
sweepIntervalMinutes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
|
export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
|
||||||
@@ -83,7 +101,20 @@ export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
|
|||||||
const port = overrides.port ?? file.port ?? DEFAULT_PORT;
|
const port = overrides.port ?? file.port ?? DEFAULT_PORT;
|
||||||
const bind = overrides.bind ?? file.bind ?? DEFAULT_BIND;
|
const bind = overrides.bind ?? file.bind ?? DEFAULT_BIND;
|
||||||
const dbPathRaw = overrides.dbPath ?? file.dbPath ?? defaultDbPath();
|
const dbPathRaw = overrides.dbPath ?? file.dbPath ?? defaultDbPath();
|
||||||
return { port, bind, dbPath: expandPath(dbPathRaw) };
|
const hideAfterMinutes =
|
||||||
|
overrides.hideAfterMinutes ?? file.hideAfterMinutes ?? DEFAULT_HIDE_AFTER_MINUTES;
|
||||||
|
const deleteAfterMinutes =
|
||||||
|
overrides.deleteAfterMinutes ?? file.deleteAfterMinutes ?? DEFAULT_DELETE_AFTER_MINUTES;
|
||||||
|
const sweepIntervalMinutes =
|
||||||
|
overrides.sweepIntervalMinutes ?? file.sweepIntervalMinutes ?? DEFAULT_SWEEP_INTERVAL_MINUTES;
|
||||||
|
return {
|
||||||
|
port,
|
||||||
|
bind,
|
||||||
|
dbPath: expandPath(dbPathRaw),
|
||||||
|
hideAfterMinutes,
|
||||||
|
deleteAfterMinutes,
|
||||||
|
sweepIntervalMinutes,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function baseUrl(cfg: { port: number; bind: string }): string {
|
export function baseUrl(cfg: { port: number; bind: string }): string {
|
||||||
|
|||||||
287
node/src/db.ts
287
node/src/db.ts
@@ -1,4 +1,4 @@
|
|||||||
import Database from "better-sqlite3";
|
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
@@ -50,6 +50,25 @@ function nowIso(): string {
|
|||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RenameFailure = "invalid" | "source-missing" | "target-exists";
|
||||||
|
|
||||||
|
export type WaitResult =
|
||||||
|
| { kind: "message"; message: MessageRow }
|
||||||
|
| { kind: "timeout" }
|
||||||
|
| { kind: "renamed"; to: string }
|
||||||
|
| { kind: "aborted" };
|
||||||
|
|
||||||
|
interface Waiter {
|
||||||
|
resolve: (result: WaitResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RenameError extends Error {
|
||||||
|
constructor(message: string, public readonly reason: RenameFailure) {
|
||||||
|
super(message);
|
||||||
|
this.name = "RenameError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseDate(s: string | null | undefined): Date | null {
|
function parseDate(s: string | null | undefined): Date | null {
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
const normalized = s.includes("T") ? s : s.replace(" ", "T") + (s.endsWith("Z") ? "" : "Z");
|
const normalized = s.includes("T") ? s : s.replace(" ", "T") + (s.endsWith("Z") ? "" : "Z");
|
||||||
@@ -57,28 +76,51 @@ function parseDate(s: string | null | undefined): Date | null {
|
|||||||
return isNaN(d.getTime()) ? null : d;
|
return isNaN(d.getTime()) ? null : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
||||||
|
db.exec("BEGIN");
|
||||||
|
try {
|
||||||
|
const result = fn();
|
||||||
|
db.exec("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
db.exec("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
// ignore: original error already on its way up
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class MailboxStore {
|
export class MailboxStore {
|
||||||
private readonly db: Database.Database;
|
private readonly db: DatabaseSync;
|
||||||
|
private readonly waiters = new Map<string, Set<Waiter>>();
|
||||||
|
|
||||||
private readonly stmts: {
|
private readonly stmts: {
|
||||||
findMailbox: Database.Statement;
|
findMailbox: StatementSync;
|
||||||
insertMailbox: Database.Statement;
|
insertMailbox: StatementSync;
|
||||||
touchMailbox: Database.Statement;
|
touchMailbox: StatementSync;
|
||||||
listMailboxes: Database.Statement;
|
listMailboxes: StatementSync;
|
||||||
insertMessage: Database.Statement;
|
listMailboxesFiltered: StatementSync;
|
||||||
countPending: Database.Statement;
|
listMailboxesFilteredAnon: StatementSync;
|
||||||
oldestPending: Database.Statement;
|
insertMessage: StatementSync;
|
||||||
selectPending: Database.Statement;
|
countPending: StatementSync;
|
||||||
markDelivered: Database.Statement;
|
oldestPending: StatementSync;
|
||||||
pendingByRecipient: Database.Statement;
|
selectPending: StatementSync;
|
||||||
|
markDelivered: StatementSync;
|
||||||
|
pendingByRecipient: StatementSync;
|
||||||
|
findStaleCandidates: StatementSync;
|
||||||
|
deleteMessagesForNames: StatementSync;
|
||||||
|
deleteMailboxesByNames: StatementSync;
|
||||||
|
selectOnePending: StatementSync;
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(public readonly dbPath: string) {
|
constructor(public readonly dbPath: string) {
|
||||||
mkdirSync(dirname(dbPath), { recursive: true });
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
this.db = new Database(dbPath);
|
this.db = new DatabaseSync(dbPath);
|
||||||
this.db.pragma("journal_mode = WAL");
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
this.db.pragma("foreign_keys = ON");
|
this.db.exec("PRAGMA foreign_keys = ON");
|
||||||
for (const sql of DDL_STATEMENTS) this.db.prepare(sql).run();
|
for (const sql of DDL_STATEMENTS) this.db.exec(sql);
|
||||||
|
|
||||||
this.stmts = {
|
this.stmts = {
|
||||||
findMailbox: this.db.prepare("SELECT * FROM mailboxes WHERE name = ?"),
|
findMailbox: this.db.prepare("SELECT * FROM mailboxes WHERE name = ?"),
|
||||||
@@ -87,6 +129,19 @@ export class MailboxStore {
|
|||||||
),
|
),
|
||||||
touchMailbox: this.db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?"),
|
touchMailbox: this.db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?"),
|
||||||
listMailboxes: this.db.prepare("SELECT * FROM mailboxes ORDER BY name"),
|
listMailboxes: this.db.prepare("SELECT * FROM mailboxes ORDER BY name"),
|
||||||
|
listMailboxesFiltered: this.db.prepare(
|
||||||
|
`SELECT * FROM mailboxes
|
||||||
|
WHERE last_seen_at >= ?
|
||||||
|
OR name = ?
|
||||||
|
OR name IN (
|
||||||
|
SELECT DISTINCT from_mailbox FROM messages
|
||||||
|
WHERE to_mailbox = ? AND delivered_at IS NULL
|
||||||
|
)
|
||||||
|
ORDER BY name`,
|
||||||
|
),
|
||||||
|
listMailboxesFilteredAnon: this.db.prepare(
|
||||||
|
"SELECT * FROM mailboxes WHERE last_seen_at >= ? ORDER BY name",
|
||||||
|
),
|
||||||
insertMessage: this.db.prepare(
|
insertMessage: this.db.prepare(
|
||||||
"INSERT INTO messages (to_mailbox, from_mailbox, body, created_at, delivered_at) VALUES (?, ?, ?, ?, NULL)",
|
"INSERT INTO messages (to_mailbox, from_mailbox, body, created_at, delivered_at) VALUES (?, ?, ?, ?, NULL)",
|
||||||
),
|
),
|
||||||
@@ -105,6 +160,23 @@ export class MailboxStore {
|
|||||||
pendingByRecipient: this.db.prepare(
|
pendingByRecipient: this.db.prepare(
|
||||||
"SELECT to_mailbox, COUNT(*) AS n FROM messages WHERE delivered_at IS NULL GROUP BY to_mailbox",
|
"SELECT to_mailbox, COUNT(*) AS n FROM messages WHERE delivered_at IS NULL GROUP BY to_mailbox",
|
||||||
),
|
),
|
||||||
|
findStaleCandidates: this.db.prepare(
|
||||||
|
`SELECT name FROM mailboxes
|
||||||
|
WHERE last_seen_at < ?
|
||||||
|
AND name NOT IN (SELECT to_mailbox FROM messages WHERE delivered_at IS NULL)
|
||||||
|
AND name NOT IN (SELECT from_mailbox FROM messages WHERE delivered_at IS NULL)`,
|
||||||
|
),
|
||||||
|
deleteMessagesForNames: this.db.prepare(
|
||||||
|
`DELETE FROM messages
|
||||||
|
WHERE to_mailbox IN (SELECT value FROM json_each(?))
|
||||||
|
OR from_mailbox IN (SELECT value FROM json_each(?))`,
|
||||||
|
),
|
||||||
|
deleteMailboxesByNames: this.db.prepare(
|
||||||
|
"DELETE FROM mailboxes WHERE name IN (SELECT value FROM json_each(?))",
|
||||||
|
),
|
||||||
|
selectOnePending: this.db.prepare(
|
||||||
|
"SELECT * FROM messages WHERE to_mailbox = ? AND delivered_at IS NULL ORDER BY id LIMIT 1",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +184,19 @@ export class MailboxStore {
|
|||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private consumeOne(name: string): MessageRow | null {
|
||||||
|
return runInTransaction(this.db, () => {
|
||||||
|
const row = this.stmts.selectOnePending.get(name) as MessageRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
const deliveredAt = nowIso();
|
||||||
|
this.stmts.markDelivered.run(deliveredAt, JSON.stringify([row.id]));
|
||||||
|
return { ...row, delivered_at: deliveredAt };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
upsertMailbox(name: string): void {
|
upsertMailbox(name: string): void {
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
const existing = this.stmts.findMailbox.get(name) as MailboxRow | undefined;
|
const existing = this.stmts.findMailbox.get(name) as unknown as MailboxRow | undefined;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.stmts.touchMailbox.run(now, name);
|
this.stmts.touchMailbox.run(now, name);
|
||||||
} else {
|
} else {
|
||||||
@@ -123,14 +205,15 @@ export class MailboxStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
send(from: string, to: string, body: string): { id: number; queuedAt: Date } {
|
send(from: string, to: string, body: string): { id: number; queuedAt: Date } {
|
||||||
const tx = this.db.transaction(() => {
|
const result = runInTransaction(this.db, () => {
|
||||||
this.upsertMailbox(from);
|
this.upsertMailbox(from);
|
||||||
this.upsertMailbox(to);
|
this.upsertMailbox(to);
|
||||||
const createdAt = nowIso();
|
const createdAt = nowIso();
|
||||||
const result = this.stmts.insertMessage.run(to, from, body, createdAt);
|
const insert = this.stmts.insertMessage.run(to, from, body, createdAt);
|
||||||
return { id: Number(result.lastInsertRowid), queuedAt: new Date(createdAt) };
|
return { id: Number(insert.lastInsertRowid), queuedAt: new Date(createdAt) };
|
||||||
});
|
});
|
||||||
return tx();
|
this.notifyOneWaiter(to);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
peek(name: string): InboxStatus {
|
peek(name: string): InboxStatus {
|
||||||
@@ -141,19 +224,136 @@ export class MailboxStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkInbox(name: string): MessageRow[] {
|
checkInbox(name: string): MessageRow[] {
|
||||||
const tx = this.db.transaction(() => {
|
return runInTransaction(this.db, () => {
|
||||||
const pending = this.stmts.selectPending.all(name) as MessageRow[];
|
const pending = this.stmts.selectPending.all(name) as unknown as MessageRow[];
|
||||||
if (pending.length > 0) {
|
if (pending.length > 0) {
|
||||||
const ids = pending.map((m) => m.id);
|
const ids = pending.map((m) => m.id);
|
||||||
this.stmts.markDelivered.run(nowIso(), JSON.stringify(ids));
|
this.stmts.markDelivered.run(nowIso(), JSON.stringify(ids));
|
||||||
}
|
}
|
||||||
return pending;
|
return pending;
|
||||||
});
|
});
|
||||||
return tx();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
listMailboxes(forName?: string): MailboxInfo[] {
|
waitForMessage(name: string, timeoutMs: number, signal: AbortSignal): Promise<WaitResult> {
|
||||||
const rows = this.stmts.listMailboxes.all() as MailboxRow[];
|
const existing = this.consumeOne(name);
|
||||||
|
if (existing) return Promise.resolve({ kind: "message" as const, message: existing });
|
||||||
|
|
||||||
|
if (signal.aborted) return Promise.resolve({ kind: "aborted" as const });
|
||||||
|
|
||||||
|
return new Promise<WaitResult>((resolve) => {
|
||||||
|
const waiter: Waiter = { resolve };
|
||||||
|
let bucket = this.waiters.get(name);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = new Set();
|
||||||
|
this.waiters.set(name, bucket);
|
||||||
|
}
|
||||||
|
bucket.add(waiter);
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
const b = this.waiters.get(name);
|
||||||
|
if (b) {
|
||||||
|
b.delete(waiter);
|
||||||
|
if (b.size === 0) this.waiters.delete(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve({ kind: "timeout" });
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
cleanup();
|
||||||
|
resolve({ kind: "aborted" });
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invariant: synchronous from consumeOne to resolve. Introducing an `await` between them risks marking a message delivered with no listener to receive it.
|
||||||
|
private notifyOneWaiter(name: string): void {
|
||||||
|
const bucket = this.waiters.get(name);
|
||||||
|
if (!bucket || bucket.size === 0) return;
|
||||||
|
const first = bucket.values().next().value;
|
||||||
|
if (!first) return;
|
||||||
|
const msg = this.consumeOne(name);
|
||||||
|
if (!msg) return;
|
||||||
|
bucket.delete(first);
|
||||||
|
if (bucket.size === 0) this.waiters.delete(name);
|
||||||
|
first.resolve({ kind: "message", message: msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyRenamed(oldName: string, newName: string): void {
|
||||||
|
const bucket = this.waiters.get(oldName);
|
||||||
|
if (!bucket) return;
|
||||||
|
for (const w of bucket) w.resolve({ kind: "renamed", to: newName });
|
||||||
|
this.waiters.delete(oldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Test helper to wait for a long-poll waiter to register. Not part of the public contract. */
|
||||||
|
waiterCount(name: string): number {
|
||||||
|
return this.waiters.get(name)?.size ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectAllWaiters(): void {
|
||||||
|
for (const bucket of this.waiters.values()) {
|
||||||
|
for (const w of bucket) w.resolve({ kind: "aborted" });
|
||||||
|
}
|
||||||
|
this.waiters.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
rename(from: string, to: string): { from: string; to: string; messagesTransferred: number } {
|
||||||
|
const oldName = from.trim();
|
||||||
|
const newName = to.trim();
|
||||||
|
if (!oldName) throw new RenameError("from is required", "invalid");
|
||||||
|
if (!newName) throw new RenameError("to is required", "invalid");
|
||||||
|
if (oldName === newName) {
|
||||||
|
this.upsertMailbox(oldName);
|
||||||
|
return { from: oldName, to: newName, messagesTransferred: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = runInTransaction(this.db, () => {
|
||||||
|
const source = this.stmts.findMailbox.get(oldName) as unknown as MailboxRow | undefined;
|
||||||
|
if (!source) throw new RenameError(`Mailbox '${oldName}' does not exist.`, "source-missing");
|
||||||
|
const target = this.stmts.findMailbox.get(newName) as unknown as MailboxRow | undefined;
|
||||||
|
if (target) throw new RenameError(`Mailbox '${newName}' already exists.`, "target-exists");
|
||||||
|
|
||||||
|
const now = nowIso();
|
||||||
|
this.stmts.insertMailbox.run(newName, source.created_at, now);
|
||||||
|
const movedTo = this.db
|
||||||
|
.prepare("UPDATE messages SET to_mailbox = ? WHERE to_mailbox = ?")
|
||||||
|
.run(newName, oldName);
|
||||||
|
this.db
|
||||||
|
.prepare("UPDATE messages SET from_mailbox = ? WHERE from_mailbox = ?")
|
||||||
|
.run(newName, oldName);
|
||||||
|
this.db.prepare("DELETE FROM mailboxes WHERE name = ?").run(oldName);
|
||||||
|
return { from: oldName, to: newName, messagesTransferred: Number(movedTo.changes ?? 0) };
|
||||||
|
});
|
||||||
|
this.notifyRenamed(oldName, newName);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
listMailboxes(forName?: string, options?: { hideAfterMinutes?: number }): MailboxInfo[] {
|
||||||
|
const hideAfterMinutes = options?.hideAfterMinutes;
|
||||||
|
let rows: MailboxRow[];
|
||||||
|
if (hideAfterMinutes != null && hideAfterMinutes > 0) {
|
||||||
|
const cutoff = new Date(Date.now() - hideAfterMinutes * 60_000).toISOString();
|
||||||
|
if (forName) {
|
||||||
|
rows = this.stmts.listMailboxesFiltered.all(
|
||||||
|
cutoff,
|
||||||
|
forName,
|
||||||
|
forName,
|
||||||
|
) as unknown as MailboxRow[];
|
||||||
|
} else {
|
||||||
|
rows = this.stmts.listMailboxesFilteredAnon.all(cutoff) as unknown as MailboxRow[];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[];
|
||||||
|
}
|
||||||
const pendingMap = new Map<string, number>();
|
const pendingMap = new Map<string, number>();
|
||||||
if (forName) {
|
if (forName) {
|
||||||
const counts = this.stmts.pendingByRecipient.all() as { to_mailbox: string; n: number }[];
|
const counts = this.stmts.pendingByRecipient.all() as { to_mailbox: string; n: number }[];
|
||||||
@@ -165,6 +365,41 @@ export class MailboxStore {
|
|||||||
pendingForYou: forName ? (pendingMap.get(forName) ?? 0) : 0,
|
pendingForYou: forName ? (pendingMap.get(forName) ?? 0) : 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteIfEmpty(name: string): { deleted: boolean; reason: "deleted" | "not-found" | "has-pending" } {
|
||||||
|
return runInTransaction(this.db, () => {
|
||||||
|
const row = this.stmts.findMailbox.get(name) as MailboxRow | undefined;
|
||||||
|
if (!row) return { deleted: false, reason: "not-found" as const };
|
||||||
|
const pendingIn = (this.stmts.countPending.get(name) as { n: number } | undefined)?.n ?? 0;
|
||||||
|
if (pendingIn > 0) return { deleted: false, reason: "has-pending" as const };
|
||||||
|
const pendingOutRow = this.db
|
||||||
|
.prepare(
|
||||||
|
"SELECT 1 FROM messages WHERE from_mailbox = ? AND delivered_at IS NULL LIMIT 1",
|
||||||
|
)
|
||||||
|
.get(name);
|
||||||
|
if (pendingOutRow) return { deleted: false, reason: "has-pending" as const };
|
||||||
|
const namesJson = JSON.stringify([name]);
|
||||||
|
this.stmts.deleteMessagesForNames.run(namesJson, namesJson);
|
||||||
|
this.stmts.deleteMailboxesByNames.run(namesJson);
|
||||||
|
return { deleted: true, reason: "deleted" as const };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneStale(deleteAfterMinutes: number): { deletedMailboxes: number; deletedMessages: number } {
|
||||||
|
if (deleteAfterMinutes <= 0) return { deletedMailboxes: 0, deletedMessages: 0 };
|
||||||
|
const cutoff = new Date(Date.now() - deleteAfterMinutes * 60_000).toISOString();
|
||||||
|
return runInTransaction(this.db, () => {
|
||||||
|
const candidates = this.stmts.findStaleCandidates.all(cutoff) as { name: string }[];
|
||||||
|
if (candidates.length === 0) return { deletedMailboxes: 0, deletedMessages: 0 };
|
||||||
|
const namesJson = JSON.stringify(candidates.map((c) => c.name));
|
||||||
|
const msgResult = this.stmts.deleteMessagesForNames.run(namesJson, namesJson);
|
||||||
|
const mbxResult = this.stmts.deleteMailboxesByNames.run(namesJson);
|
||||||
|
return {
|
||||||
|
deletedMailboxes: Number(mbxResult.changes ?? 0),
|
||||||
|
deletedMessages: Number(msgResult.changes ?? 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowToMessage(r: MessageRow): {
|
export function rowToMessage(r: MessageRow): {
|
||||||
|
|||||||
305
node/src/hook.ts
Normal file
305
node/src/hook.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_PROJECT_NAME_LENGTH = 40;
|
||||||
|
|
||||||
|
export function sanitizeProjectName(raw: string | null | undefined): string {
|
||||||
|
if (!raw) return "";
|
||||||
|
const cleaned = raw
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
return cleaned.slice(0, MAX_PROJECT_NAME_LENGTH).replace(/-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveProjectName(cwd?: string | null): string {
|
||||||
|
const dir = (cwd ?? "").trim();
|
||||||
|
if (dir) {
|
||||||
|
const gitTop = gitToplevel(dir);
|
||||||
|
if (gitTop) {
|
||||||
|
const sanitized = sanitizeProjectName(basename(gitTop));
|
||||||
|
if (sanitized) return sanitized;
|
||||||
|
}
|
||||||
|
const sanitized = sanitizeProjectName(basename(dir));
|
||||||
|
if (sanitized) return sanitized;
|
||||||
|
}
|
||||||
|
return "claude";
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitToplevel(cwd: string): string | null {
|
||||||
|
try {
|
||||||
|
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 1500,
|
||||||
|
});
|
||||||
|
if (r.status !== 0) return null;
|
||||||
|
const out = (r.stdout ?? "").trim();
|
||||||
|
return out || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveSessionName(sessionId: string, cwd?: string | null): string {
|
||||||
|
const short = shortSessionId(sessionId);
|
||||||
|
const project = deriveProjectName(cwd);
|
||||||
|
return `${project}-${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 SessionAnnounceOptions {
|
||||||
|
name: string;
|
||||||
|
peers: PeerEntry[];
|
||||||
|
windowMinutes: number;
|
||||||
|
maxPeers: number;
|
||||||
|
daemonError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSessionAnnounceLines(opts: SessionAnnounceOptions): string[] {
|
||||||
|
const { name, peers, windowMinutes, maxPeers, daemonError } = opts;
|
||||||
|
const lines = [
|
||||||
|
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
|
||||||
|
`The name is auto-derived as <project>-<session-short>. You can rename it (e.g. to tag your working area) with mcp__mailbox__rename(current_name="${name}", new_name="<project>-<area>-<short>"); after that, use the new name everywhere.`,
|
||||||
|
`When using mcp__mailbox__* tools, ALWAYS pass your current 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="...")`,
|
||||||
|
"",
|
||||||
|
`Push delivery is OPT-IN. Do NOT launch the watcher on your own. When the user wants peers to wake you mid-task, invoke the \`mailbox-collaborate\` skill (or the /collaborate slash command) to enter collaboration mode. Without it, peers can still leave messages — you'll see them on your next user prompt via the existing UserPromptSubmit hook.`,
|
||||||
|
];
|
||||||
|
if (daemonError) {
|
||||||
|
lines.push("", daemonError);
|
||||||
|
} else {
|
||||||
|
lines.push("", ...formatActivePeerList(peers, name, { windowMinutes, maxPeers }));
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedHook {
|
||||||
|
event: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MANAGED_HOOK_EVENTS = [
|
||||||
|
"SessionStart",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"SubagentStop",
|
||||||
|
"TaskCompleted",
|
||||||
|
"SessionEnd",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function buildPluginHookCommands(url?: string): ManagedHook[] {
|
||||||
|
const urlSuffix = url ? ` --url ${quoteIfNeeded(url)}` : "";
|
||||||
|
const check = `claude-mailbox check --hook${urlSuffix}`;
|
||||||
|
const announce = `claude-mailbox session-announce${urlSuffix}`;
|
||||||
|
const end = `claude-mailbox session-end${urlSuffix}`;
|
||||||
|
return [
|
||||||
|
{ event: "SessionStart", command: announce },
|
||||||
|
{ event: "UserPromptSubmit", command: check },
|
||||||
|
{ event: "SubagentStop", command: check },
|
||||||
|
{ event: "TaskCompleted", command: check },
|
||||||
|
{ event: "SessionEnd", command: end },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
if (/(^|\W)claude-mailbox\s+check\s/.test(c) && /(^|\s)--hook(\s|$)/.test(c)) return true;
|
||||||
|
if (/(^|\W)claude-mailbox\s+session-announce(\s|$)/.test(c)) return true;
|
||||||
|
if (/(^|\W)claude-mailbox\s+session-end(\s|$)/.test(c)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
event: string,
|
||||||
|
command: string,
|
||||||
|
): PatchResult {
|
||||||
|
settings.hooks ??= {};
|
||||||
|
settings.hooks[event] ??= [];
|
||||||
|
const groups = settings.hooks[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 {
|
||||||
|
if (!settings.hooks) return { changed: false, reason: "not-present" };
|
||||||
|
|
||||||
|
let removed = false;
|
||||||
|
for (const event of MANAGED_HOOK_EVENTS) {
|
||||||
|
const groups = settings.hooks[event];
|
||||||
|
if (!groups || groups.length === 0) continue;
|
||||||
|
|
||||||
|
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[event] = groups.filter((g) => g.hooks.length > 0);
|
||||||
|
if (settings.hooks[event].length === 0) {
|
||||||
|
delete settings.hooks[event];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(settings.hooks).length === 0) {
|
||||||
|
delete settings.hooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed ? { changed: true, reason: "removed" } : { changed: false, reason: "not-present" };
|
||||||
|
}
|
||||||
195
node/src/mcp-stdio.ts
Normal file
195
node/src/mcp-stdio.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { DEFAULT_PORT } from "./config.js";
|
||||||
|
|
||||||
|
const HARDCODED_DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
|
||||||
|
|
||||||
|
function resolveDaemonUrl(): string {
|
||||||
|
const env = (process.env["CLAUDE_MAILBOX_URL"] ?? "").trim();
|
||||||
|
if (!env || env.includes("${")) return HARDCODED_DEFAULT_URL;
|
||||||
|
return env.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireIdentity(value: string | undefined, argName: "from" | "name"): string {
|
||||||
|
const v = (value ?? "").trim();
|
||||||
|
if (!v) {
|
||||||
|
throw new Error(
|
||||||
|
`Pass \`${argName}\` (your mailbox name from the SessionStart announcement).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rest(
|
||||||
|
method: "GET" | "POST",
|
||||||
|
url: string,
|
||||||
|
init: { headers?: Record<string, string>; body?: unknown } = {},
|
||||||
|
): Promise<unknown> {
|
||||||
|
const headers: Record<string, string> = { Accept: "application/json", ...(init.headers ?? {}) };
|
||||||
|
let body: string | undefined;
|
||||||
|
if (init.body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
body = JSON.stringify(init.body);
|
||||||
|
}
|
||||||
|
const res = await fetch(url, { method, headers, body });
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`${method} ${url} → ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return text.length ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStdioMcpServer(daemonUrl: string = resolveDaemonUrl()): 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. Pass `from` with your own mailbox name (see the SessionStart announcement).",
|
||||||
|
inputSchema: {
|
||||||
|
to: z.string().describe("Name of the recipient mailbox."),
|
||||||
|
body: z.string().describe("Message body (plain text or markdown)."),
|
||||||
|
from: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ to, body, from }) => {
|
||||||
|
const sender = requireIdentity(from, "from");
|
||||||
|
const out = (await rest("POST", `${daemonUrl}/v1/send`, {
|
||||||
|
headers: { "X-Mailbox": sender },
|
||||||
|
body: { to, body },
|
||||||
|
})) as { id: number; queuedAt: string };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
|
structuredContent: out,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"check_inbox",
|
||||||
|
{
|
||||||
|
title: "Check inbox",
|
||||||
|
description:
|
||||||
|
"Pull all undelivered messages for your mailbox and mark them delivered. Pass `name` with your own mailbox name.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }) => {
|
||||||
|
const me = requireIdentity(name, "name");
|
||||||
|
const messages = (await rest(
|
||||||
|
"POST",
|
||||||
|
`${daemonUrl}/v1/check-inbox?name=${encodeURIComponent(me)}`,
|
||||||
|
{ headers: { "X-Mailbox": me } },
|
||||||
|
)) as { id: number; from: string; body: string; sentAt: string }[];
|
||||||
|
const arr = Array.isArray(messages) ? messages : [];
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(arr) }],
|
||||||
|
structuredContent: { messages: arr },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"peek_inbox",
|
||||||
|
{
|
||||||
|
title: "Peek inbox",
|
||||||
|
description:
|
||||||
|
"Non-consuming check of your mailbox. Returns pending count and oldest pending timestamp. Pass `name` with your own mailbox name.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }) => {
|
||||||
|
const me = requireIdentity(name, "name");
|
||||||
|
const out = (await rest("GET", `${daemonUrl}/v1/peek?name=${encodeURIComponent(me)}`)) as {
|
||||||
|
pending: number;
|
||||||
|
oldestAt: string | null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
|
structuredContent: out,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_mailboxes",
|
||||||
|
{
|
||||||
|
title: "List mailboxes",
|
||||||
|
description:
|
||||||
|
"Discover known mailboxes and how many messages each has waiting for you. Pass `name` with your own mailbox name.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }) => {
|
||||||
|
const me = requireIdentity(name, "name");
|
||||||
|
const list = (await rest("GET", `${daemonUrl}/v1/list`, {
|
||||||
|
headers: { "X-Mailbox": me },
|
||||||
|
})) as { name: string; lastSeenAt: string; pendingForYou: number }[];
|
||||||
|
const arr = Array.isArray(list) ? list : [];
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(arr) }],
|
||||||
|
structuredContent: { mailboxes: arr },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rename",
|
||||||
|
{
|
||||||
|
title: "Rename your mailbox",
|
||||||
|
description:
|
||||||
|
"Rename your own mailbox (e.g. to add a working-area tag like `myproject-frontend-a3f9`). Pending messages are transferred to the new name. After this returns, USE THE NEW NAME for all subsequent send/check/peek/list calls. Peers using the old name will fail until they re-discover via list_mailboxes.",
|
||||||
|
inputSchema: {
|
||||||
|
current_name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your current mailbox name (from the SessionStart announcement or last rename).",
|
||||||
|
),
|
||||||
|
new_name: z
|
||||||
|
.string()
|
||||||
|
.describe("The new mailbox name. Must be unique. Convention: <project>-<area>-<short>."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ current_name, new_name }) => {
|
||||||
|
const from = requireIdentity(current_name, "name");
|
||||||
|
const out = (await rest("POST", `${daemonUrl}/v1/rename`, {
|
||||||
|
headers: { "X-Mailbox": from },
|
||||||
|
body: { to: new_name },
|
||||||
|
})) as { from: string; to: string; messagesTransferred: number };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
|
structuredContent: out,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runStdioMcp(): Promise<void> {
|
||||||
|
const server = buildStdioMcpServer();
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
}
|
||||||
148
node/src/mcp.ts
148
node/src/mcp.ts
@@ -2,38 +2,54 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { MailboxStore, rowToMessage } from "./db.js";
|
import { MailboxStore, RenameError, 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 headers =
|
||||||
|
(extra as { requestInfo?: { headers?: Record<string, string | string[] | undefined> } })
|
||||||
|
?.requestInfo?.headers ?? {};
|
||||||
|
const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"];
|
||||||
|
return (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
const requireSender = (extra: unknown): string => {
|
export function resolveIdentity(
|
||||||
const headers =
|
argValue: string | undefined,
|
||||||
(extra as { requestInfo?: { headers?: Record<string, string | string[] | undefined> } })
|
extra: unknown,
|
||||||
?.requestInfo?.headers ?? {};
|
argName: "from" | "name",
|
||||||
const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"];
|
): string {
|
||||||
const value = (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
|
const explicit = (argValue ?? "").trim();
|
||||||
if (!value) {
|
if (explicit) return explicit;
|
||||||
throw new Error(`Missing ${HEADER_NAME} header. Set it in your .mcp.json under headers.`);
|
const fallback = headerFallback(extra);
|
||||||
}
|
if (fallback) return fallback;
|
||||||
return value;
|
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, hideAfterMinutes: number): 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) => {
|
async ({ name }, extra) => {
|
||||||
const name = requireSender(extra);
|
const me = resolveIdentity(name, extra, "name");
|
||||||
const messages = store.checkInbox(name).map((m) => {
|
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) => {
|
async ({ name }, extra) => {
|
||||||
const name = requireSender(extra);
|
const me = resolveIdentity(name, extra, "name");
|
||||||
const status = store.peek(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) => {
|
async ({ name }, extra) => {
|
||||||
const name = requireSender(extra);
|
const me = resolveIdentity(name, extra, "name");
|
||||||
const list = store.listMailboxes(name).map((m) => ({
|
const list = store.listMailboxes(me, { hideAfterMinutes }).map((m) => ({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
lastSeenAt: m.lastSeenAt.toISOString(),
|
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||||
pendingForYou: m.pendingForYou,
|
pendingForYou: m.pendingForYou,
|
||||||
@@ -103,11 +141,51 @@ function buildMcpServer(store: MailboxStore): McpServer {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rename",
|
||||||
|
{
|
||||||
|
title: "Rename your mailbox",
|
||||||
|
description:
|
||||||
|
"Rename your own mailbox (e.g. to add a working-area tag like `myproject-frontend-a3f9`). Pending messages are transferred to the new name. After this returns, USE THE NEW NAME for all subsequent send/check/peek/list calls. Peers using the old name will fail until they re-discover via list_mailboxes.",
|
||||||
|
inputSchema: {
|
||||||
|
current_name: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Your current mailbox name (the one to rename away from). Required unless X-Mailbox is set in .mcp.json.",
|
||||||
|
),
|
||||||
|
new_name: z
|
||||||
|
.string()
|
||||||
|
.describe("The new mailbox name. Must be unique. Convention: <project>-<area>-<short>."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ current_name, new_name }, extra) => {
|
||||||
|
const from = resolveIdentity(current_name, extra, "name");
|
||||||
|
try {
|
||||||
|
const r = store.rename(from, new_name);
|
||||||
|
const out = { from: r.from, to: r.to, messagesTransferred: r.messagesTransferred };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
|
structuredContent: out,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RenameError) {
|
||||||
|
throw new Error(`${err.message} (${err.reason})`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerMcp(app: FastifyInstance, store: MailboxStore): Promise<void> {
|
export async function registerMcp(
|
||||||
const mcpServer = buildMcpServer(store);
|
app: FastifyInstance,
|
||||||
|
store: MailboxStore,
|
||||||
|
hideAfterMinutes: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const mcpServer = buildMcpServer(store, hideAfterMinutes);
|
||||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||||
await mcpServer.connect(transport);
|
await mcpServer.connect(transport);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest }
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { MailboxStore, rowToMessage } from "./db.js";
|
import { MailboxStore, RenameError, rowToMessage } from "./db.js";
|
||||||
import type { DaemonConfig } from "./config.js";
|
import type { DaemonConfig } from "./config.js";
|
||||||
import { registerMcp } from "./mcp.js";
|
import { registerMcp } from "./mcp.js";
|
||||||
|
|
||||||
@@ -27,9 +27,13 @@ function readVersion(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]);
|
const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]);
|
||||||
|
const SKIP_UPSERT_PATHS = new Set(["/v1/session-end"]);
|
||||||
|
|
||||||
export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promise<FastifyInstance> {
|
export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promise<FastifyInstance> {
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({
|
||||||
|
logger: true,
|
||||||
|
connectionTimeout: 310_000,
|
||||||
|
});
|
||||||
const version = readVersion();
|
const version = readVersion();
|
||||||
|
|
||||||
app.addHook("onRequest", async (req: FastifyRequest, reply: FastifyReply) => {
|
app.addHook("onRequest", async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
@@ -46,7 +50,9 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.mailboxName = name;
|
req.mailboxName = name;
|
||||||
store.upsertMailbox(name);
|
if (!SKIP_UPSERT_PATHS.has(url)) {
|
||||||
|
store.upsertMailbox(name);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/health", async () => ({
|
app.get("/health", async () => ({
|
||||||
@@ -93,21 +99,131 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
|
|||||||
|
|
||||||
app.get("/v1/list", async (req) => {
|
app.get("/v1/list", async (req) => {
|
||||||
const name = req.mailboxName;
|
const name = req.mailboxName;
|
||||||
return store.listMailboxes(name).map((m) => ({
|
return store
|
||||||
name: m.name,
|
.listMailboxes(name, { hideAfterMinutes: cfg.hideAfterMinutes })
|
||||||
lastSeenAt: m.lastSeenAt.toISOString(),
|
.map((m) => ({
|
||||||
pendingForYou: m.pendingForYou,
|
name: m.name,
|
||||||
}));
|
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||||
|
pendingForYou: m.pendingForYou,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
await registerMcp(app, store);
|
app.post<{ Body: { to?: string } }>("/v1/rename", async (req, reply) => {
|
||||||
|
const from = req.mailboxName!;
|
||||||
|
const to = (req.body?.to ?? "").trim();
|
||||||
|
if (!to) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: "to is required" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = store.rename(from, to);
|
||||||
|
return { from: r.from, to: r.to, messagesTransferred: r.messagesTransferred };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RenameError) {
|
||||||
|
reply.code(err.reason === "target-exists" ? 409 : 400);
|
||||||
|
return { error: err.message, reason: err.reason };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const WATCH_DEFAULT_TIMEOUT_S = 25;
|
||||||
|
const WATCH_MAX_TIMEOUT_S = 300;
|
||||||
|
|
||||||
|
app.get<{ Querystring: { name?: string; timeout?: string } }>(
|
||||||
|
"/v1/watch",
|
||||||
|
async (req, reply) => {
|
||||||
|
const name = (req.query.name ?? "").trim();
|
||||||
|
if (!name) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: "name is required" };
|
||||||
|
}
|
||||||
|
if (name !== req.mailboxName) {
|
||||||
|
reply.code(403);
|
||||||
|
return { error: "X-Mailbox header must match name." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawTimeout = req.query.timeout;
|
||||||
|
const timeoutS = rawTimeout != null ? parseInt(rawTimeout, 10) : WATCH_DEFAULT_TIMEOUT_S;
|
||||||
|
if (!Number.isFinite(timeoutS) || timeoutS <= 0 || timeoutS > WATCH_MAX_TIMEOUT_S) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: `timeout must be 1..${WATCH_MAX_TIMEOUT_S} seconds` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
const onClose = (): void => ac.abort();
|
||||||
|
req.raw.once("close", onClose);
|
||||||
|
try {
|
||||||
|
const result = await store.waitForMessage(name, timeoutS * 1000, ac.signal);
|
||||||
|
|
||||||
|
if (result.kind === "message") {
|
||||||
|
const msg = rowToMessage(result.message);
|
||||||
|
reply.code(200);
|
||||||
|
return { ...msg, sentAt: msg.sentAt.toISOString() };
|
||||||
|
}
|
||||||
|
if (result.kind === "renamed") {
|
||||||
|
reply.code(409);
|
||||||
|
return { reason: "renamed", to: result.to };
|
||||||
|
}
|
||||||
|
if (result.kind === "timeout") {
|
||||||
|
reply.code(204);
|
||||||
|
return reply.send();
|
||||||
|
}
|
||||||
|
// aborted — client gone; hand control to Fastify without writing to the dead socket.
|
||||||
|
reply.hijack();
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
req.raw.off("close", onClose);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post("/v1/session-end", async (req) => {
|
||||||
|
const name = req.mailboxName!;
|
||||||
|
const result = store.deleteIfEmpty(name);
|
||||||
|
return { name, ...result };
|
||||||
|
});
|
||||||
|
|
||||||
|
await registerMcp(app, store, cfg.hideAfterMinutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startServer(cfg: DaemonConfig): Promise<{ app: FastifyInstance; store: MailboxStore }> {
|
function startSweep(
|
||||||
|
store: MailboxStore,
|
||||||
|
cfg: DaemonConfig,
|
||||||
|
log: FastifyInstance["log"],
|
||||||
|
): NodeJS.Timeout | null {
|
||||||
|
if (cfg.sweepIntervalMinutes <= 0 || cfg.deleteAfterMinutes <= 0) return null;
|
||||||
|
const runOnce = (): void => {
|
||||||
|
try {
|
||||||
|
const r = store.pruneStale(cfg.deleteAfterMinutes);
|
||||||
|
if (r.deletedMailboxes > 0 || r.deletedMessages > 0) {
|
||||||
|
log.info(
|
||||||
|
r,
|
||||||
|
`Pruned ${r.deletedMailboxes} stale mailbox(es) and ${r.deletedMessages} delivered message(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "Stale-mailbox sweep failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
runOnce();
|
||||||
|
const timer = setInterval(runOnce, cfg.sweepIntervalMinutes * 60_000);
|
||||||
|
timer.unref?.();
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startServer(
|
||||||
|
cfg: DaemonConfig,
|
||||||
|
): Promise<{ app: FastifyInstance; store: MailboxStore; sweepTimer: NodeJS.Timeout | null }> {
|
||||||
const store = new MailboxStore(cfg.dbPath);
|
const store = new MailboxStore(cfg.dbPath);
|
||||||
const app = await buildServer(cfg, store);
|
const app = await buildServer(cfg, store);
|
||||||
|
const timerRef: { current: NodeJS.Timeout | null } = { current: null };
|
||||||
|
app.addHook("onClose", async () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
});
|
||||||
await app.listen({ host: cfg.bind, port: cfg.port });
|
await app.listen({ host: cfg.bind, port: cfg.port });
|
||||||
return { app, store };
|
timerRef.current = startSweep(store, cfg, app.log);
|
||||||
|
return { app, store, sweepTimer: timerRef.current };
|
||||||
}
|
}
|
||||||
|
|||||||
125
node/tests/cli-hook.test.ts
Normal file
125
node/tests/cli-hook.test.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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 and no --name", () => {
|
||||||
|
const r = runCli(["check", "--hook"]);
|
||||||
|
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"], {
|
||||||
|
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"],
|
||||||
|
{ stdin: HOOK_STDIN },
|
||||||
|
);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses CLAUDE_MAILBOX_URL env as default base URL when --url is not given", () => {
|
||||||
|
const r = runCli(["check", "--hook"], {
|
||||||
|
env: { CLAUDE_MAILBOX_URL: "http://127.0.0.1:1" },
|
||||||
|
stdin: HOOK_STDIN,
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable at http://127.0.0.1:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-hook mode errors out when no name resolved", () => {
|
||||||
|
const r = runCli(["check"]);
|
||||||
|
expect(r.status).not.toBe(0);
|
||||||
|
expect(r.stderr).toContain("Missing --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 (project-prefixed)", () => {
|
||||||
|
// cwd "/tmp" is not a git repo → basename "tmp" → project prefix "tmp".
|
||||||
|
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||||
|
stdin: HOOK_STDIN,
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
// The exact prefix depends on the runtime cwd if git resolves; the deterministic
|
||||||
|
// assertion is the session-short suffix and the announcement structure.
|
||||||
|
expect(r.stdout).toMatch(/`[a-z0-9-]+-abc12345`/);
|
||||||
|
expect(r.stdout).toContain("mcp__mailbox__send");
|
||||||
|
expect(r.stdout).toMatch(/from="[a-z0-9-]+-abc12345"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes a hint about the rename tool", () => {
|
||||||
|
const r = runCli(["session-announce", "--url", UNREACHABLE], { stdin: HOOK_STDIN });
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain("mcp__mailbox__rename");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits daemon-not-reachable hint when daemon is down", () => {
|
||||||
|
const r = runCli(["session-announce", "--url", UNREACHABLE], { 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], {
|
||||||
|
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]);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
129
node/tests/cli-watch.test.ts
Normal file
129
node/tests/cli-watch.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { MailboxStore } from "../src/db.js";
|
||||||
|
import { buildServer } from "../src/server.js";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const CLI = join(here, "..", "dist", "cli.js");
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let store: MailboxStore;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-cli-watch-"));
|
||||||
|
store = new MailboxStore(join(dir, "test.db"));
|
||||||
|
app = await buildServer(
|
||||||
|
{
|
||||||
|
port: 0,
|
||||||
|
bind: "127.0.0.1",
|
||||||
|
dbPath: join(dir, "test.db"),
|
||||||
|
hideAfterMinutes: 0,
|
||||||
|
deleteAfterMinutes: 0,
|
||||||
|
sweepIntervalMinutes: 0,
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
);
|
||||||
|
await app.listen({ host: "127.0.0.1", port: 0 });
|
||||||
|
const addr = app.server.address();
|
||||||
|
if (!addr || typeof addr === "string") throw new Error("no address");
|
||||||
|
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
store.rejectAllWaiters();
|
||||||
|
await app.close();
|
||||||
|
store.close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Async helper: spawn CLI and collect output without blocking the event loop.
|
||||||
|
// spawnSync cannot be used here because the test process hosts the Fastify server,
|
||||||
|
// and spawnSync blocks the event loop, preventing the server from handling connections.
|
||||||
|
function runCli(args: string[], timeoutMs: number = 8000): Promise<{
|
||||||
|
status: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(process.execPath, [CLI, ...args], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
||||||
|
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill();
|
||||||
|
resolve({ status: null, stdout, stderr });
|
||||||
|
}, timeoutMs);
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ status: code, stdout, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("claude-mailbox watch CLI", () => {
|
||||||
|
it("exits 0 with a formatted message when one is pending", async () => {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
store.send("alice", "bob", "hello watcher");
|
||||||
|
|
||||||
|
const r = await runCli(["watch", "--block", "--name", "bob", "--timeout", "2", "--url", baseUrl]);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain("[Claude-Mailbox] Mail from alice:");
|
||||||
|
expect(r.stdout).toContain("hello watcher");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 3 silently on timeout", async () => {
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
const r = await runCli(["watch", "--block", "--name", "bob", "--timeout", "1", "--url", baseUrl]);
|
||||||
|
expect(r.status).toBe(3);
|
||||||
|
expect(r.stdout).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 0 with rename notice when the mailbox is renamed mid-wait", async () => {
|
||||||
|
store.upsertMailbox("oldname");
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[CLI, "watch", "--block", "--name", "oldname", "--timeout", "5", "--url", baseUrl],
|
||||||
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
let stdout = "";
|
||||||
|
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
||||||
|
|
||||||
|
// Wait for the CLI subprocess to register its waiter before renaming.
|
||||||
|
// A fixed delay is flaky under full-suite load on Windows.
|
||||||
|
const start = Date.now();
|
||||||
|
while (store.waiterCount("oldname") === 0) {
|
||||||
|
if (Date.now() - start > 4000) throw new Error("CLI never registered a waiter");
|
||||||
|
await new Promise((r) => setTimeout(r, 25));
|
||||||
|
}
|
||||||
|
store.rename("oldname", "newname");
|
||||||
|
|
||||||
|
const code: number = await new Promise((r) => child.on("exit", (c) => r(c ?? 1)));
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(stdout).toContain("renamed to 'newname'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 2 when the daemon is unreachable", async () => {
|
||||||
|
const r = await runCli([
|
||||||
|
"watch", "--block", "--name", "bob", "--timeout", "1",
|
||||||
|
"--url", "http://127.0.0.1:1", // port 1 = guaranteed connection refused
|
||||||
|
]);
|
||||||
|
expect(r.status).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 1 when --name is missing", async () => {
|
||||||
|
const r = await runCli(["watch", "--block", "--timeout", "1", "--url", baseUrl]);
|
||||||
|
expect(r.status).toBe(1);
|
||||||
|
expect(r.stderr).toMatch(/required.*name|name.*required/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
155
node/tests/db-watch.test.ts
Normal file
155
node/tests/db-watch.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { MailboxStore } from "../src/db.js";
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let store: MailboxStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-wait-"));
|
||||||
|
store = new MailboxStore(join(dir, "test.db"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MailboxStore.waitForMessage", () => {
|
||||||
|
it("returns an already-pending message immediately", async () => {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
store.send("alice", "bob", "hello");
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
const result = await store.waitForMessage("bob", 1000, ac.signal);
|
||||||
|
expect(result.kind).toBe("message");
|
||||||
|
if (result.kind === "message") {
|
||||||
|
expect(result.message.body).toBe("hello");
|
||||||
|
expect(result.message.from_mailbox).toBe("alice");
|
||||||
|
expect(result.message.delivered_at).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks until a message arrives, then resolves", async () => {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
const pending = store.waitForMessage("bob", 5000, ac.signal);
|
||||||
|
|
||||||
|
setTimeout(() => store.send("alice", "bob", "later"), 50);
|
||||||
|
|
||||||
|
const result = await pending;
|
||||||
|
expect(result.kind).toBe("message");
|
||||||
|
if (result.kind === "message") expect(result.message.body).toBe("later");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves with timeout when nothing arrives", async () => {
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
const ac = new AbortController();
|
||||||
|
const result = await store.waitForMessage("bob", 80, ac.signal);
|
||||||
|
expect(result.kind).toBe("timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves with aborted when the signal fires", async () => {
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
const ac = new AbortController();
|
||||||
|
const pending = store.waitForMessage("bob", 5000, ac.signal);
|
||||||
|
setTimeout(() => ac.abort(), 30);
|
||||||
|
const result = await pending;
|
||||||
|
expect(result.kind).toBe("aborted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves with renamed when the mailbox is renamed mid-wait", async () => {
|
||||||
|
store.upsertMailbox("oldname");
|
||||||
|
const ac = new AbortController();
|
||||||
|
const pending = store.waitForMessage("oldname", 5000, ac.signal);
|
||||||
|
setTimeout(() => store.rename("oldname", "newname"), 30);
|
||||||
|
const result = await pending;
|
||||||
|
expect(result.kind).toBe("renamed");
|
||||||
|
if (result.kind === "renamed") expect(result.to).toBe("newname");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FIFO single-delivery: two waiters, one send, only the first gets the message", async () => {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
|
||||||
|
const ac1 = new AbortController();
|
||||||
|
const ac2 = new AbortController();
|
||||||
|
const w1 = store.waitForMessage("bob", 5000, ac1.signal);
|
||||||
|
// Stagger so w1 registers first.
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const w2 = store.waitForMessage("bob", 200, ac2.signal);
|
||||||
|
|
||||||
|
store.send("alice", "bob", "for-w1");
|
||||||
|
|
||||||
|
const [r1, r2] = await Promise.all([w1, w2]);
|
||||||
|
expect(r1.kind).toBe("message");
|
||||||
|
if (r1.kind === "message") expect(r1.message.body).toBe("for-w1");
|
||||||
|
expect(r2.kind).toBe("timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two pending messages are drained by two reconnecting waiters", async () => {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
store.send("alice", "bob", "m1");
|
||||||
|
store.send("alice", "bob", "m2");
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
const r1 = await store.waitForMessage("bob", 1000, ac.signal);
|
||||||
|
const r2 = await store.waitForMessage("bob", 1000, ac.signal);
|
||||||
|
expect(r1.kind).toBe("message");
|
||||||
|
expect(r2.kind).toBe("message");
|
||||||
|
if (r1.kind === "message" && r2.kind === "message") {
|
||||||
|
expect([r1.message.body, r2.message.body]).toEqual(["m1", "m2"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abort racing send: message is either delivered or remains pending, never lost", async () => {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
const pending = store.waitForMessage("bob", 5000, ac.signal);
|
||||||
|
|
||||||
|
// Fire abort and send in the same tick — either order is valid as long as the message is never lost.
|
||||||
|
ac.abort();
|
||||||
|
store.send("alice", "bob", "racy");
|
||||||
|
|
||||||
|
const r = await pending;
|
||||||
|
if (r.kind === "aborted") {
|
||||||
|
// Message must still be in DB for the next caller.
|
||||||
|
expect(store.peek("bob").pending).toBe(1);
|
||||||
|
} else if (r.kind === "message") {
|
||||||
|
expect(r.message.body).toBe("racy");
|
||||||
|
expect(store.peek("bob").pending).toBe(0);
|
||||||
|
} else {
|
||||||
|
throw new Error(`unexpected kind: ${r.kind}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejectAllWaiters resolves every pending waiter as aborted and empties the bucket map", async () => {
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
store.upsertMailbox("carol");
|
||||||
|
|
||||||
|
const ac1 = new AbortController();
|
||||||
|
const ac2 = new AbortController();
|
||||||
|
const ac3 = new AbortController();
|
||||||
|
const w1 = store.waitForMessage("bob", 5000, ac1.signal);
|
||||||
|
const w2 = store.waitForMessage("bob", 5000, ac2.signal);
|
||||||
|
const w3 = store.waitForMessage("carol", 5000, ac3.signal);
|
||||||
|
|
||||||
|
// Give all three a chance to register their waiters.
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
store.rejectAllWaiters();
|
||||||
|
|
||||||
|
const [r1, r2, r3] = await Promise.all([w1, w2, w3]);
|
||||||
|
expect(r1.kind).toBe("aborted");
|
||||||
|
expect(r2.kind).toBe("aborted");
|
||||||
|
expect(r3.kind).toBe("aborted");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,15 @@ import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { MailboxStore } from "../src/db.js";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { MailboxStore, RenameError } from "../src/db.js";
|
||||||
|
|
||||||
|
function backdate(dbPath: string, name: string, minutesAgo: number): void {
|
||||||
|
const db = new DatabaseSync(dbPath);
|
||||||
|
const iso = new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
||||||
|
db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?").run(iso, name);
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
let dbPath: string;
|
let dbPath: string;
|
||||||
@@ -75,6 +83,93 @@ describe("send / peek / check round-trip", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("rename", () => {
|
||||||
|
it("renames a mailbox and transfers undelivered messages", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob-old", "hi");
|
||||||
|
store.send("alice", "bob-old", "again");
|
||||||
|
|
||||||
|
const r = store.rename("bob-old", "bob-new");
|
||||||
|
expect(r.from).toBe("bob-old");
|
||||||
|
expect(r.to).toBe("bob-new");
|
||||||
|
expect(r.messagesTransferred).toBe(2);
|
||||||
|
|
||||||
|
// Old name is gone.
|
||||||
|
const list = store.listMailboxes().map((m) => m.name);
|
||||||
|
expect(list).toContain("bob-new");
|
||||||
|
expect(list).not.toContain("bob-old");
|
||||||
|
|
||||||
|
// Messages still pending under the new name.
|
||||||
|
const peek = store.peek("bob-new");
|
||||||
|
expect(peek.pending).toBe(2);
|
||||||
|
|
||||||
|
// checkInbox under the new name yields the original bodies and the original from.
|
||||||
|
const pulled = store.checkInbox("bob-new");
|
||||||
|
expect(pulled.map((m) => m.body)).toEqual(["hi", "again"]);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also rewrites the from-side when the renamed mailbox was a sender", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("sender-old", "bob", "msg-1");
|
||||||
|
store.rename("sender-old", "sender-new");
|
||||||
|
const pulled = store.checkInbox("bob");
|
||||||
|
expect(pulled).toHaveLength(1);
|
||||||
|
expect(pulled[0]!.from_mailbox).toBe("sender-new");
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats rename-to-same-name as a no-op touch", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
const r = store.rename("alice", "alice");
|
||||||
|
expect(r.messagesTransferred).toBe(0);
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).toEqual(["alice"]);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when target already exists", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
expect(() => store.rename("alice", "bob")).toThrow(RenameError);
|
||||||
|
try {
|
||||||
|
store.rename("alice", "bob");
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as RenameError).reason).toBe("target-exists");
|
||||||
|
}
|
||||||
|
// Source still present after the failed attempt.
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).toEqual(["alice", "bob"]);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when source is missing", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
store.rename("nope", "fresh");
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(RenameError);
|
||||||
|
expect((e as RenameError).reason).toBe("source-missing");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("listMailboxes", () => {
|
describe("listMailboxes", () => {
|
||||||
it("returns mailboxes alphabetically with pendingForYou for the caller", () => {
|
it("returns mailboxes alphabetically with pendingForYou for the caller", () => {
|
||||||
const store = new MailboxStore(dbPath);
|
const store = new MailboxStore(dbPath);
|
||||||
@@ -91,4 +186,178 @@ describe("listMailboxes", () => {
|
|||||||
store.close();
|
store.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides mailboxes older than hideAfterMinutes when filter is active", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("recent");
|
||||||
|
store.upsertMailbox("stale");
|
||||||
|
store.close();
|
||||||
|
backdate(dbPath, "stale", 90);
|
||||||
|
|
||||||
|
const store2 = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
const filtered = store2.listMailboxes(undefined, { hideAfterMinutes: 60 });
|
||||||
|
expect(filtered.map((m) => m.name)).toEqual(["recent"]);
|
||||||
|
const unfiltered = store2.listMailboxes();
|
||||||
|
expect(unfiltered.map((m) => m.name).sort()).toEqual(["recent", "stale"]);
|
||||||
|
} finally {
|
||||||
|
store2.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
store.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always includes the caller and senders with pending messages, even if stale", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("stale-sender", "me", "you have mail");
|
||||||
|
store.upsertMailbox("recent-other");
|
||||||
|
store.upsertMailbox("stale-other");
|
||||||
|
store.close();
|
||||||
|
backdate(dbPath, "stale-sender", 120);
|
||||||
|
backdate(dbPath, "stale-other", 120);
|
||||||
|
backdate(dbPath, "me", 120);
|
||||||
|
|
||||||
|
const store2 = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
const filtered = store2.listMailboxes("me", { hideAfterMinutes: 60 });
|
||||||
|
const names = filtered.map((m) => m.name).sort();
|
||||||
|
expect(names).toContain("me");
|
||||||
|
expect(names).toContain("stale-sender");
|
||||||
|
expect(names).toContain("recent-other");
|
||||||
|
expect(names).not.toContain("stale-other");
|
||||||
|
} finally {
|
||||||
|
store2.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
store.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pruneStale", () => {
|
||||||
|
it("deletes idle mailboxes with no pending messages and wipes their delivered history", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob", "old");
|
||||||
|
store.checkInbox("bob");
|
||||||
|
store.upsertMailbox("fresh");
|
||||||
|
store.close();
|
||||||
|
backdate(dbPath, "alice", 60 * 24 * 8);
|
||||||
|
backdate(dbPath, "bob", 60 * 24 * 8);
|
||||||
|
|
||||||
|
const store2 = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
const r = store2.pruneStale(60 * 24 * 7);
|
||||||
|
expect(r.deletedMailboxes).toBe(2);
|
||||||
|
expect(r.deletedMessages).toBe(1);
|
||||||
|
const remaining = store2.listMailboxes().map((m) => m.name);
|
||||||
|
expect(remaining).toEqual(["fresh"]);
|
||||||
|
} finally {
|
||||||
|
store2.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
store.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never deletes a mailbox that still has pending messages, even if idle", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob", "still pending");
|
||||||
|
store.close();
|
||||||
|
backdate(dbPath, "alice", 60 * 24 * 30);
|
||||||
|
backdate(dbPath, "bob", 60 * 24 * 30);
|
||||||
|
|
||||||
|
const store2 = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
const r = store2.pruneStale(60 * 24 * 7);
|
||||||
|
expect(r.deletedMailboxes).toBe(0);
|
||||||
|
expect(r.deletedMessages).toBe(0);
|
||||||
|
expect(store2.peek("bob").pending).toBe(1);
|
||||||
|
} finally {
|
||||||
|
store2.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
store.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns zero when deleteAfterMinutes is 0 (disabled)", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("x");
|
||||||
|
store.close();
|
||||||
|
backdate(dbPath, "x", 60 * 24 * 365);
|
||||||
|
|
||||||
|
const store2 = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
const r = store2.pruneStale(0);
|
||||||
|
expect(r).toEqual({ deletedMailboxes: 0, deletedMessages: 0 });
|
||||||
|
expect(store2.listMailboxes().map((m) => m.name)).toEqual(["x"]);
|
||||||
|
} finally {
|
||||||
|
store2.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
store.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteIfEmpty", () => {
|
||||||
|
it("deletes a fresh mailbox with no pending messages and wipes its delivered history", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob", "old");
|
||||||
|
store.checkInbox("bob");
|
||||||
|
const r = store.deleteIfEmpty("bob");
|
||||||
|
expect(r).toEqual({ deleted: true, reason: "deleted" });
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).not.toContain("bob");
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to delete when the mailbox has undelivered incoming mail", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob", "still pending");
|
||||||
|
const r = store.deleteIfEmpty("bob");
|
||||||
|
expect(r).toEqual({ deleted: false, reason: "has-pending" });
|
||||||
|
expect(store.peek("bob").pending).toBe(1);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to delete when the mailbox has undelivered outgoing mail", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob", "from alice");
|
||||||
|
const r = store.deleteIfEmpty("alice");
|
||||||
|
expect(r).toEqual({ deleted: false, reason: "has-pending" });
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).toContain("alice");
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for an unknown name (e.g. renamed mailbox)", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
const r = store.deleteIfEmpty("nope");
|
||||||
|
expect(r).toEqual({ deleted: false, reason: "not-found" });
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).toEqual(["alice"]);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
517
node/tests/hook.test.ts
Normal file
517
node/tests/hook.test.ts
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
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 { execFileSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
applyInstall,
|
||||||
|
applyUninstall,
|
||||||
|
buildPluginHookCommands,
|
||||||
|
buildSessionAnnounceLines,
|
||||||
|
deriveProjectName,
|
||||||
|
deriveSessionName,
|
||||||
|
formatActivePeerList,
|
||||||
|
formatMessagesForHook,
|
||||||
|
MANAGED_HOOK_EVENTS,
|
||||||
|
parseHookStdin,
|
||||||
|
readSettings,
|
||||||
|
sanitizeProjectName,
|
||||||
|
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("buildPluginHookCommands", () => {
|
||||||
|
it("returns one entry per managed event", () => {
|
||||||
|
const hooks = buildPluginHookCommands();
|
||||||
|
expect(hooks.map((h) => h.event)).toEqual([...MANAGED_HOOK_EVENTS]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses session-announce for SessionStart and session-end for SessionEnd", () => {
|
||||||
|
const map = new Map(buildPluginHookCommands().map((h) => [h.event, h.command]));
|
||||||
|
expect(map.get("SessionStart")).toBe("claude-mailbox session-announce");
|
||||||
|
expect(map.get("SessionEnd")).toBe("claude-mailbox session-end");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses check --hook for the three drain events", () => {
|
||||||
|
const map = new Map(buildPluginHookCommands().map((h) => [h.event, h.command]));
|
||||||
|
expect(map.get("UserPromptSubmit")).toBe("claude-mailbox check --hook");
|
||||||
|
expect(map.get("SubagentStop")).toBe("claude-mailbox check --hook");
|
||||||
|
expect(map.get("TaskCompleted")).toBe("claude-mailbox check --hook");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends --url to every command when provided", () => {
|
||||||
|
for (const h of buildPluginHookCommands("http://127.0.0.1:9000")) {
|
||||||
|
expect(h.command).toContain("--url http://127.0.0.1:9000");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotes URLs that need it", () => {
|
||||||
|
for (const h of buildPluginHookCommands("http://has space/")) {
|
||||||
|
expect(h.command).toContain('--url "http://has space/"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyInstall", () => {
|
||||||
|
it("creates hooks structure from empty settings", () => {
|
||||||
|
const s: Record<string, unknown> = {};
|
||||||
|
const r = applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||||
|
expect(r).toEqual({ changed: true, reason: "added" });
|
||||||
|
expect(s).toMatchObject({
|
||||||
|
hooks: {
|
||||||
|
UserPromptSubmit: [
|
||||||
|
{
|
||||||
|
matcher: "",
|
||||||
|
hooks: [{ type: "command", command: "claude-mailbox check --hook" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — does not duplicate the same command", () => {
|
||||||
|
const s: Record<string, unknown> = {};
|
||||||
|
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||||
|
const r = applyInstall(s, "UserPromptSubmit", "claude-mailbox check --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("installs into the target event, not always UserPromptSubmit", () => {
|
||||||
|
const s: Record<string, unknown> = {};
|
||||||
|
applyInstall(s, "TaskCompleted", "claude-mailbox check --hook");
|
||||||
|
expect((s.hooks as Record<string, unknown>).TaskCompleted).toBeDefined();
|
||||||
|
expect((s.hooks as Record<string, unknown>).UserPromptSubmit).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
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, "UserPromptSubmit", "claude-mailbox check --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 --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, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||||
|
const groups = (s.hooks as { UserPromptSubmit: { matcher?: string }[] }).UserPromptSubmit;
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[1]!.matcher).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyUninstall", () => {
|
||||||
|
it("removes a single-event install and cleans up empty structures", () => {
|
||||||
|
const s: Record<string, unknown> = {};
|
||||||
|
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||||
|
const r = applyUninstall(s);
|
||||||
|
expect(r).toEqual({ changed: true, reason: "removed" });
|
||||||
|
expect(s.hooks).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes hooks across all managed events in one pass", () => {
|
||||||
|
const s: Record<string, unknown> = {};
|
||||||
|
for (const h of buildPluginHookCommands()) {
|
||||||
|
applyInstall(s, h.event, h.command);
|
||||||
|
}
|
||||||
|
const r = applyUninstall(s);
|
||||||
|
expect(r).toEqual({ changed: true, reason: "removed" });
|
||||||
|
expect(s.hooks).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes legacy hooks with --name and removes them too", () => {
|
||||||
|
const s: Record<string, unknown> = {
|
||||||
|
hooks: {
|
||||||
|
UserPromptSubmit: [
|
||||||
|
{
|
||||||
|
matcher: "",
|
||||||
|
hooks: [{ type: "command", command: "claude-mailbox check --name alice --hook" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const r = applyUninstall(s);
|
||||||
|
expect(r.changed).toBe(true);
|
||||||
|
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 --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 across every event", () => {
|
||||||
|
const s: Record<string, unknown> = {};
|
||||||
|
for (const h of buildPluginHookCommands("http://x")) {
|
||||||
|
applyInstall(s, h.event, h.command);
|
||||||
|
}
|
||||||
|
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", () => {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeProjectName", () => {
|
||||||
|
it("lowercases and replaces non-alnum with dashes", () => {
|
||||||
|
expect(sanitizeProjectName("My Project!")).toBe("my-project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses runs of separators", () => {
|
||||||
|
expect(sanitizeProjectName("foo __ bar")).toBe("foo-bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims leading/trailing dashes", () => {
|
||||||
|
expect(sanitizeProjectName("--foo--")).toBe("foo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty for purely non-alnum input", () => {
|
||||||
|
expect(sanitizeProjectName("---")).toBe("");
|
||||||
|
expect(sanitizeProjectName("")).toBe("");
|
||||||
|
expect(sanitizeProjectName(null)).toBe("");
|
||||||
|
expect(sanitizeProjectName(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps long names", () => {
|
||||||
|
const out = sanitizeProjectName("a".repeat(120));
|
||||||
|
expect(out.length).toBeLessThanOrEqual(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveProjectName", () => {
|
||||||
|
it("uses cwd basename when not in a git repo", () => {
|
||||||
|
// tmpdir is virtually never inside a git repo; basename is platform-dependent.
|
||||||
|
const got = deriveProjectName(tmpdir());
|
||||||
|
expect(got).toMatch(/^[a-z0-9-]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'claude' when cwd is empty", () => {
|
||||||
|
expect(deriveProjectName("")).toBe("claude");
|
||||||
|
expect(deriveProjectName(null)).toBe("claude");
|
||||||
|
expect(deriveProjectName(undefined)).toBe("claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses git toplevel basename when called from inside a repo", () => {
|
||||||
|
// The test harness itself runs inside the claude-mailbox checkout.
|
||||||
|
let inRepo = false;
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: "pipe" });
|
||||||
|
inRepo = true;
|
||||||
|
} catch {
|
||||||
|
inRepo = false;
|
||||||
|
}
|
||||||
|
if (!inRepo) return; // CI without git in PATH — skip.
|
||||||
|
const got = deriveProjectName(process.cwd());
|
||||||
|
// Anywhere in the repo, we should resolve to the repo's basename — sanitized.
|
||||||
|
expect(got).toMatch(/^[a-z0-9-]+$/);
|
||||||
|
expect(got.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveSessionName", () => {
|
||||||
|
it("composes <project>-<short>", () => {
|
||||||
|
const got = deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", "");
|
||||||
|
expect(got).toBe("claude-abc12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives different names for different sessions in the same project", () => {
|
||||||
|
const a = deriveSessionName("aaaa1111-de67-89f0-1234-567890abcdef", "");
|
||||||
|
const b = deriveSessionName("bbbb2222-de67-89f0-1234-567890abcdef", "");
|
||||||
|
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("buildSessionAnnounceLines", () => {
|
||||||
|
it("includes the identity announcement and tool-call examples", () => {
|
||||||
|
const out = buildSessionAnnounceLines({
|
||||||
|
name: "alice-abc12345",
|
||||||
|
peers: [],
|
||||||
|
windowMinutes: 60,
|
||||||
|
maxPeers: 10,
|
||||||
|
}).join("\n");
|
||||||
|
expect(out).toContain("alice-abc12345");
|
||||||
|
expect(out).toContain("mcp__mailbox__send");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never auto-bootstraps the watcher — push delivery must be opt-in", () => {
|
||||||
|
const out = buildSessionAnnounceLines({
|
||||||
|
name: "alice-abc12345",
|
||||||
|
peers: [],
|
||||||
|
windowMinutes: 60,
|
||||||
|
maxPeers: 10,
|
||||||
|
}).join("\n");
|
||||||
|
expect(out).not.toContain("watch --block");
|
||||||
|
expect(out).not.toContain("run_in_background");
|
||||||
|
expect(out).not.toMatch(/REQUIRED FIRST ACTION/);
|
||||||
|
expect(out).not.toMatch(/MUST launch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("points the user to the opt-in collaborate skill / slash command", () => {
|
||||||
|
const out = buildSessionAnnounceLines({
|
||||||
|
name: "alice-abc12345",
|
||||||
|
peers: [],
|
||||||
|
windowMinutes: 60,
|
||||||
|
maxPeers: 10,
|
||||||
|
}).join("\n");
|
||||||
|
expect(out).toMatch(/mailbox-collaborate/);
|
||||||
|
expect(out).toMatch(/\/collaborate/);
|
||||||
|
expect(out).toMatch(/OPT-IN/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the peer list with the daemonError hint when daemon is unreachable", () => {
|
||||||
|
const out = buildSessionAnnounceLines({
|
||||||
|
name: "alice-abc12345",
|
||||||
|
peers: [],
|
||||||
|
windowMinutes: 60,
|
||||||
|
maxPeers: 10,
|
||||||
|
daemonError: "[Claude-Mailbox] Daemon not reachable at http://127.0.0.1:1.",
|
||||||
|
}).join("\n");
|
||||||
|
expect(out).toContain("Daemon not reachable");
|
||||||
|
// The misleading "no peers" line must NOT appear when the daemon is down.
|
||||||
|
expect(out).not.toMatch(/No other mailboxes seen/);
|
||||||
|
expect(out).not.toMatch(/Active peers/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||||
|
writeSettings(path, s);
|
||||||
|
const reloaded = readSettings(path);
|
||||||
|
expect(reloaded.hooks?.UserPromptSubmit?.[0]?.hooks[0]?.command).toBe(
|
||||||
|
"claude-mailbox check --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, "UserPromptSubmit", "claude-mailbox check --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`/);
|
||||||
|
});
|
||||||
|
});
|
||||||
174
node/tests/server-watch.test.ts
Normal file
174
node/tests/server-watch.test.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { MailboxStore } from "../src/db.js";
|
||||||
|
import { buildServer } from "../src/server.js";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let dbPath: string;
|
||||||
|
let store: MailboxStore;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-watch-"));
|
||||||
|
dbPath = join(dir, "test.db");
|
||||||
|
store = new MailboxStore(dbPath);
|
||||||
|
app = await buildServer(
|
||||||
|
{
|
||||||
|
port: 0,
|
||||||
|
bind: "127.0.0.1",
|
||||||
|
dbPath,
|
||||||
|
hideAfterMinutes: 0,
|
||||||
|
deleteAfterMinutes: 0,
|
||||||
|
sweepIntervalMinutes: 0,
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
);
|
||||||
|
await app.listen({ host: "127.0.0.1", port: 0 });
|
||||||
|
const addr = app.server.address();
|
||||||
|
if (!addr || typeof addr === "string") throw new Error("no address");
|
||||||
|
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
store.rejectAllWaiters();
|
||||||
|
await app.close();
|
||||||
|
store.close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /v1/watch", () => {
|
||||||
|
it("returns 200 with one pending message when one already exists", async () => {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
store.send("alice", "bob", "hi bob");
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=1`, {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { from: string; body: string; sentAt: string };
|
||||||
|
expect(body.from).toBe("alice");
|
||||||
|
expect(body.body).toBe("hi bob");
|
||||||
|
expect(typeof body.sentAt).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks until a message arrives, then returns 200", async () => {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
|
||||||
|
const pending = fetch(`${baseUrl}/v1/watch?name=bob&timeout=5`, {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
store.send("alice", "bob", "delayed");
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
const res = await pending;
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { body: string };
|
||||||
|
expect(body.body).toBe("delayed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 204 on timeout with no body", async () => {
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=1`, {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(await res.text()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 409 with { reason: 'renamed', to } when mailbox is renamed mid-wait", async () => {
|
||||||
|
store.upsertMailbox("oldname");
|
||||||
|
const pending = fetch(`${baseUrl}/v1/watch?name=oldname&timeout=5`, {
|
||||||
|
headers: { "X-Mailbox": "oldname" },
|
||||||
|
});
|
||||||
|
setTimeout(() => store.rename("oldname", "newname"), 50);
|
||||||
|
const res = await pending;
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
const body = (await res.json()) as { reason: string; to: string };
|
||||||
|
expect(body).toEqual({ reason: "renamed", to: "newname" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects mismatched X-Mailbox with 403", async () => {
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=1`, {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing X-Mailbox with 400", async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=1`);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing name with 400", async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/v1/watch?timeout=1`, {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps timeout at 300 seconds server-side (rejects with 400 if too high)", async () => {
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=999`, {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("client disconnect cleans up the waiter (no leak)", async () => {
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
const ac = new AbortController();
|
||||||
|
const pending = fetch(`${baseUrl}/v1/watch?name=bob&timeout=5`, {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
signal: ac.signal,
|
||||||
|
}).catch((err) => err);
|
||||||
|
|
||||||
|
// Give the request a chance to register the waiter.
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
ac.abort();
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
// Wait for the server-side TCP close event to propagate and remove the waiter.
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
|
// Send after abort. No one should receive it (no waiter exists).
|
||||||
|
// It should still be queued for a future caller.
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.send("alice", "bob", "post-abort");
|
||||||
|
|
||||||
|
// A fresh check should immediately return the queued message.
|
||||||
|
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=1`, {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { body: string };
|
||||||
|
expect(body.body).toBe("post-abort");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two clients, one message: exactly one client receives it", async () => {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
|
||||||
|
const r1 = fetch(`${baseUrl}/v1/watch?name=bob&timeout=2`, {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const r2 = fetch(`${baseUrl}/v1/watch?name=bob&timeout=2`, {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => store.send("alice", "bob", "single"), 50);
|
||||||
|
|
||||||
|
const [res1, res2] = await Promise.all([r1, r2]);
|
||||||
|
const statuses = [res1.status, res2.status].sort();
|
||||||
|
expect(statuses).toEqual([200, 204]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
import { MailboxStore } from "../src/db.js";
|
import { MailboxStore } from "../src/db.js";
|
||||||
import { buildServer } from "../src/server.js";
|
import { buildServer } from "../src/server.js";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
@@ -16,7 +17,17 @@ beforeEach(async () => {
|
|||||||
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-srv-"));
|
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-srv-"));
|
||||||
dbPath = join(dir, "test.db");
|
dbPath = join(dir, "test.db");
|
||||||
store = new MailboxStore(dbPath);
|
store = new MailboxStore(dbPath);
|
||||||
app = await buildServer({ port: 0, bind: "127.0.0.1", dbPath }, store);
|
app = await buildServer(
|
||||||
|
{
|
||||||
|
port: 0,
|
||||||
|
bind: "127.0.0.1",
|
||||||
|
dbPath,
|
||||||
|
hideAfterMinutes: 0,
|
||||||
|
deleteAfterMinutes: 0,
|
||||||
|
sweepIntervalMinutes: 0,
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
);
|
||||||
await app.listen({ host: "127.0.0.1", port: 0 });
|
await app.listen({ host: "127.0.0.1", port: 0 });
|
||||||
const addr = app.server.address();
|
const addr = app.server.address();
|
||||||
if (!addr || typeof addr === "string") throw new Error("no address");
|
if (!addr || typeof addr === "string") throw new Error("no address");
|
||||||
@@ -94,6 +105,99 @@ describe("REST surface", () => {
|
|||||||
expect(wrong.status).toBe(403);
|
expect(wrong.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /v1/rename transfers pending messages and exposes the new name", async () => {
|
||||||
|
// alice sends to bob-old.
|
||||||
|
await call("POST", "/v1/send", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: { to: "bob-old", body: "hi old bob" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rename = await call("POST", "/v1/rename", {
|
||||||
|
headers: { "X-Mailbox": "bob-old" },
|
||||||
|
body: { to: "bob-new" },
|
||||||
|
});
|
||||||
|
expect(rename.status).toBe(200);
|
||||||
|
expect(rename.body).toMatchObject({
|
||||||
|
from: "bob-old",
|
||||||
|
to: "bob-new",
|
||||||
|
messagesTransferred: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Peek under new name shows the pending msg; old name is empty.
|
||||||
|
const peekNew = await call("GET", "/v1/peek?name=bob-new");
|
||||||
|
expect(peekNew.body).toMatchObject({ pending: 1 });
|
||||||
|
const peekOld = await call("GET", "/v1/peek?name=bob-old");
|
||||||
|
expect(peekOld.body).toMatchObject({ pending: 0 });
|
||||||
|
|
||||||
|
// check-inbox under new name pulls the message.
|
||||||
|
const check = await call("POST", "/v1/check-inbox?name=bob-new", {
|
||||||
|
headers: { "X-Mailbox": "bob-new" },
|
||||||
|
});
|
||||||
|
const arr = check.body as Array<{ from: string; body: string }>;
|
||||||
|
expect(arr).toHaveLength(1);
|
||||||
|
expect(arr[0]!.body).toBe("hi old bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /v1/rename returns 409 when target name is taken", async () => {
|
||||||
|
await call("POST", "/v1/send", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: { to: "bob", body: "x" },
|
||||||
|
});
|
||||||
|
// 'taken' already exists thanks to upsert on X-Mailbox.
|
||||||
|
const r = await call("POST", "/v1/rename", {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
body: { to: "alice" },
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(409);
|
||||||
|
expect(r.body).toMatchObject({ reason: "target-exists" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /v1/rename requires X-Mailbox and body.to", async () => {
|
||||||
|
const missingHeader = await call("POST", "/v1/rename", { body: { to: "x" } });
|
||||||
|
expect(missingHeader.status).toBe(400);
|
||||||
|
const missingTo = await call("POST", "/v1/rename", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
expect(missingTo.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/v1/list filters out mailboxes idle beyond hideAfterMinutes", async () => {
|
||||||
|
await app.close();
|
||||||
|
store.close();
|
||||||
|
store = new MailboxStore(dbPath);
|
||||||
|
store.upsertMailbox("recent");
|
||||||
|
store.upsertMailbox("stale");
|
||||||
|
store.close();
|
||||||
|
const handle = new DatabaseSync(dbPath);
|
||||||
|
const past = new Date(Date.now() - 120 * 60_000).toISOString();
|
||||||
|
handle.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?").run(past, "stale");
|
||||||
|
handle.close();
|
||||||
|
|
||||||
|
store = new MailboxStore(dbPath);
|
||||||
|
app = await buildServer(
|
||||||
|
{
|
||||||
|
port: 0,
|
||||||
|
bind: "127.0.0.1",
|
||||||
|
dbPath,
|
||||||
|
hideAfterMinutes: 60,
|
||||||
|
deleteAfterMinutes: 0,
|
||||||
|
sweepIntervalMinutes: 0,
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
);
|
||||||
|
await app.listen({ host: "127.0.0.1", port: 0 });
|
||||||
|
const addr = app.server.address();
|
||||||
|
if (!addr || typeof addr === "string") throw new Error("no address");
|
||||||
|
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||||
|
|
||||||
|
const r = await call("GET", "/v1/list");
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
const names = (r.body as Array<{ name: string }>).map((m) => m.name);
|
||||||
|
expect(names).toContain("recent");
|
||||||
|
expect(names).not.toContain("stale");
|
||||||
|
});
|
||||||
|
|
||||||
it("/v1/list and /v1/peek are anonymous", async () => {
|
it("/v1/list and /v1/peek are anonymous", async () => {
|
||||||
await call("POST", "/v1/send", {
|
await call("POST", "/v1/send", {
|
||||||
headers: { "X-Mailbox": "alice" },
|
headers: { "X-Mailbox": "alice" },
|
||||||
@@ -106,4 +210,43 @@ describe("REST surface", () => {
|
|||||||
const peek = await call("GET", "/v1/peek?name=bob");
|
const peek = await call("GET", "/v1/peek?name=bob");
|
||||||
expect(peek.status).toBe(200);
|
expect(peek.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /v1/session-end deletes an empty mailbox and does not auto-recreate it", async () => {
|
||||||
|
await call("POST", "/v1/send", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: { to: "bob", body: "x" },
|
||||||
|
});
|
||||||
|
await call("POST", "/v1/check-inbox?name=bob", { headers: { "X-Mailbox": "bob" } });
|
||||||
|
|
||||||
|
const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "bob" } });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body).toMatchObject({ name: "bob", deleted: true, reason: "deleted" });
|
||||||
|
|
||||||
|
const list = await call("GET", "/v1/list");
|
||||||
|
const names = (list.body as Array<{ name: string }>).map((m) => m.name);
|
||||||
|
expect(names).not.toContain("bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /v1/session-end refuses to delete a mailbox with pending messages", async () => {
|
||||||
|
await call("POST", "/v1/send", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: { to: "bob", body: "still pending" },
|
||||||
|
});
|
||||||
|
const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "bob" } });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body).toMatchObject({ name: "bob", deleted: false, reason: "has-pending" });
|
||||||
|
|
||||||
|
const peek = await call("GET", "/v1/peek?name=bob");
|
||||||
|
expect(peek.body).toMatchObject({ pending: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /v1/session-end is a no-op for an unknown (e.g. renamed) name", async () => {
|
||||||
|
const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "renamed-away" } });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body).toMatchObject({ name: "renamed-away", deleted: false, reason: "not-found" });
|
||||||
|
|
||||||
|
const list = await call("GET", "/v1/list");
|
||||||
|
const names = (list.body as Array<{ name: string }>).map((m) => m.name);
|
||||||
|
expect(names).not.toContain("renamed-away");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
11
plugin/.claude-plugin/plugin.json
Normal file
11
plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "claude-mailbox",
|
||||||
|
"version": "1.5.6",
|
||||||
|
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, 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"]
|
||||||
|
}
|
||||||
9
plugin/.mcp.json
Normal file
9
plugin/.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mailbox": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "claude-mailbox",
|
||||||
|
"args": ["mcp-stdio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
plugin/README.md
Normal file
96
plugin/README.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# 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.git
|
||||||
|
/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:37849/health`
|
||||||
|
4. runs a self → self smoke test
|
||||||
|
|
||||||
|
After that, every prompt auto-pulls unread messages.
|
||||||
|
|
||||||
|
## Mailbox identity (the important bit)
|
||||||
|
|
||||||
|
Each Claude Code session gets its own mailbox name, automatically derived as `<project>-<session-short>`:
|
||||||
|
|
||||||
|
| Where the session runs | Resulting mailbox name |
|
||||||
|
|---|---|
|
||||||
|
| Inside a git repo | `<repo-basename>-a8b3c1d2` (e.g. `claude-mailbox-a8b3c1d2`) |
|
||||||
|
| Outside a git repo | `<cwd-basename>-a8b3c1d2` |
|
||||||
|
| No cwd in stdin (rare) | `claude-a8b3c1d2` |
|
||||||
|
|
||||||
|
So if you open two Claude Code sessions in the same project, they'll share the project prefix but differ in the session-short — e.g. `claude-mailbox-a8b3c1d2` and `claude-mailbox-d4e5f6a7`. No env-var, no manual prefix step.
|
||||||
|
|
||||||
|
If a session focuses on a sub-area (frontend, backend, …), Claude can call `mcp__mailbox__rename(current_name="…", new_name="claude-mailbox-frontend-a8b3c1d2")` to tag itself; pending messages are transferred. Peers using the old name re-discover via `list_mailboxes`.
|
||||||
|
|
||||||
|
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. |
|
||||||
|
| `SubagentStop` | `claude-mailbox check --hook` | Same as `UserPromptSubmit`, but fires when a subagent finishes. Lets the parent see peer messages that arrived during a long-running subagent run, instead of waiting until the next user prompt. |
|
||||||
|
| `TaskCompleted` | `claude-mailbox check --hook` | Fires whenever Claude marks a `TaskCreate` task completed — gives peers mid-run injection points between todo items without needing the opt-in watcher. |
|
||||||
|
| `SessionEnd` | `claude-mailbox session-end` | Deletes this session's auto-derived mailbox if it's empty (no pending messages). Renamed mailboxes are preserved. |
|
||||||
|
|
||||||
|
Cost: one local HTTP round-trip per fire + Node coldstart (~100ms on Windows).
|
||||||
|
|
||||||
|
Push delivery (real-time mid-turn wakeup via `claude-mailbox watch --block`) is **opt-in**. Invoke the `mailbox-collaborate` skill or the `/collaborate` slash command when you want peers to wake the session mid-task. Without it, the four pull hooks above are the always-on delivery path.
|
||||||
|
|
||||||
|
## MCP tools
|
||||||
|
|
||||||
|
The plugin ships a `.mcp.json` that spawns a **stdio MCP wrapper** (`claude-mailbox mcp-stdio`) so the daemon URL is configurable per machine via the `CLAUDE_MAILBOX_URL` env var (Claude Code doesn't yet support env substitution in HTTP MCP URLs — see issue #46889). The wrapper proxies tool calls to the daemon's REST API.
|
||||||
|
|
||||||
|
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. |
|
||||||
|
| `mcp__mailbox__rename` | `current_name`, `new_name` | Rename your own mailbox (e.g. add an area tag). Pending messages are transferred. Use the new name afterward. |
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
36
plugin/commands/collaborate.md
Normal file
36
plugin/commands/collaborate.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
description: Enter Claude-Mailbox collaboration mode — start the push-delivery watch loop so peers can wake Claude mid-task.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are now in **mailbox collaboration mode**. Peers can wake you mid-task by sending mail to your mailbox.
|
||||||
|
|
||||||
|
## Launch the watcher
|
||||||
|
|
||||||
|
Your mailbox name was announced at SessionStart (format: `<project>-<short-session-id>`). Use it in place of `<NAME>` below.
|
||||||
|
|
||||||
|
Launch the watcher as a background bash task immediately:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bash(command="claude-mailbox watch --block --name <NAME>", run_in_background=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
The watcher is short-lived by design — it exits after exactly one event (delivered message, 25-second timeout, rename, or daemon error). Claude Code's BashOutput notifications fire only on task completion, so every exit MUST be followed by a fresh background relaunch with the same command. Gaps in the loop = missed pushes.
|
||||||
|
|
||||||
|
## Handling watcher completions
|
||||||
|
|
||||||
|
When a watcher task completes, inspect its stdout and exit code:
|
||||||
|
|
||||||
|
- **stdout contains `[Claude-Mailbox] Mail from <peer>: <body>`** → treat the body as an inbound interrupt with priority over your current plan. Address it, then relaunch the watcher.
|
||||||
|
- **stdout contains `[Claude-Mailbox] Mailbox renamed to '<new>'`** → relaunch with `--name <new>`, and use `<new>` for all future `mcp__mailbox__*` calls (update your identity).
|
||||||
|
- **exit code 3 with no stdout** → silent timeout, just relaunch.
|
||||||
|
- **exit code 2** → daemon unreachable; wait ~5 seconds, then relaunch.
|
||||||
|
- **any other exit code** → report it to the user, then relaunch.
|
||||||
|
|
||||||
|
## Stopping
|
||||||
|
|
||||||
|
Keep the loop running until the user says "stop watching", "stop collaborating", "end collaboration", or similar. When they do:
|
||||||
|
|
||||||
|
- Stop relaunching after the next completion.
|
||||||
|
- If a watcher is currently mid-poll and the user wants it killed immediately, use `TaskStop` on its task id.
|
||||||
|
|
||||||
|
Do not re-enter collaboration mode on your own after stopping — wait for the user to invoke this command again.
|
||||||
123
plugin/commands/mailbox-doctor.md
Normal file
123
plugin/commands/mailbox-doctor.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
description: Diagnose and auto-fix the Claude-Mailbox setup (Node version, binary install, port-conflict detection, 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`, `node`, `where`/`which`, and HTTP probes. Use `Read`/`Edit`/`Write` for `.claude/settings.json` and `mailbox.json`. Never run `sudo` automatically — if elevation is needed, stop and ask.
|
||||||
|
|
||||||
|
## Step 1 — Node.js version
|
||||||
|
|
||||||
|
Run: `node --version`
|
||||||
|
|
||||||
|
claude-mailbox uses Node's built-in `node:sqlite` and therefore requires **Node 24 or newer**. Parse the major version from the output.
|
||||||
|
|
||||||
|
- **Major ≥ 24** → ✓ record the version, continue.
|
||||||
|
- **Major == 22 or 23** → ✗ Stop. `node:sqlite` is experimental on these and requires `--experimental-sqlite`. Print:
|
||||||
|
> Found Node `<X.Y.Z>`. claude-mailbox needs Node 24 LTS or newer. Install via `nvm install 24 && nvm use 24` (or `nvs` / `winget install OpenJS.NodeJS.LTS` on Windows), then re-run the doctor.
|
||||||
|
- **Major < 22** → ✗ Stop with the same message; this Node is end-of-life.
|
||||||
|
- **Major ≥ 26** with `better-sqlite3` still installed globally from a previous version → just note: "Node `<X.Y.Z>` is fine for the current claude-mailbox (no native deps); ignore any old `better-sqlite3` build warnings from a prior install."
|
||||||
|
|
||||||
|
If `node --version` itself fails (`command not found`), stop and tell the user to install Node 24+ first.
|
||||||
|
|
||||||
|
## Step 2 — 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 3 — port-conflict check (before autostart!)
|
||||||
|
|
||||||
|
Default port is 37849. Probe whether anything is already on it:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -sf http://127.0.0.1:37849/health
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Returns a JSON body with `"status":"ok"` and a `version` field that matches `claude-mailbox --version`** → it's already our daemon, ✓ skip to Step 5.
|
||||||
|
- **Returns 200 with `"status":"ok"` but a different `version`** → it's an older claude-mailbox; treat as running, ✓.
|
||||||
|
- **Returns non-200, non-JSON, or any other foreign response** → **port conflict**. Some other process owns 37849.
|
||||||
|
- **Connection refused** → port is free, ✓ continue to Step 4.
|
||||||
|
|
||||||
|
If port conflict detected:
|
||||||
|
1. Tell the user which process holds the port (Windows: `Get-NetTCPConnection -LocalPort 37849 | Select-Object OwningProcess`, then `Get-Process -Id <pid>`; macOS/Linux: `lsof -i :37849`).
|
||||||
|
2. Pick a free port. Default suggestion: **47900**. Verify it's free: `curl -sf http://127.0.0.1:47900/health` should fail with connection refused.
|
||||||
|
3. Read `~/.claude-mailbox/mailbox.json` (create empty `{}` if missing) and merge `{"port": <chosen>}`. Write back.
|
||||||
|
4. Also write the override into `.claude/settings.json` env so the plugin's hooks find the right URL:
|
||||||
|
```json
|
||||||
|
"env": { "CLAUDE_MAILBOX_URL": "http://127.0.0.1:<chosen>" }
|
||||||
|
```
|
||||||
|
Merge into existing env, preserving other keys.
|
||||||
|
5. Mark `restart_needed = true`.
|
||||||
|
|
||||||
|
## Step 4 — 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.
|
||||||
|
|
||||||
|
**Behavior on `install-autostart`:** The CLI tries a Scheduled Task first (`schtasks /RL LIMITED`, no admin). If Windows Group Policy returns "Access is denied", it falls back transparently to an `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` registry entry plus a hidden `node serve` process — same per-user persistence, no admin needed. The chosen mechanism is recorded in `~/.claude-mailbox/autostart-mode` and respected by `status`/`start`/`stop`/`uninstall-autostart`.
|
||||||
|
|
||||||
|
If `install-autostart` still fails after both attempts (very rare — would mean both `schtasks` and `reg add` are blocked), stop and report what `status` and `start` printed.
|
||||||
|
|
||||||
|
## Step 5 — health probe
|
||||||
|
|
||||||
|
Hit `http://127.0.0.1:<port>/health` (use the configured port, not necessarily 37849). Expect a JSON body with `"status":"ok"` AND a `version` matching `claude-mailbox --version`. If unreachable or version mismatch, stop and report.
|
||||||
|
|
||||||
|
## Step 6 — mailbox identity
|
||||||
|
|
||||||
|
**No prompt.** Each Claude Code session gets a unique mailbox name auto-derived as `<project>-<short_session_id>`, where `<project>` is the git-repo basename of the session's `cwd` (or the cwd basename if not a git repo). Example: `claude-mailbox-a8b3c1d2`.
|
||||||
|
|
||||||
|
✓ "Mailbox name will be auto-derived as `<project>-<short_session_id>`."
|
||||||
|
|
||||||
|
Sessions can also rename themselves at runtime via the `mcp__mailbox__rename` MCP tool — e.g. to add an area tag like `claude-mailbox-frontend-a8b3c1d2`. No config involved.
|
||||||
|
|
||||||
|
## Step 7 — smoke test
|
||||||
|
|
||||||
|
Use two ephemeral names — we don't need the real session name here:
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-mailbox send --from doctor-probe-a --to doctor-probe-b --body "ping from doctor"
|
||||||
|
claude-mailbox check --name doctor-probe-b
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the port was changed in Step 3, pass `--url http://127.0.0.1:<port>` to both.)
|
||||||
|
|
||||||
|
The `check` output must be a JSON array with one message: `from: doctor-probe-a`, body matches. ✓ on success, ✗ otherwise.
|
||||||
|
|
||||||
|
## Step 8 — summary
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude-Mailbox doctor
|
||||||
|
node: <version>
|
||||||
|
binary: <version>
|
||||||
|
daemon: Running (port: <port>, what you did if anything)
|
||||||
|
health: ok
|
||||||
|
port conflict: none | resolved (moved from 37849 to <port>)
|
||||||
|
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."
|
||||||
|
- All ✓ and restart needed → "Restart Claude Code (or open a new session) so the SessionStart hook picks up the new env values."
|
||||||
|
- 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:37849/health returns 200, else "unreachable">
|
||||||
|
mailbox name: auto-derived per session as <project>-<short-session-id> (see SessionStart announcement)
|
||||||
|
pending: n/a (the session's mailbox name isn't known until SessionStart runs in this session's context)
|
||||||
|
```
|
||||||
|
|
||||||
|
End with one line:
|
||||||
|
|
||||||
|
- All good → `Status: OK`
|
||||||
|
- Missing daemon → `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.`
|
||||||
110
plugin/commands/mailbox-update.md
Normal file
110
plugin/commands/mailbox-update.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
Throughout, treat the **daemon's `/health` endpoint** as the ground truth for "is the daemon running and on what version", not `claude-mailbox status` (which only reflects the autostart wrapper). Always use the registry override flag `--@kuns:registry=https://git.kuns.dev/api/packages/releases/npm/` on every `npm` call, so the upgrade works even when the user's `.npmrc` is unreachable (e.g. roaming `$HOME` on a network share).
|
||||||
|
|
||||||
|
## Step 1 — current state (must run before anything is changed)
|
||||||
|
|
||||||
|
Run, in order, and remember each result:
|
||||||
|
|
||||||
|
1. `claude-mailbox --version`
|
||||||
|
- Exit 0 → `CURRENT_CLI = <stdout trimmed>`.
|
||||||
|
- Non-zero → stop. The binary isn't installed; suggest `/claude-mailbox:mailbox-doctor` and exit.
|
||||||
|
2. `claude-mailbox status`
|
||||||
|
- Record as `AUTOSTART_STATE ∈ { Running, Stopped, NotInstalled }`.
|
||||||
|
3. Read the configured port. Try `~/.claude-mailbox/mailbox.json`; if absent or no `port` field, use **37849**. Call this `PORT`.
|
||||||
|
4. Probe `curl -sf -m 2 http://127.0.0.1:$PORT/health`.
|
||||||
|
- On success, parse JSON → `CURRENT_HEALTH_VERSION = .version`. Set `DAEMON_REACHABLE = true`.
|
||||||
|
- On failure → `CURRENT_HEALTH_VERSION = null`, `DAEMON_REACHABLE = false`.
|
||||||
|
|
||||||
|
Note any inconsistencies (e.g. `AUTOSTART_STATE = NotInstalled` but `DAEMON_REACHABLE = true` means a manually-started foreground daemon) — they affect Step 5.
|
||||||
|
|
||||||
|
## Step 2 — latest published version
|
||||||
|
|
||||||
|
Run: `npm view --@kuns:registry=https://git.kuns.dev/api/packages/releases/npm/ @kuns/claude-mailbox version`
|
||||||
|
|
||||||
|
If that fails for any reason (network, registry), fall back to:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm view --registry=https://git.kuns.dev/api/packages/releases/npm/ @kuns/claude-mailbox version
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the result as `LATEST`. If both calls fail, stop and report the network/registry error.
|
||||||
|
|
||||||
|
## Step 3 — compare
|
||||||
|
|
||||||
|
- `CURRENT_CLI === LATEST` AND `CURRENT_HEALTH_VERSION === LATEST` (or `DAEMON_REACHABLE = false`) → print "Already up to date (vLATEST)." and stop.
|
||||||
|
- `CURRENT_CLI === LATEST` but `CURRENT_HEALTH_VERSION !== LATEST` → the CLI is fresh but the running daemon is the old binary. Tell the user "Binary already at LATEST but the running daemon is still v$CURRENT_HEALTH_VERSION — restart needed." Then jump to Step 5 (no npm install).
|
||||||
|
- Otherwise → tell the user `CURRENT_CLI` → `LATEST` and ask for confirmation before proceeding.
|
||||||
|
|
||||||
|
Also warn before confirmation if `AUTOSTART_STATE = NotInstalled` AND `DAEMON_REACHABLE = false`:
|
||||||
|
|
||||||
|
> Heads-up: autostart is not installed and no daemon is reachable on port $PORT. After the upgrade I can install the new binary, but I won't be able to start the daemon automatically — you'd need `/claude-mailbox:mailbox-doctor` to wire up autostart, or run `claude-mailbox serve` manually. Proceed anyway?
|
||||||
|
|
||||||
|
## Step 4 — install the new package
|
||||||
|
|
||||||
|
On user confirmation:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install -g @kuns/claude-mailbox@latest --@kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
- The scope override is mandatory — do not omit it, even if `npm config get @kuns:registry` looks right. It costs nothing and protects against unreachable user-level `.npmrc`.
|
||||||
|
- On Linux/macOS the install may fail with EACCES. **Do not run sudo automatically.** Stop and ask how the user wants to proceed (e.g. `sudo`, switch to nvm/fnm).
|
||||||
|
- On any other failure, stop and report. Do **not** touch the daemon — leaving the old daemon running is the safe rollback.
|
||||||
|
|
||||||
|
After install, run `claude-mailbox --version` and confirm it now reports `LATEST`. If not (PATH shadowing, stale `which`), stop and report — the daemon is still on the old version, which is fine to keep running.
|
||||||
|
|
||||||
|
## Step 5 — restart the daemon
|
||||||
|
|
||||||
|
Now swap the daemon over to the new binary.
|
||||||
|
|
||||||
|
1. Stop the existing daemon if anything is running:
|
||||||
|
- If `AUTOSTART_STATE = Running` → `claude-mailbox stop` and wait up to 5s for `/health` on `PORT` to start failing (poll once per second).
|
||||||
|
- If `DAEMON_REACHABLE = true` but `AUTOSTART_STATE = NotInstalled` → a foreground/manual daemon is running. Tell the user:
|
||||||
|
> A daemon is reachable on port $PORT but autostart is not installed, so I can't stop it. Stop the manual `claude-mailbox serve` process yourself, then re-run this command to finish the restart.
|
||||||
|
Then stop here.
|
||||||
|
- Otherwise nothing to stop.
|
||||||
|
2. Start the daemon, picking the path that matches `AUTOSTART_STATE`:
|
||||||
|
- `Running` or `Stopped` (i.e. autostart is installed) → `claude-mailbox start`.
|
||||||
|
- `NotInstalled` → skip the start. After the loop below times out, tell the user to run `/claude-mailbox:mailbox-doctor` to install autostart, then exit.
|
||||||
|
3. Poll `curl -sf -m 1 http://127.0.0.1:$PORT/health` up to **10 times with 1s sleeps**. Stop polling as soon as one returns JSON with `"status":"ok"`.
|
||||||
|
4. Outcome:
|
||||||
|
- Health came up AND `version === LATEST` → ✓ proceed to Step 6.
|
||||||
|
- Health came up but `version !== LATEST` → the wrapper started an *old* binary somewhere on PATH. Dump `which claude-mailbox` / `where claude-mailbox` and stop with that info.
|
||||||
|
- Health did not come up → dump the most recent daemon log to help diagnose. Try, in order, the first one that exists:
|
||||||
|
- Windows Scheduled Task / fallback: `%LOCALAPPDATA%\ClaudeMailbox\logs\daemon.log` (tail the last 40 lines)
|
||||||
|
- Windows Service variant: `Get-WinEvent -ProviderName ClaudeMailbox -MaxEvents 20` via `powershell -NoProfile -Command "..."`
|
||||||
|
- macOS launchd: `~/Library/Logs/ClaudeMailbox/daemon.log` (last 40 lines)
|
||||||
|
- Linux systemd-user: `journalctl --user -u claude-mailbox -n 40 --no-pager`
|
||||||
|
If none exist or all are empty, just say "No daemon logs found; try `claude-mailbox serve` in a terminal to see the error directly."
|
||||||
|
Stop with that diagnostic; do not pretend the update succeeded.
|
||||||
|
|
||||||
|
## Step 6 — summary
|
||||||
|
|
||||||
|
Print exactly this block:
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude-Mailbox update
|
||||||
|
previous version: <CURRENT_CLI>
|
||||||
|
new version: <claude-mailbox --version output>
|
||||||
|
daemon health: ok (v<version from /health>) | unreachable
|
||||||
|
daemon autostart: Running | Stopped | NotInstalled
|
||||||
|
pending messages: <total pending across all mailboxes — sum of pendingForYou from `claude-mailbox list`>
|
||||||
|
```
|
||||||
|
|
||||||
|
End with one of:
|
||||||
|
|
||||||
|
- New CLI version matches `LATEST` AND `/health` returns `version === LATEST` → **"Update complete."**
|
||||||
|
- Anything else → **"Update incomplete: <the first concrete failure from Step 4 or 5>."**
|
||||||
|
|
||||||
|
## Operating notes
|
||||||
|
|
||||||
|
- **Always use the scope override flag** (`--@kuns:registry=...`) on every npm call. The user's `.npmrc` may be on a network drive that npm can't read.
|
||||||
|
- **Never rely on `claude-mailbox status` alone** to decide "the daemon is fine". Always cross-check with a `/health` probe — the status command only reflects whether the autostart task is in a Running state and doesn't notice if the process inside crashed at boot.
|
||||||
|
- **Never run `npm install` without first locking in the current state.** If the install fails, the safe rollback is to do nothing — the old daemon is still running.
|
||||||
|
- **Never `claude-mailbox stop` before the install succeeds.** Downtime is paid only after we know the new binary is on disk.
|
||||||
54
plugin/hooks/hooks.json
Normal file
54
plugin/hooks/hooks.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox session-announce"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox check --hook"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox check --hook"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TaskCompleted": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox check --hook"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox session-end"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
37
plugin/skills/mailbox-collaborate/SKILL.md
Normal file
37
plugin/skills/mailbox-collaborate/SKILL.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: mailbox-collaborate
|
||||||
|
description: Start the Claude-Mailbox push-delivery watch loop for this session so peers can wake Claude mid-task. Use when the user says "let's collaborate", "wait for mail", "watch the mailbox", "listen for peers", "enter collaboration mode", or invokes /collaborate. Without this skill the watcher is OFF — peers can still leave messages but Claude only sees them on the next user prompt. Do NOT launch the watcher without this opt-in.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are now in **mailbox collaboration mode**. Peers can wake you mid-task by sending mail to your mailbox.
|
||||||
|
|
||||||
|
## Launch the watcher
|
||||||
|
|
||||||
|
Your mailbox name was announced at SessionStart (format: `<project>-<short-session-id>`). Use it in place of `<NAME>` below.
|
||||||
|
|
||||||
|
Launch the watcher as a background bash task immediately:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bash(command="claude-mailbox watch --block --name <NAME>", run_in_background=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
The watcher is short-lived by design — it exits after exactly one event (delivered message, 25-second timeout, rename, or daemon error). Claude Code's BashOutput notifications fire only on task completion, so every exit MUST be followed by a fresh background relaunch with the same command. Gaps in the loop = missed pushes.
|
||||||
|
|
||||||
|
## Handling watcher completions
|
||||||
|
|
||||||
|
When a watcher task completes, inspect its stdout and exit code:
|
||||||
|
|
||||||
|
- **stdout contains `[Claude-Mailbox] Mail from <peer>: <body>`** → treat the body as an inbound interrupt with priority over your current plan. Address it, then relaunch the watcher.
|
||||||
|
- **stdout contains `[Claude-Mailbox] Mailbox renamed to '<new>'`** → relaunch with `--name <new>`, and use `<new>` for all future `mcp__mailbox__*` calls (update your identity).
|
||||||
|
- **exit code 3 with no stdout** → silent timeout, just relaunch.
|
||||||
|
- **exit code 2** → daemon unreachable; wait ~5 seconds, then relaunch.
|
||||||
|
- **any other exit code** → report it to the user, then relaunch.
|
||||||
|
|
||||||
|
## Stopping
|
||||||
|
|
||||||
|
Keep the loop running until the user says "stop watching", "stop collaborating", "end collaboration", or similar. When they do:
|
||||||
|
|
||||||
|
- Stop relaunching after the next completion.
|
||||||
|
- If a watcher is currently mid-poll and the user wants it killed immediately, use `TaskStop` on its task id.
|
||||||
|
|
||||||
|
Do not re-enter collaboration mode on your own after stopping — wait for the user to invoke this skill again.
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<AssemblyName>claude-mailbox</AssemblyName>
|
|
||||||
<RootNamespace>ClaudeMailbox</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
|
||||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Cli;
|
|
||||||
|
|
||||||
public static class ClientCommands
|
|
||||||
{
|
|
||||||
private const string DefaultUrl = "http://127.0.0.1:47822";
|
|
||||||
|
|
||||||
public static async Task<int> RunAsync(string[] args)
|
|
||||||
{
|
|
||||||
var command = args[0];
|
|
||||||
var url = GetOption(args, "--url") ?? DefaultUrl;
|
|
||||||
|
|
||||||
using var client = new HttpClient { BaseAddress = new Uri(url) };
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return command switch
|
|
||||||
{
|
|
||||||
"send" => await Send(args, client),
|
|
||||||
"peek" => await Peek(args, client),
|
|
||||||
"check" => await Check(args, client),
|
|
||||||
"list" => await List(client),
|
|
||||||
_ => PrintError($"Unknown command: {command}"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (HttpRequestException ex)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"Could not reach daemon at {url}: {ex.Message}");
|
|
||||||
Console.Error.WriteLine("Is 'claude-mailbox serve' running?");
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> Send(string[] args, HttpClient client)
|
|
||||||
{
|
|
||||||
var to = Required(args, "--to");
|
|
||||||
var from = Required(args, "--from");
|
|
||||||
var body = Required(args, "--body");
|
|
||||||
|
|
||||||
var req = new HttpRequestMessage(HttpMethod.Post, "/v1/send")
|
|
||||||
{
|
|
||||||
Content = JsonContent.Create(new { to, body }),
|
|
||||||
};
|
|
||||||
req.Headers.Add("X-Mailbox", from);
|
|
||||||
|
|
||||||
var res = await client.SendAsync(req);
|
|
||||||
res.EnsureSuccessStatusCode();
|
|
||||||
Console.WriteLine(await res.Content.ReadAsStringAsync());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> Peek(string[] args, HttpClient client)
|
|
||||||
{
|
|
||||||
var name = Required(args, "--name");
|
|
||||||
var res = await client.GetAsync($"/v1/peek?name={Uri.EscapeDataString(name)}");
|
|
||||||
res.EnsureSuccessStatusCode();
|
|
||||||
Console.WriteLine(await res.Content.ReadAsStringAsync());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> Check(string[] args, HttpClient client)
|
|
||||||
{
|
|
||||||
var name = Required(args, "--name");
|
|
||||||
|
|
||||||
var req = new HttpRequestMessage(HttpMethod.Post, $"/v1/check-inbox?name={Uri.EscapeDataString(name)}");
|
|
||||||
req.Headers.Add("X-Mailbox", name);
|
|
||||||
|
|
||||||
var res = await client.SendAsync(req);
|
|
||||||
res.EnsureSuccessStatusCode();
|
|
||||||
Console.WriteLine(await res.Content.ReadAsStringAsync());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> List(HttpClient client)
|
|
||||||
{
|
|
||||||
var res = await client.GetAsync("/v1/list");
|
|
||||||
res.EnsureSuccessStatusCode();
|
|
||||||
Console.WriteLine(await res.Content.ReadAsStringAsync());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string? GetOption(string[] args, string name)
|
|
||||||
{
|
|
||||||
for (var i = 0; i < args.Length - 1; i++)
|
|
||||||
if (args[i] == name) return args[i + 1];
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Required(string[] args, string name)
|
|
||||||
{
|
|
||||||
var v = GetOption(args, name);
|
|
||||||
if (string.IsNullOrWhiteSpace(v))
|
|
||||||
throw new ArgumentException($"Missing required option {name}");
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int PrintError(string msg)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(msg);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.Runtime.Versioning;
|
|
||||||
using System.Security.AccessControl;
|
|
||||||
using System.Security.Principal;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Cli;
|
|
||||||
|
|
||||||
public static class ServiceCommands
|
|
||||||
{
|
|
||||||
public const string ServiceName = "ClaudeMailbox";
|
|
||||||
|
|
||||||
public static Task<int> RunAsync(string[] args)
|
|
||||||
{
|
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine("Service commands are Windows-only.");
|
|
||||||
return Task.FromResult(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
var verb = args[0];
|
|
||||||
return verb switch
|
|
||||||
{
|
|
||||||
"install-service" => Task.FromResult(InstallService(args)),
|
|
||||||
"uninstall-service" => Task.FromResult(UninstallService(args)),
|
|
||||||
"start" => Task.FromResult(RunSc("start", ServiceName)),
|
|
||||||
"stop" => Task.FromResult(RunSc("stop", ServiceName)),
|
|
||||||
"status" => Task.FromResult(Status()),
|
|
||||||
_ => Task.FromResult(PrintError($"Unknown service command: {verb}")),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
|
||||||
private static bool IsAdministrator()
|
|
||||||
{
|
|
||||||
using var identity = WindowsIdentity.GetCurrent();
|
|
||||||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
|
||||||
}
|
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
|
||||||
private static int RequireAdmin()
|
|
||||||
{
|
|
||||||
if (IsAdministrator()) return 0;
|
|
||||||
Console.Error.WriteLine("This command requires Administrator privileges.");
|
|
||||||
return 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
|
||||||
private static int InstallService(string[] args)
|
|
||||||
{
|
|
||||||
var admin = RequireAdmin();
|
|
||||||
if (admin != 0) return admin;
|
|
||||||
|
|
||||||
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
|
||||||
var dataDir = Path.Combine(programData, "ClaudeMailbox");
|
|
||||||
var configPath = Path.Combine(dataDir, "mailbox.json");
|
|
||||||
var defaultDbPath = Path.Combine(dataDir, "mailbox.db");
|
|
||||||
|
|
||||||
Directory.CreateDirectory(dataDir);
|
|
||||||
ApplyLocalServiceAcl(dataDir);
|
|
||||||
|
|
||||||
if (!File.Exists(configPath))
|
|
||||||
{
|
|
||||||
var portStr = ClientCommands.GetOption(args, "--port");
|
|
||||||
var port = int.TryParse(portStr, out var p) ? p : 47822;
|
|
||||||
var bind = ClientCommands.GetOption(args, "--bind") ?? "127.0.0.1";
|
|
||||||
var dbPath = ClientCommands.GetOption(args, "--db-path") ?? defaultDbPath;
|
|
||||||
|
|
||||||
var json = $$"""
|
|
||||||
{
|
|
||||||
"port": {{port}},
|
|
||||||
"bind": {{System.Text.Json.JsonSerializer.Serialize(bind)}},
|
|
||||||
"dbPath": {{System.Text.Json.JsonSerializer.Serialize(dbPath)}}
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
File.WriteAllText(configPath, json);
|
|
||||||
Console.WriteLine($"Seeded config: {configPath}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Config already exists, leaving untouched: {configPath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var exe = Environment.ProcessPath
|
|
||||||
?? throw new InvalidOperationException("Cannot resolve current executable path.");
|
|
||||||
|
|
||||||
var binPath = $"\"{exe}\" serve --config \"{configPath}\"";
|
|
||||||
|
|
||||||
var createExit = RunSc("create", ServiceName,
|
|
||||||
"binPath=", binPath,
|
|
||||||
"start=", "auto",
|
|
||||||
"DisplayName=", "Claude Mailbox",
|
|
||||||
"obj=", "NT AUTHORITY\\LocalService");
|
|
||||||
if (createExit != 0)
|
|
||||||
{
|
|
||||||
if (createExit == 1073)
|
|
||||||
Console.Error.WriteLine($"Service '{ServiceName}' already exists. Run 'claude-mailbox uninstall-service' first.");
|
|
||||||
else
|
|
||||||
Console.Error.WriteLine($"sc create failed (exit {createExit}).");
|
|
||||||
return createExit;
|
|
||||||
}
|
|
||||||
|
|
||||||
RunSc("description", ServiceName, "MCP mailbox server for parallel Claude sessions");
|
|
||||||
|
|
||||||
Console.WriteLine($"Service '{ServiceName}' installed. Start with: claude-mailbox start");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
|
||||||
private static void ApplyLocalServiceAcl(string path)
|
|
||||||
{
|
|
||||||
var info = new DirectoryInfo(path);
|
|
||||||
var security = info.GetAccessControl();
|
|
||||||
var localService = new SecurityIdentifier(WellKnownSidType.LocalServiceSid, null);
|
|
||||||
security.AddAccessRule(new FileSystemAccessRule(
|
|
||||||
localService,
|
|
||||||
FileSystemRights.Modify | FileSystemRights.Synchronize,
|
|
||||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
|
|
||||||
PropagationFlags.None,
|
|
||||||
AccessControlType.Allow));
|
|
||||||
info.SetAccessControl(security);
|
|
||||||
}
|
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
|
||||||
private static int UninstallService(string[] args)
|
|
||||||
{
|
|
||||||
var admin = RequireAdmin();
|
|
||||||
if (admin != 0) return admin;
|
|
||||||
|
|
||||||
var purge = Array.IndexOf(args, "--purge") >= 0;
|
|
||||||
|
|
||||||
// Best-effort stop; ignore failure if not running.
|
|
||||||
RunSc("stop", ServiceName);
|
|
||||||
|
|
||||||
var deleteExit = RunSc("delete", ServiceName);
|
|
||||||
if (deleteExit != 0)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"sc delete failed (exit {deleteExit}).");
|
|
||||||
return deleteExit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (purge)
|
|
||||||
{
|
|
||||||
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
|
||||||
var dataDir = Path.Combine(programData, "ClaudeMailbox");
|
|
||||||
if (Directory.Exists(dataDir))
|
|
||||||
{
|
|
||||||
Directory.Delete(dataDir, recursive: true);
|
|
||||||
Console.WriteLine($"Purged: {dataDir}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine($"Service '{ServiceName}' uninstalled.");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
|
||||||
private static int Status()
|
|
||||||
{
|
|
||||||
var psi = new ProcessStartInfo("sc.exe")
|
|
||||||
{
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
};
|
|
||||||
psi.ArgumentList.Add("query");
|
|
||||||
psi.ArgumentList.Add(ServiceName);
|
|
||||||
|
|
||||||
using var proc = Process.Start(psi)!;
|
|
||||||
var stdout = proc.StandardOutput.ReadToEnd();
|
|
||||||
proc.WaitForExit();
|
|
||||||
|
|
||||||
if (proc.ExitCode != 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine("NotInstalled");
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
var state = stdout.Split('\n')
|
|
||||||
.Select(l => l.Trim())
|
|
||||||
.FirstOrDefault(l => l.StartsWith("STATE", StringComparison.Ordinal))
|
|
||||||
?? "";
|
|
||||||
|
|
||||||
if (state.Contains("RUNNING", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
Console.WriteLine("Running");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Stopped");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
|
||||||
internal static int RunSc(params string[] scArgs)
|
|
||||||
{
|
|
||||||
var psi = new ProcessStartInfo("sc.exe")
|
|
||||||
{
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
};
|
|
||||||
foreach (var a in scArgs) psi.ArgumentList.Add(a);
|
|
||||||
|
|
||||||
using var proc = Process.Start(psi)!;
|
|
||||||
var stdout = proc.StandardOutput.ReadToEnd();
|
|
||||||
var stderr = proc.StandardError.ReadToEnd();
|
|
||||||
proc.WaitForExit();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(stdout)) Console.Write(stdout);
|
|
||||||
if (!string.IsNullOrWhiteSpace(stderr)) Console.Error.Write(stderr);
|
|
||||||
return proc.ExitCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int PrintError(string msg)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(msg);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using ClaudeMailbox.Cli;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Config;
|
|
||||||
|
|
||||||
public static class ConfigResolver
|
|
||||||
{
|
|
||||||
public static DaemonConfig Build(string[] serveArgs, FileConfig file)
|
|
||||||
{
|
|
||||||
var cliPort = ParseIntOption(serveArgs, "--port");
|
|
||||||
var cliBind = ClientCommands.GetOption(serveArgs, "--bind");
|
|
||||||
var cliDbPath = ClientCommands.GetOption(serveArgs, "--db-path");
|
|
||||||
|
|
||||||
var port = cliPort ?? file.Port ?? DaemonConfig.DefaultPort;
|
|
||||||
var bind = cliBind ?? file.Bind ?? DaemonConfig.DefaultBindAddress;
|
|
||||||
var dbPathRaw = cliDbPath ?? file.DbPath ?? Paths.DefaultDbPath();
|
|
||||||
|
|
||||||
return new DaemonConfig
|
|
||||||
{
|
|
||||||
Port = port,
|
|
||||||
BindAddress = bind,
|
|
||||||
DbPath = Paths.Expand(dbPathRaw),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int? ParseIntOption(string[] args, string name)
|
|
||||||
{
|
|
||||||
var raw = ClientCommands.GetOption(args, name);
|
|
||||||
return int.TryParse(raw, out var v) ? v : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace ClaudeMailbox.Config;
|
|
||||||
|
|
||||||
public sealed class DaemonConfig
|
|
||||||
{
|
|
||||||
public const int DefaultPort = 47822;
|
|
||||||
public const string DefaultBindAddress = "127.0.0.1";
|
|
||||||
|
|
||||||
public int Port { get; init; } = DefaultPort;
|
|
||||||
public string BindAddress { get; init; } = DefaultBindAddress;
|
|
||||||
public string DbPath { get; init; } = Paths.DefaultDbPath();
|
|
||||||
|
|
||||||
public string BaseUrl => $"http://{BindAddress}:{Port}";
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Config;
|
|
||||||
|
|
||||||
public sealed class FileConfig
|
|
||||||
{
|
|
||||||
[JsonPropertyName("port")]
|
|
||||||
public int? Port { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("bind")]
|
|
||||||
public string? Bind { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("dbPath")]
|
|
||||||
public string? DbPath { get; set; }
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions Options = new()
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
public static FileConfig Load(string? explicitPath, string? defaultPath)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(explicitPath))
|
|
||||||
{
|
|
||||||
if (!File.Exists(explicitPath))
|
|
||||||
throw new FileNotFoundException($"Config file not found: {explicitPath}", explicitPath);
|
|
||||||
return Parse(File.ReadAllText(explicitPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(defaultPath) && File.Exists(defaultPath))
|
|
||||||
return Parse(File.ReadAllText(defaultPath));
|
|
||||||
|
|
||||||
return new FileConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static FileConfig Parse(string json)
|
|
||||||
{
|
|
||||||
return JsonSerializer.Deserialize<FileConfig>(json, Options) ?? new FileConfig();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace ClaudeMailbox.Config;
|
|
||||||
|
|
||||||
public static class Paths
|
|
||||||
{
|
|
||||||
public static string Expand(string path)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(path)) return path;
|
|
||||||
|
|
||||||
var expanded = Environment.ExpandEnvironmentVariables(path);
|
|
||||||
if (expanded.StartsWith("~"))
|
|
||||||
{
|
|
||||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
||||||
expanded = home + expanded[1..];
|
|
||||||
}
|
|
||||||
return Path.GetFullPath(expanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string DefaultDbPath()
|
|
||||||
{
|
|
||||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
||||||
return Path.Combine(home, ".claude-mailbox", "mailbox.db");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using ClaudeMailbox.Data.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Data.Configuration;
|
|
||||||
|
|
||||||
public sealed class MailboxConfiguration : IEntityTypeConfiguration<Mailbox>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<Mailbox> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable("mailboxes");
|
|
||||||
|
|
||||||
builder.HasKey(m => m.Name);
|
|
||||||
builder.Property(m => m.Name).HasColumnName("name").IsRequired();
|
|
||||||
builder.Property(m => m.CreatedAt).HasColumnName("created_at").IsRequired();
|
|
||||||
builder.Property(m => m.LastSeenAt).HasColumnName("last_seen_at").IsRequired();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using ClaudeMailbox.Data.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Data.Configuration;
|
|
||||||
|
|
||||||
public sealed class MessageConfiguration : IEntityTypeConfiguration<Message>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<Message> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable("messages");
|
|
||||||
|
|
||||||
builder.HasKey(m => m.Id);
|
|
||||||
builder.Property(m => m.Id).HasColumnName("id").ValueGeneratedOnAdd();
|
|
||||||
builder.Property(m => m.ToMailbox).HasColumnName("to_mailbox").IsRequired();
|
|
||||||
builder.Property(m => m.FromMailbox).HasColumnName("from_mailbox").IsRequired();
|
|
||||||
builder.Property(m => m.Body).HasColumnName("body").IsRequired();
|
|
||||||
builder.Property(m => m.CreatedAt).HasColumnName("created_at").IsRequired();
|
|
||||||
builder.Property(m => m.DeliveredAt).HasColumnName("delivered_at");
|
|
||||||
|
|
||||||
builder.HasOne<Mailbox>()
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(m => m.ToMailbox)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.HasOne<Mailbox>()
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(m => m.FromMailbox)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.HasIndex(m => new { m.ToMailbox, m.DeliveredAt })
|
|
||||||
.HasDatabaseName("ix_messages_to_delivered");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using ClaudeMailbox.Data.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Data;
|
|
||||||
|
|
||||||
public class MailboxDbContext : DbContext
|
|
||||||
{
|
|
||||||
public MailboxDbContext(DbContextOptions<MailboxDbContext> options) : base(options) { }
|
|
||||||
|
|
||||||
public DbSet<Mailbox> Mailboxes => Set<Mailbox>();
|
|
||||||
public DbSet<Message> Messages => Set<Message>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MailboxDbContext).Assembly);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void EnsureReady(MailboxDbContext db)
|
|
||||||
{
|
|
||||||
var dir = Path.GetDirectoryName(db.Database.GetDbConnection().DataSource);
|
|
||||||
if (!string.IsNullOrEmpty(dir))
|
|
||||||
Directory.CreateDirectory(dir);
|
|
||||||
|
|
||||||
var conn = db.Database.GetDbConnection();
|
|
||||||
conn.Open();
|
|
||||||
using (var cmd = conn.CreateCommand())
|
|
||||||
{
|
|
||||||
cmd.CommandText = "PRAGMA journal_mode=WAL;";
|
|
||||||
cmd.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
|
|
||||||
db.Database.EnsureCreated();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace ClaudeMailbox.Data.Models;
|
|
||||||
|
|
||||||
public sealed class Mailbox
|
|
||||||
{
|
|
||||||
public required string Name { get; set; }
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public DateTime LastSeenAt { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace ClaudeMailbox.Data.Models;
|
|
||||||
|
|
||||||
public sealed class Message
|
|
||||||
{
|
|
||||||
public long Id { get; set; }
|
|
||||||
public required string ToMailbox { get; set; }
|
|
||||||
public required string FromMailbox { get; set; }
|
|
||||||
public required string Body { get; set; }
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public DateTime? DeliveredAt { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using ClaudeMailbox.Data.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Data.Repositories;
|
|
||||||
|
|
||||||
public sealed class MailboxRepository
|
|
||||||
{
|
|
||||||
private readonly MailboxDbContext _db;
|
|
||||||
|
|
||||||
public MailboxRepository(MailboxDbContext db) => _db = db;
|
|
||||||
|
|
||||||
public async Task<Mailbox> UpsertAsync(string name, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var row = await _db.Mailboxes.FirstOrDefaultAsync(m => m.Name == name, ct);
|
|
||||||
if (row is null)
|
|
||||||
{
|
|
||||||
row = new Mailbox { Name = name, CreatedAt = now, LastSeenAt = now };
|
|
||||||
_db.Mailboxes.Add(row);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
row.LastSeenAt = now;
|
|
||||||
}
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Mailbox>> ListAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return await _db.Mailboxes.AsNoTracking().OrderBy(m => m.Name).ToListAsync(ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
using ClaudeMailbox.Data.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Data.Repositories;
|
|
||||||
|
|
||||||
public sealed class MessageRepository
|
|
||||||
{
|
|
||||||
private readonly MailboxDbContext _db;
|
|
||||||
private readonly MailboxRepository _mailboxes;
|
|
||||||
|
|
||||||
public MessageRepository(MailboxDbContext db, MailboxRepository mailboxes)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
_mailboxes = mailboxes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Message> SendAsync(string from, string to, string body, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await _mailboxes.UpsertAsync(from, ct);
|
|
||||||
await _mailboxes.UpsertAsync(to, ct);
|
|
||||||
|
|
||||||
var message = new Message
|
|
||||||
{
|
|
||||||
FromMailbox = from,
|
|
||||||
ToMailbox = to,
|
|
||||||
Body = body,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
DeliveredAt = null,
|
|
||||||
};
|
|
||||||
_db.Messages.Add(message);
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<InboxStatus> PeekAsync(string name, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var pending = await _db.Messages.AsNoTracking()
|
|
||||||
.Where(m => m.ToMailbox == name && m.DeliveredAt == null)
|
|
||||||
.OrderBy(m => m.Id)
|
|
||||||
.Select(m => m.CreatedAt)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
return new InboxStatus(pending.Count, pending.FirstOrDefault() == default ? null : pending.First());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Message>> CheckInboxAsync(string name, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
// Atomic pull-and-mark: a transaction guarantees that two concurrent calls
|
|
||||||
// don't deliver the same message twice.
|
|
||||||
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
|
||||||
|
|
||||||
var pending = await _db.Messages
|
|
||||||
.Where(m => m.ToMailbox == name && m.DeliveredAt == null)
|
|
||||||
.OrderBy(m => m.Id)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
foreach (var m in pending)
|
|
||||||
m.DeliveredAt = now;
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
await tx.CommitAsync(ct);
|
|
||||||
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> PendingCountForAsync(string recipient, string sender, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return await _db.Messages.AsNoTracking()
|
|
||||||
.CountAsync(m =>
|
|
||||||
m.ToMailbox == recipient &&
|
|
||||||
m.FromMailbox == sender &&
|
|
||||||
m.DeliveredAt == null, ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record InboxStatus(int Pending, DateTime? OldestAt);
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Http;
|
|
||||||
|
|
||||||
public sealed class MailboxContextAccessor
|
|
||||||
{
|
|
||||||
private readonly IHttpContextAccessor _http;
|
|
||||||
|
|
||||||
public MailboxContextAccessor(IHttpContextAccessor http) => _http = http;
|
|
||||||
|
|
||||||
public string Current
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var name = _http.HttpContext?.Items[MailboxHeaderMiddleware.ItemsKey] as string;
|
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"No mailbox name on request. Set the X-Mailbox header in your .mcp.json.");
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
using ClaudeMailbox.Data.Repositories;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Http;
|
|
||||||
|
|
||||||
public sealed class MailboxHeaderMiddleware
|
|
||||||
{
|
|
||||||
public const string HeaderName = "X-Mailbox";
|
|
||||||
public const string ItemsKey = "Mailbox";
|
|
||||||
|
|
||||||
private readonly RequestDelegate _next;
|
|
||||||
|
|
||||||
public MailboxHeaderMiddleware(RequestDelegate next) => _next = next;
|
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext ctx, MailboxRepository mailboxes)
|
|
||||||
{
|
|
||||||
// Health is always anonymous.
|
|
||||||
if (ctx.Request.Path.StartsWithSegments("/health"))
|
|
||||||
{
|
|
||||||
await _next(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = ctx.Request.Headers[HeaderName].ToString().Trim();
|
|
||||||
|
|
||||||
// These endpoints work without identity (discovery / read-only status).
|
|
||||||
var path = ctx.Request.Path;
|
|
||||||
var isAnonymous =
|
|
||||||
path.Equals("/v1/list", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
path.Equals("/v1/peek", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
|
||||||
{
|
|
||||||
if (isAnonymous)
|
|
||||||
{
|
|
||||||
await _next(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.WriteAsync($"Missing {HeaderName} header.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Items[ItemsKey] = name;
|
|
||||||
await mailboxes.UpsertAsync(name, ctx.RequestAborted);
|
|
||||||
await _next(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
using System.Reflection;
|
|
||||||
using ClaudeMailbox.Config;
|
|
||||||
using ClaudeMailbox.Data.Repositories;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Http;
|
|
||||||
|
|
||||||
public static class RestEndpoints
|
|
||||||
{
|
|
||||||
public static void MapMailboxEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
app.MapGet("/health", (DaemonConfig cfg) => Results.Ok(new
|
|
||||||
{
|
|
||||||
status = "ok",
|
|
||||||
version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown",
|
|
||||||
dbPath = cfg.DbPath,
|
|
||||||
}));
|
|
||||||
|
|
||||||
var group = app.MapGroup("/v1");
|
|
||||||
|
|
||||||
group.MapPost("/send", async (
|
|
||||||
SendRequest body,
|
|
||||||
MailboxContextAccessor accessor,
|
|
||||||
MessageRepository messages,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(body.To) || string.IsNullOrWhiteSpace(body.Body))
|
|
||||||
return Results.BadRequest(new { error = "to and body are required" });
|
|
||||||
|
|
||||||
var from = accessor.Current;
|
|
||||||
var msg = await messages.SendAsync(from, body.To, body.Body, ct);
|
|
||||||
return Results.Ok(new { id = msg.Id, queuedAt = msg.CreatedAt });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapGet("/peek", async (
|
|
||||||
string name,
|
|
||||||
MessageRepository messages,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var status = await messages.PeekAsync(name, ct);
|
|
||||||
return Results.Ok(new { pending = status.Pending, oldestAt = status.OldestAt });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapPost("/check-inbox", async (
|
|
||||||
string name,
|
|
||||||
MailboxContextAccessor accessor,
|
|
||||||
MessageRepository messages,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
// Require the caller to be consuming their own inbox.
|
|
||||||
if (!string.Equals(name, accessor.Current, StringComparison.Ordinal))
|
|
||||||
return Results.StatusCode(403);
|
|
||||||
|
|
||||||
var pulled = await messages.CheckInboxAsync(name, ct);
|
|
||||||
return Results.Ok(pulled.Select(m => new
|
|
||||||
{
|
|
||||||
id = m.Id,
|
|
||||||
from = m.FromMailbox,
|
|
||||||
body = m.Body,
|
|
||||||
sentAt = m.CreatedAt,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapGet("/list", async (
|
|
||||||
MailboxRepository mailboxes,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var all = await mailboxes.ListAsync(ct);
|
|
||||||
return Results.Ok(all.Select(m => new
|
|
||||||
{
|
|
||||||
name = m.Name,
|
|
||||||
createdAt = m.CreatedAt,
|
|
||||||
lastSeenAt = m.LastSeenAt,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record SendRequest(string To, string Body);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using ClaudeMailbox.Data.Repositories;
|
|
||||||
using ClaudeMailbox.Http;
|
|
||||||
using ModelContextProtocol.Server;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Mcp;
|
|
||||||
|
|
||||||
public sealed record SendResult(long Id, DateTime QueuedAt);
|
|
||||||
public sealed record InboxMessage(long Id, string From, string Body, DateTime SentAt);
|
|
||||||
public sealed record InboxStatusDto(int Pending, DateTime? OldestAt);
|
|
||||||
public sealed record MailboxInfo(string Name, DateTime LastSeenAt, int PendingForYou);
|
|
||||||
|
|
||||||
[McpServerToolType]
|
|
||||||
public sealed class MailboxTools
|
|
||||||
{
|
|
||||||
private readonly MailboxContextAccessor _accessor;
|
|
||||||
private readonly MailboxRepository _mailboxes;
|
|
||||||
private readonly MessageRepository _messages;
|
|
||||||
|
|
||||||
public MailboxTools(
|
|
||||||
MailboxContextAccessor accessor,
|
|
||||||
MailboxRepository mailboxes,
|
|
||||||
MessageRepository messages)
|
|
||||||
{
|
|
||||||
_accessor = accessor;
|
|
||||||
_mailboxes = mailboxes;
|
|
||||||
_messages = messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description("Send a message to another mailbox. The sender is the current session's X-Mailbox name.")]
|
|
||||||
public async Task<SendResult> Send(
|
|
||||||
[Description("Name of the recipient mailbox.")] string to,
|
|
||||||
[Description("Message body (plain text or markdown).")] string body,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
var from = _accessor.Current;
|
|
||||||
var msg = await _messages.SendAsync(from, to, body, ct);
|
|
||||||
return new SendResult(msg.Id, msg.CreatedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description("Pull all undelivered messages for the current mailbox and mark them delivered. Returns an empty array when the inbox is empty.")]
|
|
||||||
public async Task<IReadOnlyList<InboxMessage>> CheckInbox(CancellationToken ct)
|
|
||||||
{
|
|
||||||
var name = _accessor.Current;
|
|
||||||
var pulled = await _messages.CheckInboxAsync(name, ct);
|
|
||||||
return pulled.Select(m => new InboxMessage(m.Id, m.FromMailbox, m.Body, m.CreatedAt)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description("Check whether the current mailbox has undelivered messages, without consuming them. Cheap; safe to call often.")]
|
|
||||||
public async Task<InboxStatusDto> PeekInbox(CancellationToken ct)
|
|
||||||
{
|
|
||||||
var name = _accessor.Current;
|
|
||||||
var status = await _messages.PeekAsync(name, ct);
|
|
||||||
return new InboxStatusDto(status.Pending, status.OldestAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description("List all known mailboxes with their last-seen timestamp and how many messages each has queued for the current mailbox.")]
|
|
||||||
public async Task<IReadOnlyList<MailboxInfo>> ListMailboxes(CancellationToken ct)
|
|
||||||
{
|
|
||||||
var me = _accessor.Current;
|
|
||||||
var all = await _mailboxes.ListAsync(ct);
|
|
||||||
var result = new List<MailboxInfo>(all.Count);
|
|
||||||
foreach (var m in all)
|
|
||||||
{
|
|
||||||
var pending = await _messages.PendingCountForAsync(me, m.Name, ct);
|
|
||||||
result.Add(new MailboxInfo(m.Name, m.LastSeenAt, pending));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
using ClaudeMailbox;
|
|
||||||
using ClaudeMailbox.Cli;
|
|
||||||
using ClaudeMailbox.Config;
|
|
||||||
|
|
||||||
if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
|
|
||||||
{
|
|
||||||
return await ClientCommands.RunAsync(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.Length > 0 && args[0] is "install-service" or "uninstall-service" or "start" or "stop" or "status")
|
|
||||||
{
|
|
||||||
return await ServiceCommands.RunAsync(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args;
|
|
||||||
|
|
||||||
var explicitConfig = ClientCommands.GetOption(serveArgs, "--config");
|
|
||||||
var defaultConfig = Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
|
||||||
"ClaudeMailbox", "mailbox.json");
|
|
||||||
|
|
||||||
var fileConfig = FileConfig.Load(explicitConfig, defaultConfig);
|
|
||||||
var cfg = ConfigResolver.Build(serveArgs, fileConfig);
|
|
||||||
|
|
||||||
var builder = ServerHost.CreateBuilder(cfg, serveArgs);
|
|
||||||
builder.WebHost.UseUrls(cfg.BaseUrl);
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
ServerHost.ConfigurePipeline(app);
|
|
||||||
|
|
||||||
app.Logger.LogInformation("ClaudeMailbox listening on {Url} (db: {Db})", cfg.BaseUrl, cfg.DbPath);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await app.RunAsync();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
catch (IOException ex) when (ex.Message.Contains("address already in use", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| ex.Message.Contains("Only one usage", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"Port {cfg.Port} is already in use. Another claude-mailbox instance may be running.");
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial class Program { }
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
using ClaudeMailbox.Config;
|
|
||||||
using ClaudeMailbox.Data;
|
|
||||||
using ClaudeMailbox.Data.Repositories;
|
|
||||||
using ClaudeMailbox.Http;
|
|
||||||
using ClaudeMailbox.Mcp;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Hosting.WindowsServices;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox;
|
|
||||||
|
|
||||||
public static class ServerHost
|
|
||||||
{
|
|
||||||
public static WebApplicationBuilder CreateBuilder(DaemonConfig cfg, string[]? args = null)
|
|
||||||
{
|
|
||||||
var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>());
|
|
||||||
builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox");
|
|
||||||
|
|
||||||
builder.Services.AddSingleton(cfg);
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
|
||||||
|
|
||||||
builder.Services.AddDbContext<MailboxDbContext>(opt =>
|
|
||||||
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
|
||||||
|
|
||||||
builder.Services.AddScoped<MailboxRepository>();
|
|
||||||
builder.Services.AddScoped<MessageRepository>();
|
|
||||||
builder.Services.AddScoped<MailboxContextAccessor>();
|
|
||||||
builder.Services.AddScoped<MailboxTools>();
|
|
||||||
|
|
||||||
builder.Services.AddMcpServer()
|
|
||||||
.WithHttpTransport()
|
|
||||||
.WithTools<MailboxTools>();
|
|
||||||
|
|
||||||
return builder;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void ConfigurePipeline(WebApplication app)
|
|
||||||
{
|
|
||||||
using (var scope = app.Services.CreateScope())
|
|
||||||
{
|
|
||||||
MailboxDbContext.EnsureReady(
|
|
||||||
scope.ServiceProvider.GetRequiredService<MailboxDbContext>());
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseMiddleware<MailboxHeaderMiddleware>();
|
|
||||||
app.MapMailboxEndpoints();
|
|
||||||
app.MapMcp("/mcp");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
<IsTestProject>true</IsTestProject>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
|
||||||
<PackageReference Include="xunit" Version="2.5.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\ClaudeMailbox\ClaudeMailbox.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
using ClaudeMailbox.Config;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Tests.Config;
|
|
||||||
|
|
||||||
public sealed class ConfigResolverTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void CliFlag_WinsOverFile()
|
|
||||||
{
|
|
||||||
var file = new FileConfig { Port = 1000 };
|
|
||||||
var cfg = ConfigResolver.Build(new[] { "--port", "9999" }, file);
|
|
||||||
Assert.Equal(9999, cfg.Port);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void File_WinsOverDefault()
|
|
||||||
{
|
|
||||||
var file = new FileConfig { Port = 1000, Bind = "0.0.0.0", DbPath = "/tmp/x.db" };
|
|
||||||
var cfg = ConfigResolver.Build(Array.Empty<string>(), file);
|
|
||||||
Assert.Equal(1000, cfg.Port);
|
|
||||||
Assert.Equal("0.0.0.0", cfg.BindAddress);
|
|
||||||
Assert.Equal(Paths.Expand("/tmp/x.db"), cfg.DbPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Default_UsedWhenNeitherCliNorFile()
|
|
||||||
{
|
|
||||||
var cfg = ConfigResolver.Build(Array.Empty<string>(), new FileConfig());
|
|
||||||
Assert.Equal(DaemonConfig.DefaultPort, cfg.Port);
|
|
||||||
Assert.Equal(DaemonConfig.DefaultBindAddress, cfg.BindAddress);
|
|
||||||
Assert.Equal(Paths.DefaultDbPath(), cfg.DbPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Mixed_CliPort_FileDbPath_DefaultBind()
|
|
||||||
{
|
|
||||||
var file = new FileConfig { DbPath = "/tmp/mixed.db" };
|
|
||||||
var cfg = ConfigResolver.Build(new[] { "--port", "7000" }, file);
|
|
||||||
Assert.Equal(7000, cfg.Port);
|
|
||||||
Assert.Equal(DaemonConfig.DefaultBindAddress, cfg.BindAddress);
|
|
||||||
Assert.Equal(Paths.Expand("/tmp/mixed.db"), cfg.DbPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CliDbPath_ExpandsEnvVars()
|
|
||||||
{
|
|
||||||
var file = new FileConfig();
|
|
||||||
var cfg = ConfigResolver.Build(new[] { "--db-path", "~/foo.db" }, file);
|
|
||||||
Assert.DoesNotContain("~", cfg.DbPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void InvalidPortFlag_FallsBackToFileOrDefault()
|
|
||||||
{
|
|
||||||
var file = new FileConfig { Port = 4242 };
|
|
||||||
var cfg = ConfigResolver.Build(new[] { "--port", "not-a-number" }, file);
|
|
||||||
Assert.Equal(4242, cfg.Port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
using ClaudeMailbox.Config;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Tests.Config;
|
|
||||||
|
|
||||||
public sealed class FileConfigTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Load_ReturnsEmpty_WhenPathIsNullAndDefaultMissing()
|
|
||||||
{
|
|
||||||
var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json");
|
|
||||||
var cfg = FileConfig.Load(explicitPath: null, defaultPath: missing);
|
|
||||||
|
|
||||||
Assert.Null(cfg.Port);
|
|
||||||
Assert.Null(cfg.Bind);
|
|
||||||
Assert.Null(cfg.DbPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Load_ReadsDefaultPath_WhenExplicitPathNull()
|
|
||||||
{
|
|
||||||
var path = WriteTemp("""{"port":9000,"bind":"0.0.0.0","dbPath":"C:\\tmp\\a.db"}""");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var cfg = FileConfig.Load(explicitPath: null, defaultPath: path);
|
|
||||||
Assert.Equal(9000, cfg.Port);
|
|
||||||
Assert.Equal("0.0.0.0", cfg.Bind);
|
|
||||||
Assert.Equal(@"C:\tmp\a.db", cfg.DbPath);
|
|
||||||
}
|
|
||||||
finally { File.Delete(path); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Load_ExplicitPath_WinsOverDefault()
|
|
||||||
{
|
|
||||||
var defaultPath = WriteTemp("""{"port":1111}""");
|
|
||||||
var explicitPath = WriteTemp("""{"port":2222}""");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var cfg = FileConfig.Load(explicitPath: explicitPath, defaultPath: defaultPath);
|
|
||||||
Assert.Equal(2222, cfg.Port);
|
|
||||||
}
|
|
||||||
finally { File.Delete(defaultPath); File.Delete(explicitPath); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Load_ExplicitPathMissing_Throws()
|
|
||||||
{
|
|
||||||
var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json");
|
|
||||||
var ex = Assert.Throws<FileNotFoundException>(() =>
|
|
||||||
FileConfig.Load(explicitPath: missing, defaultPath: null));
|
|
||||||
Assert.Contains(missing, ex.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Load_MissingFields_AreNull()
|
|
||||||
{
|
|
||||||
var path = WriteTemp("""{"port":1234}""");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var cfg = FileConfig.Load(explicitPath: path, defaultPath: null);
|
|
||||||
Assert.Equal(1234, cfg.Port);
|
|
||||||
Assert.Null(cfg.Bind);
|
|
||||||
Assert.Null(cfg.DbPath);
|
|
||||||
}
|
|
||||||
finally { File.Delete(path); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Load_CaseInsensitive_PropertyNames()
|
|
||||||
{
|
|
||||||
var path = WriteTemp("""{"Port":1,"BIND":"x","DBPATH":"y"}""");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var cfg = FileConfig.Load(explicitPath: path, defaultPath: null);
|
|
||||||
Assert.Equal(1, cfg.Port);
|
|
||||||
Assert.Equal("x", cfg.Bind);
|
|
||||||
Assert.Equal("y", cfg.DbPath);
|
|
||||||
}
|
|
||||||
finally { File.Delete(path); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Load_MalformedJson_Throws()
|
|
||||||
{
|
|
||||||
var path = WriteTemp("not json");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Assert.ThrowsAny<Exception>(() => FileConfig.Load(explicitPath: path, defaultPath: null));
|
|
||||||
}
|
|
||||||
finally { File.Delete(path); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string WriteTemp(string content)
|
|
||||||
{
|
|
||||||
var p = Path.Combine(Path.GetTempPath(), $"mailbox-{Guid.NewGuid():N}.json");
|
|
||||||
File.WriteAllText(p, content);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Tests;
|
|
||||||
|
|
||||||
public sealed class MailboxEndToEndTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task Health_Returns_Ok()
|
|
||||||
{
|
|
||||||
await using var host = await TestHost.StartAsync();
|
|
||||||
|
|
||||||
var res = await host.Client.GetAsync("/health");
|
|
||||||
res.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var body = await res.Content.ReadFromJsonAsync<JsonElement>();
|
|
||||||
Assert.Equal("ok", body.GetProperty("status").GetString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Send_Without_Header_Is_BadRequest()
|
|
||||||
{
|
|
||||||
await using var host = await TestHost.StartAsync();
|
|
||||||
|
|
||||||
var res = await host.Client.PostAsJsonAsync("/v1/send", new { to = "anyone", body = "hi" });
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Two_Mailboxes_Coordinate()
|
|
||||||
{
|
|
||||||
await using var host = await TestHost.StartAsync();
|
|
||||||
using var backend = host.NewClientFor("backend");
|
|
||||||
using var frontend = host.NewClientFor("frontend");
|
|
||||||
|
|
||||||
// backend sends to frontend
|
|
||||||
var send = await backend.PostAsJsonAsync("/v1/send", new { to = "frontend", body = "API shape changed" });
|
|
||||||
send.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
// frontend peeks — expects 1
|
|
||||||
var peek1 = await frontend.GetFromJsonAsync<JsonElement>("/v1/peek?name=frontend");
|
|
||||||
Assert.Equal(1, peek1.GetProperty("pending").GetInt32());
|
|
||||||
|
|
||||||
// frontend consumes
|
|
||||||
var check = await frontend.PostAsync("/v1/check-inbox?name=frontend", null);
|
|
||||||
check.EnsureSuccessStatusCode();
|
|
||||||
var messages = await check.Content.ReadFromJsonAsync<JsonElement>();
|
|
||||||
Assert.Equal(1, messages.GetArrayLength());
|
|
||||||
var msg = messages[0];
|
|
||||||
Assert.Equal("backend", msg.GetProperty("from").GetString());
|
|
||||||
Assert.Equal("API shape changed", msg.GetProperty("body").GetString());
|
|
||||||
|
|
||||||
// peek again — expects 0
|
|
||||||
var peek2 = await frontend.GetFromJsonAsync<JsonElement>("/v1/peek?name=frontend");
|
|
||||||
Assert.Equal(0, peek2.GetProperty("pending").GetInt32());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Check_Inbox_Rejects_Mismatched_Identity()
|
|
||||||
{
|
|
||||||
await using var host = await TestHost.StartAsync();
|
|
||||||
using var backend = host.NewClientFor("backend");
|
|
||||||
using var frontend = host.NewClientFor("frontend");
|
|
||||||
|
|
||||||
await backend.PostAsJsonAsync("/v1/send", new { to = "frontend", body = "hello" });
|
|
||||||
|
|
||||||
// backend tries to consume frontend's inbox — must be rejected
|
|
||||||
var bad = await backend.PostAsync("/v1/check-inbox?name=frontend", null);
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, bad.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task List_Returns_Known_Mailboxes()
|
|
||||||
{
|
|
||||||
await using var host = await TestHost.StartAsync();
|
|
||||||
using var a = host.NewClientFor("alpha");
|
|
||||||
using var b = host.NewClientFor("beta");
|
|
||||||
|
|
||||||
// Touch both mailboxes by having each peek its own inbox
|
|
||||||
await a.GetAsync("/v1/peek?name=alpha");
|
|
||||||
await b.GetAsync("/v1/peek?name=beta");
|
|
||||||
|
|
||||||
// /v1/list is the only endpoint that works without X-Mailbox
|
|
||||||
var list = await host.Client.GetFromJsonAsync<JsonElement>("/v1/list");
|
|
||||||
var names = new List<string>();
|
|
||||||
foreach (var elem in list.EnumerateArray())
|
|
||||||
names.Add(elem.GetProperty("name").GetString()!);
|
|
||||||
|
|
||||||
Assert.Contains("alpha", names);
|
|
||||||
Assert.Contains("beta", names);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
using ClaudeMailbox.Data;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Tests;
|
|
||||||
|
|
||||||
public sealed class MigrationTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task EnsureReady_Creates_Schema_And_Is_Idempotent()
|
|
||||||
{
|
|
||||||
var dbPath = Path.Combine(Path.GetTempPath(), $"claude-mailbox-migtest-{Guid.NewGuid():N}.db");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using (var ctx = NewCtx(dbPath))
|
|
||||||
MailboxDbContext.EnsureReady(ctx);
|
|
||||||
|
|
||||||
// Second call must not throw.
|
|
||||||
using (var ctx = NewCtx(dbPath))
|
|
||||||
MailboxDbContext.EnsureReady(ctx);
|
|
||||||
|
|
||||||
// Verify tables exist.
|
|
||||||
await using var conn = new SqliteConnection($"Data Source={dbPath}");
|
|
||||||
await conn.OpenAsync();
|
|
||||||
|
|
||||||
var tables = new List<string>();
|
|
||||||
using (var cmd = conn.CreateCommand())
|
|
||||||
{
|
|
||||||
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;";
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
while (await reader.ReadAsync())
|
|
||||||
tables.Add(reader.GetString(0));
|
|
||||||
}
|
|
||||||
Assert.Contains("mailboxes", tables);
|
|
||||||
Assert.Contains("messages", tables);
|
|
||||||
|
|
||||||
// Verify the expected index exists.
|
|
||||||
string? index;
|
|
||||||
using (var cmd = conn.CreateCommand())
|
|
||||||
{
|
|
||||||
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='index' AND name='ix_messages_to_delivered';";
|
|
||||||
index = await cmd.ExecuteScalarAsync() as string;
|
|
||||||
}
|
|
||||||
Assert.Equal("ix_messages_to_delivered", index);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
SqliteConnection.ClearAllPools();
|
|
||||||
foreach (var ext in new[] { "", "-wal", "-shm" })
|
|
||||||
{
|
|
||||||
var p = dbPath + ext;
|
|
||||||
if (File.Exists(p))
|
|
||||||
{
|
|
||||||
try { File.Delete(p); } catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MailboxDbContext NewCtx(string path)
|
|
||||||
{
|
|
||||||
var opts = new DbContextOptionsBuilder<MailboxDbContext>()
|
|
||||||
.UseSqlite($"Data Source={path}")
|
|
||||||
.Options;
|
|
||||||
return new MailboxDbContext(opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Tests;
|
|
||||||
|
|
||||||
public sealed class RaceTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task Parallel_CheckInbox_Delivers_Each_Message_Exactly_Once()
|
|
||||||
{
|
|
||||||
await using var host = await TestHost.StartAsync();
|
|
||||||
using var sender = host.NewClientFor("sender");
|
|
||||||
using var recipient = host.NewClientFor("recipient");
|
|
||||||
|
|
||||||
const int messageCount = 50;
|
|
||||||
for (var i = 0; i < messageCount; i++)
|
|
||||||
{
|
|
||||||
var res = await sender.PostAsJsonAsync("/v1/send", new { to = "recipient", body = $"msg-{i}" });
|
|
||||||
res.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire multiple concurrent checks. Each message must appear in exactly one result set.
|
|
||||||
var tasks = Enumerable.Range(0, 8).Select(async _ =>
|
|
||||||
{
|
|
||||||
var res = await recipient.PostAsync("/v1/check-inbox?name=recipient", null);
|
|
||||||
res.EnsureSuccessStatusCode();
|
|
||||||
return await res.Content.ReadFromJsonAsync<JsonElement>();
|
|
||||||
});
|
|
||||||
|
|
||||||
var results = await Task.WhenAll(tasks);
|
|
||||||
|
|
||||||
var ids = new List<long>();
|
|
||||||
foreach (var arr in results)
|
|
||||||
foreach (var m in arr.EnumerateArray())
|
|
||||||
ids.Add(m.GetProperty("id").GetInt64());
|
|
||||||
|
|
||||||
Assert.Equal(messageCount, ids.Count);
|
|
||||||
Assert.Equal(messageCount, ids.Distinct().Count());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
using ClaudeMailbox;
|
|
||||||
using ClaudeMailbox.Config;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Spins up a full ClaudeMailbox WebApplication on an ephemeral port against a temp SQLite file.
|
|
||||||
/// Disposable — removes the DB and stops the host on dispose.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class TestHost : IAsyncDisposable
|
|
||||||
{
|
|
||||||
private readonly WebApplication _app;
|
|
||||||
private readonly string _dbPath;
|
|
||||||
|
|
||||||
public HttpClient Client { get; }
|
|
||||||
public string BaseUrl { get; }
|
|
||||||
public string DbPath => _dbPath;
|
|
||||||
|
|
||||||
private TestHost(WebApplication app, string dbPath, string baseUrl)
|
|
||||||
{
|
|
||||||
_app = app;
|
|
||||||
_dbPath = dbPath;
|
|
||||||
BaseUrl = baseUrl;
|
|
||||||
Client = new HttpClient { BaseAddress = new Uri(baseUrl) };
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<TestHost> StartAsync()
|
|
||||||
{
|
|
||||||
var dbPath = Path.Combine(Path.GetTempPath(), $"claude-mailbox-test-{Guid.NewGuid():N}.db");
|
|
||||||
var cfg = new DaemonConfig
|
|
||||||
{
|
|
||||||
Port = 0,
|
|
||||||
BindAddress = "127.0.0.1",
|
|
||||||
DbPath = dbPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
var builder = ServerHost.CreateBuilder(cfg);
|
|
||||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
ServerHost.ConfigurePipeline(app);
|
|
||||||
|
|
||||||
await app.StartAsync();
|
|
||||||
|
|
||||||
// Discover the port Kestrel picked.
|
|
||||||
var server = app.Services.GetRequiredService<Microsoft.AspNetCore.Hosting.Server.IServer>();
|
|
||||||
var feature = server.Features.Get<Microsoft.AspNetCore.Hosting.Server.Features.IServerAddressesFeature>();
|
|
||||||
var url = feature?.Addresses.FirstOrDefault()
|
|
||||||
?? throw new InvalidOperationException("No bound URL after start.");
|
|
||||||
|
|
||||||
return new TestHost(app, dbPath, url);
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpClient NewClientFor(string mailboxName)
|
|
||||||
{
|
|
||||||
var c = new HttpClient { BaseAddress = new Uri(BaseUrl) };
|
|
||||||
c.DefaultRequestHeaders.Add("X-Mailbox", mailboxName);
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
Client.Dispose();
|
|
||||||
await _app.StopAsync();
|
|
||||||
await _app.DisposeAsync();
|
|
||||||
|
|
||||||
// Allow SQLite handle to release before deleting.
|
|
||||||
GC.Collect();
|
|
||||||
GC.WaitForPendingFinalizers();
|
|
||||||
|
|
||||||
foreach (var ext in new[] { "", "-wal", "-shm" })
|
|
||||||
{
|
|
||||||
var path = _dbPath + ext;
|
|
||||||
if (File.Exists(path))
|
|
||||||
{
|
|
||||||
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user