22 Commits

Author SHA1 Message Date
mika kuns
7b58db771a chore(release): 1.5.4
All checks were successful
CI (Node) / build-test (push) Successful in 12s
Release (Node) / release (push) Successful in 15s
Make /collaborate slash command self-contained so it works without a registered mailbox-collaborate skill in the session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:23:08 +02:00
mika kuns
6592d428b7 chore(release): 1.5.3
All checks were successful
CI (Node) / build-test (push) Successful in 12s
Release (Node) / release (push) Successful in 14s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:24:49 +02:00
mika kuns
22824bd35f feat(hook): make push delivery opt-in via mailbox-collaborate skill
The SessionStart announce no longer forces a watch-loop bootstrap on every session — it now emits a short pointer instructing Claude to invoke the new mailbox-collaborate skill (or /collaborate slash command) when the user wants peers to wake them mid-task. Messages still surface on the next user prompt via the UserPromptSubmit hook even without the watcher, so nothing is lost; idle sessions just stop burning relaunch tokens.

The watch-loop protocol (exit codes, rename handling, mail handling) moves from the hook prose into the new skill body, where it only loads when actually needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:24:41 +02:00
mika kuns
951fb4f021 fix(autostart): hide console window on logon via wscript VBS shim
Run-key autostart used to register node.exe directly, which made Windows pop a console window at every user logon. Now install-autostart writes a one-line WshShell.Run launcher with vbHide and points the Run-key value at wscript.exe, so the daemon starts truly hidden. Uninstall paths also clean the .vbs file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:24:29 +02:00
Mika Kuns
c1fc863047 docs(db): mark waiterCount as @internal test helper
Final review flagged that waiterCount looks like a public API but is
only called from the rename-watch flake fix test. The behavior is fine
to keep public (in-memory, safe), but the docstring now matches the
intent so future API-surface scans don't load-bear on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:45:47 +02:00
Mika Kuns
8c8be67a98 docs: document watch --block push delivery and bootstrap behavior
Adds a Push delivery (watch) section to the root README with exit-code
table, cross-process semantics, and the active-vs-idle latency caveat
that came out of the empirical Claude Code BashOutput test. Adds a brief
reference + cross-link in node/README.md, and notes the SessionStart
bootstrap behavior in plugin/README.md alongside the existing hook
table. Adds /v1/watch to the REST surface table and the watch verb to
the CLI listing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:42:52 +02:00
Mika Kuns
307e15b05b fix(hook): suppress peer list when daemon is unreachable
Fold daemonError into SessionAnnounceOptions so the helper owns the
mutually-exclusive choice between peer list and daemon-down hint. Before
this fix, a session-announce against an unreachable daemon emitted both
"No other mailboxes seen within the last 60 minutes (0 total registered)."
(misleading — the daemon was never asked) AND the daemon-unreachable hint.
Now only the hint appears when the daemon is down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:41:24 +02:00
Mika Kuns
efdc752890 feat(hook): nudge Claude to start watch --block as background bash on session start
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:37:40 +02:00
Mika Kuns
9f8c1d9e9d test(cli): fix flaky rename watch test with deterministic waiter polling
The rename test relied on a fixed 300ms setTimeout to fire after the CLI
subprocess had registered its waiter — adequate in isolation but flaky
under full-suite load on Windows (CLI spawn + first HTTP request can
exceed 300ms). Add a tiny public MailboxStore.waiterCount(name) helper
so the test can poll until the waiter is actually registered before
triggering the rename. Also tighten the missing-name assertion from
not-zero to the contract-exact exit code 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:34:47 +02:00
Mika Kuns
1c2c1d2f7e feat(cli): add watch --block subcommand with documented exit codes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:29:38 +02:00
Mika Kuns
bc53daf6e6 fix(server): set explicit connectionTimeout to bound long-poll sockets
Fastify's default connectionTimeout is 0 (no timeout). With /v1/watch
holding requests open for up to 300s, an OS-level cap prevents a stuck
socket from persisting forever even if app-level cleanup misses a case.
Set just above the watch max so a healthy long-poll never races the
socket timeout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:24:26 +02:00
Mika Kuns
8169ebf4fe refactor(server): clean up close listener and avoid writing 499 to dead socket
Wrap the long-poll handler in try/finally to detach the req.raw close
listener on every resolution path, and use reply.hijack() on the aborted
branch so Fastify does not attempt to write to a socket that's already
closed (which would otherwise emit per-disconnect log noise once the
watcher relaunch loop is busy). Behavior on the wire is unchanged for
the four documented status codes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:23:48 +02:00
Mika Kuns
b05e6f2bd7 feat(server): add GET /v1/watch long-poll endpoint with abort + rename handling
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:19:11 +02:00
Mika Kuns
b74e969229 test(db): add abort-racing-send + rejectAllWaiters coverage; document notifyOneWaiter invariant
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:15:39 +02:00
Mika Kuns
31584fe623 feat(db): add waitForMessage with FIFO single-delivery and rename signaling
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:10:17 +02:00
Mika Kuns
407f3a8f16 chore(repo): drop .NET implementation in favor of npm-only
The Node port (@kuns/claude-mailbox) is the recommended runtime and
covers all supported features. Maintaining a wire-compatible .NET twin
adds dual-impl tax with no remaining users, so remove src/, tests/,
solution + MSBuild files, NuGet config, and both .NET CI workflows.

Docs updated: README "Path C" build-from-source section dropped,
architecture diagram + Development section simplified, and node/README
no longer subtitles itself as a port of the .NET daemon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:35:31 +02:00
Mika Kuns
75a180279e chore(release): 1.5.2
All checks were successful
Release / release (push) Successful in 7s
CI (Node) / build-test (push) Successful in 9s
Release (Node) / release (push) Successful in 11s
2026-05-20 14:41:56 +02:00
Mika Kuns
9438b1d8dc fix(plugin): make mailbox-update skill robust to common failure modes
All checks were successful
CI (Node) / build-test (push) Successful in 9s
The previous skill assumed a happy-path environment and silently
broke on real installs. Hardened the flow:

- Always pass --@kuns:registry on every npm call, so the upgrade
  survives an unreadable user .npmrc (e.g. roaming HOME on a
  network share that npm can't access).
- Treat /health as the ground truth, not `claude-mailbox status`.
  Status only reflects the autostart wrapper and silently lies
  when the inner process crashed at boot.
- Capture state before changing anything (CLI version, autostart
  state, port, daemon /health version) so failures can roll back
  to "do nothing — old daemon keeps running".
- Detect the "daemon reachable but autostart NotInstalled" case
  (manual foreground serve) and refuse to swap binaries from
  under it.
- After restart, poll /health for up to 10s and verify the live
  daemon's version matches LATEST; on timeout, dump the platform-
  appropriate daemon log tail instead of just reporting "Stopped".
- Reorder: install first, then stop+start. Downtime is paid only
  after the new binary is verified on disk.
2026-05-20 14:22:52 +02:00
Mika Kuns
f4539eb2c9 chore(release): 1.5.1
All checks were successful
Release / release (push) Successful in 8s
Release (Node) / release (push) Successful in 10s
2026-05-20 14:09:19 +02:00
Mika Kuns
4b93641cf4 fix(server): register onClose hook before app.listen
Fastify forbids addHook after the instance is listening, so the
sweep-timer cleanup hook from 1.5.0 threw on every `serve` startup
and crashed the daemon. Register the hook first, then start
listening, and assign the timer through a ref.
2026-05-20 14:09:11 +02:00
Mika Kuns
2cadc3a867 chore(release): 1.5.0
All checks were successful
CI (Node) / build-test (push) Successful in 8s
Release / release (push) Successful in 8s
Release (Node) / release (push) Successful in 12s
2026-05-20 13:54:43 +02:00
Mika Kuns
0c06e2cf4b feat(cleanup): hide and prune stale mailboxes
Mailbox listings grew unbounded as old sessions ended without
unregistering. This adds two layers of cleanup, configurable via
mailbox.json or `serve` flags:

- Lazy filter: list responses (REST /v1/list, MCP list_mailboxes)
  drop mailboxes idle longer than hideAfterMinutes (default 24h),
  while always keeping the caller and any sender with messages
  pending for them.
- Background sweep: startServer runs an initial prune on boot and
  schedules an unref'd interval timer that hard-deletes mailboxes
  idle longer than deleteAfterMinutes (default 7d) which have no
  pending messages, and wipes their delivered history.
2026-05-20 13:54:03 +02:00
56 changed files with 1343 additions and 1733 deletions

View File

@@ -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"

View File

@@ -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."

View File

@@ -85,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 "")

View File

@@ -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>

View File

@@ -1,8 +0,0 @@
<Project>
<PropertyGroup>
<MinVerTagPrefix>v</MinVerTagPrefix>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MinVer" Version="5.0.0" PrivateAssets="all" />
</ItemGroup>
</Project>

View File

@@ -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>

View File

@@ -85,21 +85,6 @@ Optionally add a static identity (so your client doesn't need to pass `from` / `
"headers": { "X-Mailbox": "backend" } "headers": { "X-Mailbox": "backend" }
``` ```
### C. Build the .NET binary from source
The original .NET 8 implementation lives in `src/ClaudeMailbox/`. Wire-compatible with the npm build (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema).
```powershell
dotnet publish src/ClaudeMailbox -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
```
Put the resulting `claude-mailbox.exe` on `PATH`. Windows-only `install-service` verbs (admin shell):
```
claude-mailbox install-service [--port 37849] [--bind 127.0.0.1] [--db-path <path>]
claude-mailbox uninstall-service [--purge]
```
--- ---
## How identity works ## How identity works
@@ -165,6 +150,35 @@ and treat the messages as input with priority over the current plan.
--- ---
## 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 watch --block --name <mailbox> [--timeout 25] [--url <daemon>]
```
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 ## CLI
Any external process — scripts, UIs, manual debugging — can talk to a running daemon directly: Any external process — scripts, UIs, manual debugging — can talk to a running daemon directly:
@@ -173,6 +187,7 @@ Any external process — scripts, UIs, manual debugging — can talk to a runnin
claude-mailbox send --from <mailbox> --to <mailbox> --body <text> claude-mailbox send --from <mailbox> --to <mailbox> --body <text>
claude-mailbox peek --name <mailbox> claude-mailbox peek --name <mailbox>
claude-mailbox check --name <mailbox> [--hook] claude-mailbox check --name <mailbox> [--hook]
claude-mailbox watch --block --name <mailbox> [--timeout 25]
claude-mailbox list claude-mailbox list
claude-mailbox status claude-mailbox status
claude-mailbox session-announce # hook helper, reads stdin JSON claude-mailbox session-announce # hook helper, reads stdin JSON
@@ -192,6 +207,7 @@ All subcommands accept `--url <url>` to target a non-default daemon address.
| `POST` | `/v1/send` | yes (sender) | body: `{ 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/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 | | `GET` | `/v1/list` | optional (presence registers caller) | list all mailboxes |
--- ---
@@ -219,7 +235,7 @@ One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` a
| HTTP | | | HTTP | |
+--------------+-----------------+--------------------------+ +--------------+-----------------+--------------------------+
v v
claude-mailbox serve (npm: Fastify; .NET: Kestrel) claude-mailbox serve (Fastify)
/mcp MCP tools /mcp MCP tools
/v1/* REST for non-MCP senders /v1/* REST for non-MCP senders
/health /health
@@ -232,19 +248,13 @@ One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` a
## Development ## Development
```sh ```sh
# Node port (the recommended runtime)
cd node cd node
npm install npm install
npm run build npm run build
npm test npm test
# .NET 8 port (wire-compatible alternative)
dotnet build
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj
dotnet run --project src/ClaudeMailbox -- serve
``` ```
The test suites cover end-to-end coordination, concurrent `check_inbox` race safety, schema idempotency, hook stdin parsing, session-id derivation, and settings-file patching. 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.
--- ---

View File

@@ -1,6 +0,0 @@
{
"sdk": {
"version": "8.0.418",
"rollForward": "latestFeature"
}
}

View File

@@ -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
@@ -39,6 +39,16 @@ Under the hood the hook runs `claude-mailbox check --name <mailbox> --hook`, whi
Cost: one local HTTP round-trip plus Node coldstart per prompt (~100ms on Windows). Cost: one local HTTP round-trip plus Node coldstart per prompt (~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 ## Troubleshooting
`npm install` returns `401 Unauthorized` `npm install` returns `401 Unauthorized`

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@kuns/claude-mailbox", "name": "@kuns/claude-mailbox",
"version": "1.4.1", "version": "1.5.4",
"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": {

View File

@@ -35,6 +35,7 @@ const SERVICE_NAME = "ClaudeMailbox";
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
const RUN_VALUE = "ClaudeMailbox"; const RUN_VALUE = "ClaudeMailbox";
const MARKER_FILE = "autostart-mode"; 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();
@@ -79,9 +80,33 @@ function tryScheduledTaskInstall(opts: AutostartInstallOpts): { ok: boolean; std
return { ok: true, stderr: "" }; 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 { function runKeyInstall(opts: AutostartInstallOpts): void {
const configPath = ensureConfigSeeded(opts); const configPath = ensureConfigSeeded(opts);
const cmd = buildServeCommandString(configPath); const launcher = writeRunKeyLauncher(configPath);
const cmd = `wscript.exe "${launcher}"`;
const r = run("reg.exe", [ const r = run("reg.exe", [
"add", "add",
RUN_KEY, RUN_KEY,
@@ -122,6 +147,7 @@ function runKeyUninstall(): void {
if (r.status !== 0 && !/unable to find/i.test(r.stderr)) { if (r.status !== 0 && !/unable to find/i.test(r.stderr)) {
throw new Error(`reg delete failed (exit ${r.status}): ${r.stderr || r.stdout}`); throw new Error(`reg delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
} }
removeRunKeyLauncher();
killRunKeyDaemon(); killRunKeyDaemon();
} }
@@ -158,6 +184,7 @@ function scheduledTaskUninstall(purge: boolean): void {
} }
// Best-effort Run-key cleanup // Best-effort Run-key cleanup
run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]); run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
removeRunKeyLauncher();
killRunKeyDaemon(); killRunKeyDaemon();
clearActiveMode(); clearActiveMode();
if (purge) purgeData(); if (purge) purgeData();

View File

@@ -10,8 +10,8 @@ import {
applyInstall, applyInstall,
applyUninstall, applyUninstall,
buildHookCommand, buildHookCommand,
buildSessionAnnounceLines,
deriveSessionName, deriveSessionName,
formatActivePeerList,
formatMessagesForHook, formatMessagesForHook,
parseHookStdin, parseHookStdin,
readSettings, readSettings,
@@ -78,7 +78,30 @@ 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 { startServer } = await import("./server.js");
@@ -217,38 +240,27 @@ program
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd(); const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
const name = deriveSessionName(sid, cwd); const name = deriveSessionName(sid, cwd);
const lines = [ let peers: PeerEntry[] = [];
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`, let daemonError: string | null = null;
`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="...")`,
];
try { try {
const out = await callJson("GET", `${opts.url}/v1/list`, { const out = await callJson("GET", `${opts.url}/v1/list`, {
headers: { "X-Mailbox": name }, headers: { "X-Mailbox": name },
}); });
const all = (Array.isArray(out) ? out : []) as PeerEntry[]; peers = (Array.isArray(out) ? out : []) as PeerEntry[];
lines.push(
"",
...formatActivePeerList(all, name, {
windowMinutes: opts.peerWindowMinutes,
maxPeers: opts.maxPeers,
}),
);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) { if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
lines.push( daemonError = `[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`;
"",
`[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(""); lines.push("");
process.stdout.write(lines.join("\n")); process.stdout.write(lines.join("\n"));
}); });
@@ -266,6 +278,55 @@ 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 program
.command("mcp-stdio") .command("mcp-stdio")
.description( .description(

View File

@@ -4,17 +4,26 @@ import { join, resolve } from "node:path";
export const DEFAULT_PORT = 37849; 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 {

View File

@@ -52,6 +52,16 @@ function nowIso(): string {
export type RenameFailure = "invalid" | "source-missing" | "target-exists"; 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 { export class RenameError extends Error {
constructor(message: string, public readonly reason: RenameFailure) { constructor(message: string, public readonly reason: RenameFailure) {
super(message); super(message);
@@ -84,18 +94,25 @@ function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
export class MailboxStore { export class MailboxStore {
private readonly db: DatabaseSync; private readonly db: DatabaseSync;
private readonly waiters = new Map<string, Set<Waiter>>();
private readonly stmts: { private readonly stmts: {
findMailbox: StatementSync; findMailbox: StatementSync;
insertMailbox: StatementSync; insertMailbox: StatementSync;
touchMailbox: StatementSync; touchMailbox: StatementSync;
listMailboxes: StatementSync; listMailboxes: StatementSync;
listMailboxesFiltered: StatementSync;
listMailboxesFilteredAnon: StatementSync;
insertMessage: StatementSync; insertMessage: StatementSync;
countPending: StatementSync; countPending: StatementSync;
oldestPending: StatementSync; oldestPending: StatementSync;
selectPending: StatementSync; selectPending: StatementSync;
markDelivered: StatementSync; markDelivered: StatementSync;
pendingByRecipient: StatementSync; pendingByRecipient: StatementSync;
findStaleCandidates: StatementSync;
deleteMessagesForNames: StatementSync;
deleteMailboxesByNames: StatementSync;
selectOnePending: StatementSync;
}; };
constructor(public readonly dbPath: string) { constructor(public readonly dbPath: string) {
@@ -112,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)",
), ),
@@ -130,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",
),
}; };
} }
@@ -137,6 +184,16 @@ 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 unknown as MailboxRow | undefined; const existing = this.stmts.findMailbox.get(name) as unknown as MailboxRow | undefined;
@@ -148,13 +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 } {
return runInTransaction(this.db, () => { 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) };
}); });
this.notifyOneWaiter(to);
return result;
} }
peek(name: string): InboxStatus { peek(name: string): InboxStatus {
@@ -175,6 +234,78 @@ export class MailboxStore {
}); });
} }
waitForMessage(name: string, timeoutMs: number, signal: AbortSignal): Promise<WaitResult> {
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 } { rename(from: string, to: string): { from: string; to: string; messagesTransferred: number } {
const oldName = from.trim(); const oldName = from.trim();
const newName = to.trim(); const newName = to.trim();
@@ -185,7 +316,7 @@ export class MailboxStore {
return { from: oldName, to: newName, messagesTransferred: 0 }; return { from: oldName, to: newName, messagesTransferred: 0 };
} }
return runInTransaction(this.db, () => { const result = runInTransaction(this.db, () => {
const source = this.stmts.findMailbox.get(oldName) as unknown as MailboxRow | undefined; const source = this.stmts.findMailbox.get(oldName) as unknown as MailboxRow | undefined;
if (!source) throw new RenameError(`Mailbox '${oldName}' does not exist.`, "source-missing"); if (!source) throw new RenameError(`Mailbox '${oldName}' does not exist.`, "source-missing");
const target = this.stmts.findMailbox.get(newName) as unknown as MailboxRow | undefined; const target = this.stmts.findMailbox.get(newName) as unknown as MailboxRow | undefined;
@@ -202,10 +333,27 @@ export class MailboxStore {
this.db.prepare("DELETE FROM mailboxes WHERE name = ?").run(oldName); this.db.prepare("DELETE FROM mailboxes WHERE name = ?").run(oldName);
return { from: oldName, to: newName, messagesTransferred: Number(movedTo.changes ?? 0) }; return { from: oldName, to: newName, messagesTransferred: Number(movedTo.changes ?? 0) };
}); });
this.notifyRenamed(oldName, newName);
return result;
} }
listMailboxes(forName?: string): MailboxInfo[] { listMailboxes(forName?: string, options?: { hideAfterMinutes?: number }): MailboxInfo[] {
const rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[]; 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 }[];
@@ -217,6 +365,22 @@ export class MailboxStore {
pendingForYou: forName ? (pendingMap.get(forName) ?? 0) : 0, pendingForYou: forName ? (pendingMap.get(forName) ?? 0) : 0,
})); }));
} }
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): {

View File

@@ -117,6 +117,36 @@ export function formatActivePeerList(
return lines; 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 { export interface HookMessage {
id: number; id: number;
from: string; from: string;

View File

@@ -27,7 +27,7 @@ export function resolveIdentity(
); );
} }
function buildMcpServer(store: MailboxStore): McpServer { function buildMcpServer(store: MailboxStore, hideAfterMinutes: number): McpServer {
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" }); const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
server.registerTool( server.registerTool(
@@ -129,7 +129,7 @@ function buildMcpServer(store: MailboxStore): McpServer {
}, },
async ({ name }, extra) => { async ({ name }, extra) => {
const me = resolveIdentity(name, extra, "name"); const me = resolveIdentity(name, extra, "name");
const list = store.listMailboxes(me).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,
@@ -180,8 +180,12 @@ function buildMcpServer(store: MailboxStore): McpServer {
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);

View File

@@ -29,7 +29,10 @@ function readVersion(): string {
const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]); const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]);
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) => {
@@ -93,11 +96,13 @@ 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,
}));
}); });
app.post<{ Body: { to?: string } }>("/v1/rename", async (req, reply) => { app.post<{ Body: { to?: string } }>("/v1/rename", async (req, reply) => {
@@ -119,14 +124,97 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
} }
}); });
await registerMcp(app, store); 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);
}
},
);
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 };
} }

View 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
View 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");
});
});

View File

@@ -2,8 +2,16 @@ 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, RenameError } from "../src/db.js"; 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;
@@ -178,4 +186,127 @@ 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;
}
});
}); });

View File

@@ -7,6 +7,7 @@ import {
applyInstall, applyInstall,
applyUninstall, applyUninstall,
buildHookCommand, buildHookCommand,
buildSessionAnnounceLines,
deriveProjectName, deriveProjectName,
deriveSessionName, deriveSessionName,
formatActivePeerList, formatActivePeerList,
@@ -359,6 +360,58 @@ describe("formatActivePeerList", () => {
}); });
}); });
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", () => { describe("readSettings / writeSettings roundtrip", () => {
it("survives an install → write → read cycle", () => { it("survives an install → write → read cycle", () => {
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-")); const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));

View 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]);
});
});

View File

@@ -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");
@@ -151,6 +162,42 @@ describe("REST surface", () => {
expect(missingTo.status).toBe(400); 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" },

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-mailbox", "name": "claude-mailbox",
"version": "1.4.1", "version": "1.5.4",
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.", "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": { "author": {
"name": "Mika Kuns" "name": "Mika Kuns"

View File

@@ -45,6 +45,8 @@ The `SessionStart` hook announces the current session's mailbox name in the conv
Cost: one local HTTP round-trip per prompt and per subagent stop + Node coldstart (~100ms on Windows). Cost: one local HTTP round-trip per prompt and per subagent stop + Node coldstart (~100ms on Windows).
The SessionStart announcement also instructs Claude to start `claude-mailbox watch --block --name <derived-name>` as a background bash task on its first turn. While that watcher is alive, peers can `mcp__mailbox__send(...)` and Claude reacts mid-turn — no user prompt needed. After processing each completion (delivery, timeout, rename, or daemon-down), Claude relaunches the watcher in the background. The pull hook (`UserPromptSubmit`) remains as a fallback for any messages that arrive while no watcher is running.
## MCP tools ## 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. 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.

View 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.

View File

@@ -5,52 +5,106 @@ 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. You are running the **Claude-Mailbox update** command. Update the `@kuns/claude-mailbox` npm package and restart the daemon. Never run `sudo` automatically — if elevation is needed, stop and ask.
## Step 1 — current version 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).
Run: `claude-mailbox --version` ## Step 1 — current state (must run before anything is changed)
- Exit 0 → record the version string as `CURRENT`. Run, in order, and remember each result:
- Non-zero → tell the user the daemon binary is not installed yet. Suggest `/claude-mailbox:mailbox-doctor` to do a full setup and stop.
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 ## Step 2 — latest published version
Run: `npm view @kuns/claude-mailbox version` Run: `npm view --@kuns:registry=https://git.kuns.dev/api/packages/releases/npm/ @kuns/claude-mailbox version`
If the npm registry config is missing, the call may fail with a 404. Fall back to: 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 npm view --registry=https://git.kuns.dev/api/packages/releases/npm/ @kuns/claude-mailbox version
``` ```
Record the result as `LATEST`. Record the result as `LATEST`. If both calls fail, stop and report the network/registry error.
## Step 3 — compare ## Step 3 — compare
- If `CURRENT === LATEST`: print "Already up to date (vX.Y.Z)." and stop. Do not run any further steps. - `CURRENT_CLI === LATEST` AND `CURRENT_HEALTH_VERSION === LATEST` (or `DAEMON_REACHABLE = false`) → print "Already up to date (vLATEST)." and stop.
- Otherwise: tell the user `CURRENT``LATEST` and ask for confirmation before proceeding. - `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.
## Step 4 — perform the update Also warn before confirmation if `AUTOSTART_STATE = NotInstalled` AND `DAEMON_REACHABLE = false`:
On user confirmation, run these in order. Stop on the first failure and report it: > 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?
1. `claude-mailbox stop` ## Step 4 — install the new package
2. `npm install -g @kuns/claude-mailbox@latest`
- On Linux/macOS this may fail with EACCES. **Do not run sudo automatically.** Ask the user how they want to proceed (e.g., `sudo npm install -g …`, or switch to a user-scoped Node setup with nvm/fnm).
3. `claude-mailbox start`
4. `claude-mailbox --version` to verify the upgrade landed.
5. `claude-mailbox status` to verify the daemon is `Running`.
## Step 5 — summary 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: Print exactly this block:
``` ```
Claude-Mailbox update Claude-Mailbox update
previous version: <CURRENT> previous version: <CURRENT_CLI>
new version: <whatever --version now reports> new version: <claude-mailbox --version output>
daemon: Running | Stopped | NotInstalled daemon health: ok (v<version from /health>) | unreachable
pending messages survived: <count of messages still in inbox via `claude-mailbox list`, if applicable> daemon autostart: Running | Stopped | NotInstalled
pending messages: <total pending across all mailboxes — sum of pendingForYou from `claude-mailbox list`>
``` ```
If the new version matches `LATEST` and daemon is `Running`, end with: "Update complete." End with one of:
Otherwise, end with the first thing that went wrong.
- 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.

View 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.

View File

@@ -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>

View File

@@ -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:37849";
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;
}
}

View File

@@ -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 : 37849;
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;
}
}

View File

@@ -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;
}
}

View File

@@ -1,13 +0,0 @@
namespace ClaudeMailbox.Config;
public sealed class DaemonConfig
{
public const int DefaultPort = 37849;
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}";
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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 { }

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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 */ }
}
}
}
}