Compare commits
21 Commits
06a2ea6b7b
...
v1.5.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6592d428b7 | ||
|
|
22824bd35f | ||
|
|
951fb4f021 | ||
|
|
c1fc863047 | ||
|
|
8c8be67a98 | ||
|
|
307e15b05b | ||
|
|
efdc752890 | ||
|
|
9f8c1d9e9d | ||
|
|
1c2c1d2f7e | ||
|
|
bc53daf6e6 | ||
|
|
8169ebf4fe | ||
|
|
b05e6f2bd7 | ||
|
|
b74e969229 | ||
|
|
31584fe623 | ||
|
|
407f3a8f16 | ||
|
|
75a180279e | ||
|
|
9438b1d8dc | ||
|
|
f4539eb2c9 | ||
|
|
4b93641cf4 | ||
|
|
2cadc3a867 | ||
|
|
0c06e2cf4b |
@@ -1,45 +0,0 @@
|
||||
name: CI (.NET)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "src/**"
|
||||
- "tests/**"
|
||||
- "ClaudeMailbox.slnx"
|
||||
- "global.json"
|
||||
- ".gitea/workflows/ci-dotnet.yml"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "src/**"
|
||||
- "tests/**"
|
||||
- "ClaudeMailbox.slnx"
|
||||
- "global.json"
|
||||
- ".gitea/workflows/ci-dotnet.yml"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOTNET_ROOT: /home/mika/.dotnet
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$DOTNET_ROOT:$PATH"
|
||||
dotnet build tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj -c Release
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$DOTNET_ROOT:$PATH"
|
||||
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj \
|
||||
-c Release --no-build --logger "console;verbosity=normal"
|
||||
@@ -1,109 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOTNET_ROOT: /home/mika/.dotnet
|
||||
GITEA_API: https://git.kuns.dev/api/v1
|
||||
REPO: releases/ClaudeMailbox
|
||||
steps:
|
||||
- name: Checkout tag
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ github.ref_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Building version: $VERSION (tag: $TAG)"
|
||||
|
||||
- name: Publish (win-x64, self-contained, single-file)
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$DOTNET_ROOT:$PATH"
|
||||
dotnet publish src/ClaudeMailbox/ClaudeMailbox.csproj \
|
||||
-c Release -r win-x64 --self-contained true \
|
||||
/p:MinVerVersionOverride=$VERSION \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
-o out/app
|
||||
|
||||
- name: Package assets
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p assets
|
||||
|
||||
EXE_SRC=$(ls out/app/*.exe | head -n 1)
|
||||
if [ -z "$EXE_SRC" ]; then
|
||||
echo "::error::No .exe produced by publish" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXE_NAME="claude-mailbox-${VERSION}-win-x64.exe"
|
||||
cp "$EXE_SRC" "assets/${EXE_NAME}"
|
||||
|
||||
( cd assets && sha256sum "${EXE_NAME}" > checksums.txt )
|
||||
|
||||
echo "--- assets ---"
|
||||
ls -la assets
|
||||
|
||||
- name: Create Gitea Release
|
||||
id: release
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BODY=$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg name "$TAG" \
|
||||
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
|
||||
RESP=$(curl -sS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
"${GITEA_API}/repos/${REPO}/releases")
|
||||
RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
|
||||
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||||
echo "::error::Release creation failed" >&2
|
||||
echo "$RESP" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Created release id=$RELEASE_ID for tag=$TAG"
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd assets
|
||||
for f in \
|
||||
"claude-mailbox-${VERSION}-win-x64.exe" \
|
||||
"checksums.txt"
|
||||
do
|
||||
echo "Uploading: $f"
|
||||
curl -sS --fail-with-body -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-F "attachment=@${f}" \
|
||||
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${f}" \
|
||||
> /dev/null
|
||||
done
|
||||
echo "All assets uploaded."
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
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 \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_API}/repos/${REPO}/releases/tags/${TAG}" || echo "")
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ClaudeMailbox/ClaudeMailbox.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -1,8 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<MinVerTagPrefix>v</MinVerTagPrefix>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MinVer" Version="5.0.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
56
README.md
56
README.md
@@ -85,21 +85,6 @@ Optionally add a static identity (so your client doesn't need to pass `from` / `
|
||||
"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
|
||||
@@ -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
|
||||
|
||||
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 peek --name <mailbox>
|
||||
claude-mailbox check --name <mailbox> [--hook]
|
||||
claude-mailbox watch --block --name <mailbox> [--timeout 25]
|
||||
claude-mailbox list
|
||||
claude-mailbox status
|
||||
claude-mailbox session-announce # hook helper, reads stdin JSON
|
||||
@@ -192,6 +207,7 @@ All subcommands accept `--url <url>` to target a non-default daemon address.
|
||||
| `POST` | `/v1/send` | yes (sender) | body: `{ to, body }` |
|
||||
| `GET` | `/v1/peek?name=<mailbox>` | no | read-only status |
|
||||
| `POST` | `/v1/check-inbox?name=<mailbox>` | yes (must match `name`) | consume inbox |
|
||||
| `GET` | `/v1/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 |
|
||||
|
||||
---
|
||||
@@ -219,7 +235,7 @@ One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` a
|
||||
| HTTP | |
|
||||
+--------------+-----------------+--------------------------+
|
||||
v
|
||||
claude-mailbox serve (npm: Fastify; .NET: Kestrel)
|
||||
claude-mailbox serve (Fastify)
|
||||
/mcp MCP tools
|
||||
/v1/* REST for non-MCP senders
|
||||
/health
|
||||
@@ -232,19 +248,13 @@ One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` a
|
||||
## Development
|
||||
|
||||
```sh
|
||||
# Node port (the recommended runtime)
|
||||
cd node
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
|
||||
# .NET 8 port (wire-compatible alternative)
|
||||
dotnet build
|
||||
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj
|
||||
dotnet run --project src/ClaudeMailbox -- serve
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.418",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# @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
|
||||
|
||||
@@ -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).
|
||||
|
||||
## Push delivery (watch)
|
||||
|
||||
For long-running autonomous sessions, run the watcher as a background bash task so peer messages surface immediately via `BashOutput`:
|
||||
|
||||
```sh
|
||||
claude-mailbox watch --block --name <mailbox>
|
||||
```
|
||||
|
||||
Exit codes: `0` delivered or renamed, `1` error, `2` daemon unreachable, `3` timeout. See the [repository README](https://git.kuns.dev/releases/ClaudeMailbox/src/branch/main/README.md#push-delivery-watch) for the full contract.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
`npm install` returns `401 Unauthorized`
|
||||
|
||||
4
node/package-lock.json
generated
4
node/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@kuns/claude-mailbox",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@kuns/claude-mailbox",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kuns/claude-mailbox",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.3",
|
||||
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -35,6 +35,7 @@ const SERVICE_NAME = "ClaudeMailbox";
|
||||
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
||||
const RUN_VALUE = "ClaudeMailbox";
|
||||
const MARKER_FILE = "autostart-mode";
|
||||
const LAUNCHER_FILE = "autostart-launcher.vbs";
|
||||
|
||||
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
|
||||
const path = userConfigPath();
|
||||
@@ -79,9 +80,33 @@ function tryScheduledTaskInstall(opts: AutostartInstallOpts): { ok: boolean; std
|
||||
return { ok: true, stderr: "" };
|
||||
}
|
||||
|
||||
function runKeyLauncherPath(): string {
|
||||
return join(dirname(userConfigPath()), LAUNCHER_FILE);
|
||||
}
|
||||
|
||||
function writeRunKeyLauncher(configPath: string): string {
|
||||
const { node, script } = buildServeCommand();
|
||||
const cmd = `"${node}" "${script}" serve --config "${configPath}"`;
|
||||
const escaped = cmd.replace(/"/g, '""');
|
||||
const vbs =
|
||||
`Set WshShell = CreateObject("WScript.Shell")\r\n` +
|
||||
`WshShell.Run "${escaped}", 0, False\r\n` +
|
||||
`Set WshShell = Nothing\r\n`;
|
||||
const path = runKeyLauncherPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, vbs, "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
function removeRunKeyLauncher(): void {
|
||||
const path = runKeyLauncherPath();
|
||||
if (existsSync(path)) rmSync(path, { force: true });
|
||||
}
|
||||
|
||||
function runKeyInstall(opts: AutostartInstallOpts): void {
|
||||
const configPath = ensureConfigSeeded(opts);
|
||||
const cmd = buildServeCommandString(configPath);
|
||||
const launcher = writeRunKeyLauncher(configPath);
|
||||
const cmd = `wscript.exe "${launcher}"`;
|
||||
const r = run("reg.exe", [
|
||||
"add",
|
||||
RUN_KEY,
|
||||
@@ -122,6 +147,7 @@ function runKeyUninstall(): void {
|
||||
if (r.status !== 0 && !/unable to find/i.test(r.stderr)) {
|
||||
throw new Error(`reg delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
||||
}
|
||||
removeRunKeyLauncher();
|
||||
killRunKeyDaemon();
|
||||
}
|
||||
|
||||
@@ -158,6 +184,7 @@ function scheduledTaskUninstall(purge: boolean): void {
|
||||
}
|
||||
// Best-effort Run-key cleanup
|
||||
run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
|
||||
removeRunKeyLauncher();
|
||||
killRunKeyDaemon();
|
||||
clearActiveMode();
|
||||
if (purge) purgeData();
|
||||
|
||||
111
node/src/cli.ts
111
node/src/cli.ts
@@ -10,8 +10,8 @@ import {
|
||||
applyInstall,
|
||||
applyUninstall,
|
||||
buildHookCommand,
|
||||
buildSessionAnnounceLines,
|
||||
deriveSessionName,
|
||||
formatActivePeerList,
|
||||
formatMessagesForHook,
|
||||
parseHookStdin,
|
||||
readSettings,
|
||||
@@ -78,7 +78,30 @@ program
|
||||
.option("--bind <address>", "Bind address")
|
||||
.option("--db-path <path>", "SQLite database path")
|
||||
.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);
|
||||
try {
|
||||
const { startServer } = await import("./server.js");
|
||||
@@ -217,38 +240,27 @@ program
|
||||
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||
const name = deriveSessionName(sid, cwd);
|
||||
|
||||
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="...")`,
|
||||
];
|
||||
|
||||
let peers: PeerEntry[] = [];
|
||||
let daemonError: string | null = null;
|
||||
try {
|
||||
const out = await callJson("GET", `${opts.url}/v1/list`, {
|
||||
headers: { "X-Mailbox": name },
|
||||
});
|
||||
const all = (Array.isArray(out) ? out : []) as PeerEntry[];
|
||||
lines.push(
|
||||
"",
|
||||
...formatActivePeerList(all, name, {
|
||||
windowMinutes: opts.peerWindowMinutes,
|
||||
maxPeers: opts.maxPeers,
|
||||
}),
|
||||
);
|
||||
peers = (Array.isArray(out) ? out : []) as PeerEntry[];
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
|
||||
lines.push(
|
||||
"",
|
||||
`[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`,
|
||||
);
|
||||
daemonError = `[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = buildSessionAnnounceLines({
|
||||
name,
|
||||
peers,
|
||||
windowMinutes: opts.peerWindowMinutes,
|
||||
maxPeers: opts.maxPeers,
|
||||
daemonError: daemonError ?? undefined,
|
||||
});
|
||||
lines.push("");
|
||||
process.stdout.write(lines.join("\n"));
|
||||
});
|
||||
@@ -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
|
||||
.command("mcp-stdio")
|
||||
.description(
|
||||
|
||||
@@ -4,17 +4,26 @@ import { join, resolve } from "node:path";
|
||||
|
||||
export const DEFAULT_PORT = 37849;
|
||||
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 {
|
||||
port?: number;
|
||||
bind?: string;
|
||||
dbPath?: string;
|
||||
hideAfterMinutes?: number;
|
||||
deleteAfterMinutes?: number;
|
||||
sweepIntervalMinutes?: number;
|
||||
}
|
||||
|
||||
export interface DaemonConfig {
|
||||
port: number;
|
||||
bind: string;
|
||||
dbPath: string;
|
||||
hideAfterMinutes: number;
|
||||
deleteAfterMinutes: number;
|
||||
sweepIntervalMinutes: number;
|
||||
}
|
||||
|
||||
export function defaultDbPath(): string {
|
||||
@@ -65,6 +74,12 @@ export function loadFileConfig(explicitPath?: string): FileConfig {
|
||||
port: typeof parsed.port === "number" ? parsed.port : undefined,
|
||||
bind: typeof parsed.bind === "string" ? parsed.bind : 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;
|
||||
dbPath?: string;
|
||||
config?: string;
|
||||
hideAfterMinutes?: number;
|
||||
deleteAfterMinutes?: number;
|
||||
sweepIntervalMinutes?: number;
|
||||
}
|
||||
|
||||
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 bind = overrides.bind ?? file.bind ?? DEFAULT_BIND;
|
||||
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 {
|
||||
|
||||
176
node/src/db.ts
176
node/src/db.ts
@@ -52,6 +52,16 @@ function nowIso(): string {
|
||||
|
||||
export type RenameFailure = "invalid" | "source-missing" | "target-exists";
|
||||
|
||||
export type WaitResult =
|
||||
| { kind: "message"; message: MessageRow }
|
||||
| { kind: "timeout" }
|
||||
| { kind: "renamed"; to: string }
|
||||
| { kind: "aborted" };
|
||||
|
||||
interface Waiter {
|
||||
resolve: (result: WaitResult) => void;
|
||||
}
|
||||
|
||||
export class RenameError extends Error {
|
||||
constructor(message: string, public readonly reason: RenameFailure) {
|
||||
super(message);
|
||||
@@ -84,18 +94,25 @@ function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
||||
|
||||
export class MailboxStore {
|
||||
private readonly db: DatabaseSync;
|
||||
private readonly waiters = new Map<string, Set<Waiter>>();
|
||||
|
||||
private readonly stmts: {
|
||||
findMailbox: StatementSync;
|
||||
insertMailbox: StatementSync;
|
||||
touchMailbox: StatementSync;
|
||||
listMailboxes: StatementSync;
|
||||
listMailboxesFiltered: StatementSync;
|
||||
listMailboxesFilteredAnon: StatementSync;
|
||||
insertMessage: StatementSync;
|
||||
countPending: StatementSync;
|
||||
oldestPending: StatementSync;
|
||||
selectPending: StatementSync;
|
||||
markDelivered: StatementSync;
|
||||
pendingByRecipient: StatementSync;
|
||||
findStaleCandidates: StatementSync;
|
||||
deleteMessagesForNames: StatementSync;
|
||||
deleteMailboxesByNames: StatementSync;
|
||||
selectOnePending: StatementSync;
|
||||
};
|
||||
|
||||
constructor(public readonly dbPath: string) {
|
||||
@@ -112,6 +129,19 @@ export class MailboxStore {
|
||||
),
|
||||
touchMailbox: this.db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE 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(
|
||||
"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(
|
||||
"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();
|
||||
}
|
||||
|
||||
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 {
|
||||
const now = nowIso();
|
||||
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 } {
|
||||
return runInTransaction(this.db, () => {
|
||||
const result = runInTransaction(this.db, () => {
|
||||
this.upsertMailbox(from);
|
||||
this.upsertMailbox(to);
|
||||
const createdAt = nowIso();
|
||||
const result = this.stmts.insertMessage.run(to, from, body, createdAt);
|
||||
return { id: Number(result.lastInsertRowid), queuedAt: new Date(createdAt) };
|
||||
const insert = this.stmts.insertMessage.run(to, from, body, createdAt);
|
||||
return { id: Number(insert.lastInsertRowid), queuedAt: new Date(createdAt) };
|
||||
});
|
||||
this.notifyOneWaiter(to);
|
||||
return result;
|
||||
}
|
||||
|
||||
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 } {
|
||||
const oldName = from.trim();
|
||||
const newName = to.trim();
|
||||
@@ -185,7 +316,7 @@ export class MailboxStore {
|
||||
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;
|
||||
if (!source) throw new RenameError(`Mailbox '${oldName}' does not exist.`, "source-missing");
|
||||
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);
|
||||
return { from: oldName, to: newName, messagesTransferred: Number(movedTo.changes ?? 0) };
|
||||
});
|
||||
this.notifyRenamed(oldName, newName);
|
||||
return result;
|
||||
}
|
||||
|
||||
listMailboxes(forName?: string): MailboxInfo[] {
|
||||
const rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[];
|
||||
listMailboxes(forName?: string, options?: { hideAfterMinutes?: number }): MailboxInfo[] {
|
||||
const hideAfterMinutes = options?.hideAfterMinutes;
|
||||
let rows: MailboxRow[];
|
||||
if (hideAfterMinutes != null && hideAfterMinutes > 0) {
|
||||
const cutoff = new Date(Date.now() - hideAfterMinutes * 60_000).toISOString();
|
||||
if (forName) {
|
||||
rows = this.stmts.listMailboxesFiltered.all(
|
||||
cutoff,
|
||||
forName,
|
||||
forName,
|
||||
) as unknown as MailboxRow[];
|
||||
} else {
|
||||
rows = this.stmts.listMailboxesFilteredAnon.all(cutoff) as unknown as MailboxRow[];
|
||||
}
|
||||
} else {
|
||||
rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[];
|
||||
}
|
||||
const pendingMap = new Map<string, number>();
|
||||
if (forName) {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
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): {
|
||||
|
||||
@@ -117,6 +117,36 @@ export function formatActivePeerList(
|
||||
return lines;
|
||||
}
|
||||
|
||||
export interface SessionAnnounceOptions {
|
||||
name: string;
|
||||
peers: PeerEntry[];
|
||||
windowMinutes: number;
|
||||
maxPeers: number;
|
||||
daemonError?: string;
|
||||
}
|
||||
|
||||
export function buildSessionAnnounceLines(opts: SessionAnnounceOptions): string[] {
|
||||
const { name, peers, windowMinutes, maxPeers, daemonError } = opts;
|
||||
const lines = [
|
||||
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
|
||||
`The name is auto-derived as <project>-<session-short>. You can rename it (e.g. to tag your working area) with mcp__mailbox__rename(current_name="${name}", new_name="<project>-<area>-<short>"); after that, use the new name everywhere.`,
|
||||
`When using mcp__mailbox__* tools, ALWAYS pass your current name explicitly:`,
|
||||
` - mcp__mailbox__send: from="${name}"`,
|
||||
` - mcp__mailbox__check_inbox: name="${name}"`,
|
||||
` - mcp__mailbox__peek_inbox: name="${name}"`,
|
||||
` - mcp__mailbox__list_mailboxes: name="${name}"`,
|
||||
`Peers reach you with: mcp__mailbox__send(from="<their-name>", to="${name}", body="...")`,
|
||||
"",
|
||||
`Push delivery is OPT-IN. Do NOT launch the watcher on your own. When the user wants peers to wake you mid-task, invoke the \`mailbox-collaborate\` skill (or the /collaborate slash command) to enter collaboration mode. Without it, peers can still leave messages — you'll see them on your next user prompt via the existing UserPromptSubmit hook.`,
|
||||
];
|
||||
if (daemonError) {
|
||||
lines.push("", daemonError);
|
||||
} else {
|
||||
lines.push("", ...formatActivePeerList(peers, name, { windowMinutes, maxPeers }));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export interface HookMessage {
|
||||
id: number;
|
||||
from: string;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
server.registerTool(
|
||||
@@ -129,7 +129,7 @@ function buildMcpServer(store: MailboxStore): McpServer {
|
||||
},
|
||||
async ({ name }, extra) => {
|
||||
const me = resolveIdentity(name, extra, "name");
|
||||
const list = store.listMailboxes(me).map((m) => ({
|
||||
const list = store.listMailboxes(me, { hideAfterMinutes }).map((m) => ({
|
||||
name: m.name,
|
||||
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||
pendingForYou: m.pendingForYou,
|
||||
@@ -180,8 +180,12 @@ function buildMcpServer(store: MailboxStore): McpServer {
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function registerMcp(app: FastifyInstance, store: MailboxStore): Promise<void> {
|
||||
const mcpServer = buildMcpServer(store);
|
||||
export async function registerMcp(
|
||||
app: FastifyInstance,
|
||||
store: MailboxStore,
|
||||
hideAfterMinutes: number,
|
||||
): Promise<void> {
|
||||
const mcpServer = buildMcpServer(store, hideAfterMinutes);
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
await mcpServer.connect(transport);
|
||||
|
||||
|
||||
@@ -29,7 +29,10 @@ function readVersion(): string {
|
||||
const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]);
|
||||
|
||||
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();
|
||||
|
||||
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) => {
|
||||
const name = req.mailboxName;
|
||||
return store.listMailboxes(name).map((m) => ({
|
||||
name: m.name,
|
||||
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||
pendingForYou: m.pendingForYou,
|
||||
}));
|
||||
return store
|
||||
.listMailboxes(name, { hideAfterMinutes: cfg.hideAfterMinutes })
|
||||
.map((m) => ({
|
||||
name: m.name,
|
||||
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||
pendingForYou: m.pendingForYou,
|
||||
}));
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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 });
|
||||
return { app, store };
|
||||
timerRef.current = startSweep(store, cfg, app.log);
|
||||
return { app, store, sweepTimer: timerRef.current };
|
||||
}
|
||||
|
||||
129
node/tests/cli-watch.test.ts
Normal file
129
node/tests/cli-watch.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import { MailboxStore } from "../src/db.js";
|
||||
import { buildServer } from "../src/server.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI = join(here, "..", "dist", "cli.js");
|
||||
|
||||
let dir: string;
|
||||
let store: MailboxStore;
|
||||
let app: FastifyInstance;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-cli-watch-"));
|
||||
store = new MailboxStore(join(dir, "test.db"));
|
||||
app = await buildServer(
|
||||
{
|
||||
port: 0,
|
||||
bind: "127.0.0.1",
|
||||
dbPath: join(dir, "test.db"),
|
||||
hideAfterMinutes: 0,
|
||||
deleteAfterMinutes: 0,
|
||||
sweepIntervalMinutes: 0,
|
||||
},
|
||||
store,
|
||||
);
|
||||
await app.listen({ host: "127.0.0.1", port: 0 });
|
||||
const addr = app.server.address();
|
||||
if (!addr || typeof addr === "string") throw new Error("no address");
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store.rejectAllWaiters();
|
||||
await app.close();
|
||||
store.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Async helper: spawn CLI and collect output without blocking the event loop.
|
||||
// spawnSync cannot be used here because the test process hosts the Fastify server,
|
||||
// and spawnSync blocks the event loop, preventing the server from handling connections.
|
||||
function runCli(args: string[], timeoutMs: number = 8000): Promise<{
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [CLI, ...args], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
||||
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
resolve({ status: null, stdout, stderr });
|
||||
}, timeoutMs);
|
||||
child.on("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ status: code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("claude-mailbox watch CLI", () => {
|
||||
it("exits 0 with a formatted message when one is pending", async () => {
|
||||
store.upsertMailbox("alice");
|
||||
store.upsertMailbox("bob");
|
||||
store.send("alice", "bob", "hello watcher");
|
||||
|
||||
const r = await runCli(["watch", "--block", "--name", "bob", "--timeout", "2", "--url", baseUrl]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain("[Claude-Mailbox] Mail from alice:");
|
||||
expect(r.stdout).toContain("hello watcher");
|
||||
});
|
||||
|
||||
it("exits 3 silently on timeout", async () => {
|
||||
store.upsertMailbox("bob");
|
||||
const r = await runCli(["watch", "--block", "--name", "bob", "--timeout", "1", "--url", baseUrl]);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stdout).toBe("");
|
||||
});
|
||||
|
||||
it("exits 0 with rename notice when the mailbox is renamed mid-wait", async () => {
|
||||
store.upsertMailbox("oldname");
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[CLI, "watch", "--block", "--name", "oldname", "--timeout", "5", "--url", baseUrl],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
let stdout = "";
|
||||
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
||||
|
||||
// Wait for the CLI subprocess to register its waiter before renaming.
|
||||
// A fixed delay is flaky under full-suite load on Windows.
|
||||
const start = Date.now();
|
||||
while (store.waiterCount("oldname") === 0) {
|
||||
if (Date.now() - start > 4000) throw new Error("CLI never registered a waiter");
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
store.rename("oldname", "newname");
|
||||
|
||||
const code: number = await new Promise((r) => child.on("exit", (c) => r(c ?? 1)));
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain("renamed to 'newname'");
|
||||
});
|
||||
|
||||
it("exits 2 when the daemon is unreachable", async () => {
|
||||
const r = await runCli([
|
||||
"watch", "--block", "--name", "bob", "--timeout", "1",
|
||||
"--url", "http://127.0.0.1:1", // port 1 = guaranteed connection refused
|
||||
]);
|
||||
expect(r.status).toBe(2);
|
||||
});
|
||||
|
||||
it("exits 1 when --name is missing", async () => {
|
||||
const r = await runCli(["watch", "--block", "--timeout", "1", "--url", baseUrl]);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/required.*name|name.*required/i);
|
||||
});
|
||||
});
|
||||
155
node/tests/db-watch.test.ts
Normal file
155
node/tests/db-watch.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { MailboxStore } from "../src/db.js";
|
||||
|
||||
let dir: string;
|
||||
let store: MailboxStore;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-wait-"));
|
||||
store = new MailboxStore(join(dir, "test.db"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("MailboxStore.waitForMessage", () => {
|
||||
it("returns an already-pending message immediately", async () => {
|
||||
store.upsertMailbox("alice");
|
||||
store.upsertMailbox("bob");
|
||||
store.send("alice", "bob", "hello");
|
||||
|
||||
const ac = new AbortController();
|
||||
const result = await store.waitForMessage("bob", 1000, ac.signal);
|
||||
expect(result.kind).toBe("message");
|
||||
if (result.kind === "message") {
|
||||
expect(result.message.body).toBe("hello");
|
||||
expect(result.message.from_mailbox).toBe("alice");
|
||||
expect(result.message.delivered_at).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks until a message arrives, then resolves", async () => {
|
||||
store.upsertMailbox("alice");
|
||||
store.upsertMailbox("bob");
|
||||
|
||||
const ac = new AbortController();
|
||||
const pending = store.waitForMessage("bob", 5000, ac.signal);
|
||||
|
||||
setTimeout(() => store.send("alice", "bob", "later"), 50);
|
||||
|
||||
const result = await pending;
|
||||
expect(result.kind).toBe("message");
|
||||
if (result.kind === "message") expect(result.message.body).toBe("later");
|
||||
});
|
||||
|
||||
it("resolves with timeout when nothing arrives", async () => {
|
||||
store.upsertMailbox("bob");
|
||||
const ac = new AbortController();
|
||||
const result = await store.waitForMessage("bob", 80, ac.signal);
|
||||
expect(result.kind).toBe("timeout");
|
||||
});
|
||||
|
||||
it("resolves with aborted when the signal fires", async () => {
|
||||
store.upsertMailbox("bob");
|
||||
const ac = new AbortController();
|
||||
const pending = store.waitForMessage("bob", 5000, ac.signal);
|
||||
setTimeout(() => ac.abort(), 30);
|
||||
const result = await pending;
|
||||
expect(result.kind).toBe("aborted");
|
||||
});
|
||||
|
||||
it("resolves with renamed when the mailbox is renamed mid-wait", async () => {
|
||||
store.upsertMailbox("oldname");
|
||||
const ac = new AbortController();
|
||||
const pending = store.waitForMessage("oldname", 5000, ac.signal);
|
||||
setTimeout(() => store.rename("oldname", "newname"), 30);
|
||||
const result = await pending;
|
||||
expect(result.kind).toBe("renamed");
|
||||
if (result.kind === "renamed") expect(result.to).toBe("newname");
|
||||
});
|
||||
|
||||
it("FIFO single-delivery: two waiters, one send, only the first gets the message", async () => {
|
||||
store.upsertMailbox("alice");
|
||||
store.upsertMailbox("bob");
|
||||
|
||||
const ac1 = new AbortController();
|
||||
const ac2 = new AbortController();
|
||||
const w1 = store.waitForMessage("bob", 5000, ac1.signal);
|
||||
// Stagger so w1 registers first.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const w2 = store.waitForMessage("bob", 200, ac2.signal);
|
||||
|
||||
store.send("alice", "bob", "for-w1");
|
||||
|
||||
const [r1, r2] = await Promise.all([w1, w2]);
|
||||
expect(r1.kind).toBe("message");
|
||||
if (r1.kind === "message") expect(r1.message.body).toBe("for-w1");
|
||||
expect(r2.kind).toBe("timeout");
|
||||
});
|
||||
|
||||
it("two pending messages are drained by two reconnecting waiters", async () => {
|
||||
store.upsertMailbox("alice");
|
||||
store.upsertMailbox("bob");
|
||||
store.send("alice", "bob", "m1");
|
||||
store.send("alice", "bob", "m2");
|
||||
|
||||
const ac = new AbortController();
|
||||
const r1 = await store.waitForMessage("bob", 1000, ac.signal);
|
||||
const r2 = await store.waitForMessage("bob", 1000, ac.signal);
|
||||
expect(r1.kind).toBe("message");
|
||||
expect(r2.kind).toBe("message");
|
||||
if (r1.kind === "message" && r2.kind === "message") {
|
||||
expect([r1.message.body, r2.message.body]).toEqual(["m1", "m2"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("abort racing send: message is either delivered or remains pending, never lost", async () => {
|
||||
store.upsertMailbox("alice");
|
||||
store.upsertMailbox("bob");
|
||||
|
||||
const ac = new AbortController();
|
||||
const pending = store.waitForMessage("bob", 5000, ac.signal);
|
||||
|
||||
// Fire abort and send in the same tick — either order is valid as long as the message is never lost.
|
||||
ac.abort();
|
||||
store.send("alice", "bob", "racy");
|
||||
|
||||
const r = await pending;
|
||||
if (r.kind === "aborted") {
|
||||
// Message must still be in DB for the next caller.
|
||||
expect(store.peek("bob").pending).toBe(1);
|
||||
} else if (r.kind === "message") {
|
||||
expect(r.message.body).toBe("racy");
|
||||
expect(store.peek("bob").pending).toBe(0);
|
||||
} else {
|
||||
throw new Error(`unexpected kind: ${r.kind}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejectAllWaiters resolves every pending waiter as aborted and empties the bucket map", async () => {
|
||||
store.upsertMailbox("bob");
|
||||
store.upsertMailbox("carol");
|
||||
|
||||
const ac1 = new AbortController();
|
||||
const ac2 = new AbortController();
|
||||
const ac3 = new AbortController();
|
||||
const w1 = store.waitForMessage("bob", 5000, ac1.signal);
|
||||
const w2 = store.waitForMessage("bob", 5000, ac2.signal);
|
||||
const w3 = store.waitForMessage("carol", 5000, ac3.signal);
|
||||
|
||||
// Give all three a chance to register their waiters.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
store.rejectAllWaiters();
|
||||
|
||||
const [r1, r2, r3] = await Promise.all([w1, w2, w3]);
|
||||
expect(r1.kind).toBe("aborted");
|
||||
expect(r2.kind).toBe("aborted");
|
||||
expect(r3.kind).toBe("aborted");
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,16 @@ 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 { DatabaseSync } from "node:sqlite";
|
||||
import { MailboxStore, RenameError } from "../src/db.js";
|
||||
|
||||
function backdate(dbPath: string, name: string, minutesAgo: number): void {
|
||||
const db = new DatabaseSync(dbPath);
|
||||
const iso = new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
||||
db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?").run(iso, name);
|
||||
db.close();
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let dbPath: string;
|
||||
|
||||
@@ -178,4 +186,127 @@ describe("listMailboxes", () => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
applyInstall,
|
||||
applyUninstall,
|
||||
buildHookCommand,
|
||||
buildSessionAnnounceLines,
|
||||
deriveProjectName,
|
||||
deriveSessionName,
|
||||
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", () => {
|
||||
it("survives an install → write → read cycle", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
||||
|
||||
174
node/tests/server-watch.test.ts
Normal file
174
node/tests/server-watch.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { MailboxStore } from "../src/db.js";
|
||||
import { buildServer } from "../src/server.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let dir: string;
|
||||
let dbPath: string;
|
||||
let store: MailboxStore;
|
||||
let app: FastifyInstance;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-watch-"));
|
||||
dbPath = join(dir, "test.db");
|
||||
store = new MailboxStore(dbPath);
|
||||
app = await buildServer(
|
||||
{
|
||||
port: 0,
|
||||
bind: "127.0.0.1",
|
||||
dbPath,
|
||||
hideAfterMinutes: 0,
|
||||
deleteAfterMinutes: 0,
|
||||
sweepIntervalMinutes: 0,
|
||||
},
|
||||
store,
|
||||
);
|
||||
await app.listen({ host: "127.0.0.1", port: 0 });
|
||||
const addr = app.server.address();
|
||||
if (!addr || typeof addr === "string") throw new Error("no address");
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store.rejectAllWaiters();
|
||||
await app.close();
|
||||
store.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("GET /v1/watch", () => {
|
||||
it("returns 200 with one pending message when one already exists", async () => {
|
||||
store.upsertMailbox("alice");
|
||||
store.upsertMailbox("bob");
|
||||
store.send("alice", "bob", "hi bob");
|
||||
|
||||
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=1`, {
|
||||
headers: { "X-Mailbox": "bob" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { from: string; body: string; sentAt: string };
|
||||
expect(body.from).toBe("alice");
|
||||
expect(body.body).toBe("hi bob");
|
||||
expect(typeof body.sentAt).toBe("string");
|
||||
});
|
||||
|
||||
it("blocks until a message arrives, then returns 200", async () => {
|
||||
store.upsertMailbox("alice");
|
||||
store.upsertMailbox("bob");
|
||||
|
||||
const pending = fetch(`${baseUrl}/v1/watch?name=bob&timeout=5`, {
|
||||
headers: { "X-Mailbox": "bob" },
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
store.send("alice", "bob", "delayed");
|
||||
}, 50);
|
||||
|
||||
const res = await pending;
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { body: string };
|
||||
expect(body.body).toBe("delayed");
|
||||
});
|
||||
|
||||
it("returns 204 on timeout with no body", async () => {
|
||||
store.upsertMailbox("bob");
|
||||
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=1`, {
|
||||
headers: { "X-Mailbox": "bob" },
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
expect(await res.text()).toBe("");
|
||||
});
|
||||
|
||||
it("returns 409 with { reason: 'renamed', to } when mailbox is renamed mid-wait", async () => {
|
||||
store.upsertMailbox("oldname");
|
||||
const pending = fetch(`${baseUrl}/v1/watch?name=oldname&timeout=5`, {
|
||||
headers: { "X-Mailbox": "oldname" },
|
||||
});
|
||||
setTimeout(() => store.rename("oldname", "newname"), 50);
|
||||
const res = await pending;
|
||||
expect(res.status).toBe(409);
|
||||
const body = (await res.json()) as { reason: string; to: string };
|
||||
expect(body).toEqual({ reason: "renamed", to: "newname" });
|
||||
});
|
||||
|
||||
it("rejects mismatched X-Mailbox with 403", async () => {
|
||||
store.upsertMailbox("bob");
|
||||
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=1`, {
|
||||
headers: { "X-Mailbox": "alice" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects missing X-Mailbox with 400", async () => {
|
||||
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=1`);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects missing name with 400", async () => {
|
||||
const res = await fetch(`${baseUrl}/v1/watch?timeout=1`, {
|
||||
headers: { "X-Mailbox": "bob" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("caps timeout at 300 seconds server-side (rejects with 400 if too high)", async () => {
|
||||
store.upsertMailbox("bob");
|
||||
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=999`, {
|
||||
headers: { "X-Mailbox": "bob" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("client disconnect cleans up the waiter (no leak)", async () => {
|
||||
store.upsertMailbox("bob");
|
||||
const ac = new AbortController();
|
||||
const pending = fetch(`${baseUrl}/v1/watch?name=bob&timeout=5`, {
|
||||
headers: { "X-Mailbox": "bob" },
|
||||
signal: ac.signal,
|
||||
}).catch((err) => err);
|
||||
|
||||
// Give the request a chance to register the waiter.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
ac.abort();
|
||||
await pending;
|
||||
|
||||
// Wait for the server-side TCP close event to propagate and remove the waiter.
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Send after abort. No one should receive it (no waiter exists).
|
||||
// It should still be queued for a future caller.
|
||||
store.upsertMailbox("alice");
|
||||
store.send("alice", "bob", "post-abort");
|
||||
|
||||
// A fresh check should immediately return the queued message.
|
||||
const res = await fetch(`${baseUrl}/v1/watch?name=bob&timeout=1`, {
|
||||
headers: { "X-Mailbox": "bob" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { body: string };
|
||||
expect(body.body).toBe("post-abort");
|
||||
});
|
||||
|
||||
it("two clients, one message: exactly one client receives it", async () => {
|
||||
store.upsertMailbox("alice");
|
||||
store.upsertMailbox("bob");
|
||||
|
||||
const r1 = fetch(`${baseUrl}/v1/watch?name=bob&timeout=2`, {
|
||||
headers: { "X-Mailbox": "bob" },
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const r2 = fetch(`${baseUrl}/v1/watch?name=bob&timeout=2`, {
|
||||
headers: { "X-Mailbox": "bob" },
|
||||
});
|
||||
|
||||
setTimeout(() => store.send("alice", "bob", "single"), 50);
|
||||
|
||||
const [res1, res2] = await Promise.all([r1, r2]);
|
||||
const statuses = [res1.status, res2.status].sort();
|
||||
expect(statuses).toEqual([200, 204]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { MailboxStore } from "../src/db.js";
|
||||
import { buildServer } from "../src/server.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
@@ -16,7 +17,17 @@ beforeEach(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-srv-"));
|
||||
dbPath = join(dir, "test.db");
|
||||
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 });
|
||||
const addr = app.server.address();
|
||||
if (!addr || typeof addr === "string") throw new Error("no address");
|
||||
@@ -151,6 +162,42 @@ describe("REST surface", () => {
|
||||
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 () => {
|
||||
await call("POST", "/v1/send", {
|
||||
headers: { "X-Mailbox": "alice" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mailbox",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.3",
|
||||
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.",
|
||||
"author": {
|
||||
"name": "Mika Kuns"
|
||||
|
||||
@@ -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).
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
5
plugin/commands/collaborate.md
Normal file
5
plugin/commands/collaborate.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: Enter Claude-Mailbox collaboration mode — start the push-delivery watch loop so peers can wake Claude mid-task.
|
||||
---
|
||||
|
||||
Invoke the `mailbox-collaborate` skill now to enter collaboration mode and start the watcher relaunch loop.
|
||||
@@ -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.
|
||||
|
||||
## 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`.
|
||||
- Non-zero → tell the user the daemon binary is not installed yet. Suggest `/claude-mailbox:mailbox-doctor` to do a full setup and stop.
|
||||
Run, in order, and remember each result:
|
||||
|
||||
1. `claude-mailbox --version`
|
||||
- Exit 0 → `CURRENT_CLI = <stdout trimmed>`.
|
||||
- Non-zero → stop. The binary isn't installed; suggest `/claude-mailbox:mailbox-doctor` and exit.
|
||||
2. `claude-mailbox status`
|
||||
- Record as `AUTOSTART_STATE ∈ { Running, Stopped, NotInstalled }`.
|
||||
3. Read the configured port. Try `~/.claude-mailbox/mailbox.json`; if absent or no `port` field, use **37849**. Call this `PORT`.
|
||||
4. Probe `curl -sf -m 2 http://127.0.0.1:$PORT/health`.
|
||||
- On success, parse JSON → `CURRENT_HEALTH_VERSION = .version`. Set `DAEMON_REACHABLE = true`.
|
||||
- On failure → `CURRENT_HEALTH_VERSION = null`, `DAEMON_REACHABLE = false`.
|
||||
|
||||
Note any inconsistencies (e.g. `AUTOSTART_STATE = NotInstalled` but `DAEMON_REACHABLE = true` means a manually-started foreground daemon) — they affect Step 5.
|
||||
|
||||
## Step 2 — latest published version
|
||||
|
||||
Run: `npm view @kuns/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
|
||||
```
|
||||
|
||||
Record the result as `LATEST`.
|
||||
Record the result as `LATEST`. If both calls fail, stop and report the network/registry error.
|
||||
|
||||
## Step 3 — compare
|
||||
|
||||
- If `CURRENT === LATEST`: print "Already up to date (vX.Y.Z)." and stop. Do not run any further steps.
|
||||
- Otherwise: tell the user `CURRENT` → `LATEST` and ask for confirmation before proceeding.
|
||||
- `CURRENT_CLI === LATEST` AND `CURRENT_HEALTH_VERSION === LATEST` (or `DAEMON_REACHABLE = false`) → print "Already up to date (vLATEST)." and stop.
|
||||
- `CURRENT_CLI === LATEST` but `CURRENT_HEALTH_VERSION !== LATEST` → the CLI is fresh but the running daemon is the old binary. Tell the user "Binary already at LATEST but the running daemon is still v$CURRENT_HEALTH_VERSION — restart needed." Then jump to Step 5 (no npm install).
|
||||
- Otherwise → tell the user `CURRENT_CLI` → `LATEST` and ask for confirmation before proceeding.
|
||||
|
||||
## 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`
|
||||
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 4 — install the new package
|
||||
|
||||
## 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:
|
||||
|
||||
```
|
||||
Claude-Mailbox update
|
||||
previous version: <CURRENT>
|
||||
new version: <whatever --version now reports>
|
||||
daemon: Running | Stopped | NotInstalled
|
||||
pending messages survived: <count of messages still in inbox via `claude-mailbox list`, if applicable>
|
||||
previous version: <CURRENT_CLI>
|
||||
new version: <claude-mailbox --version output>
|
||||
daemon health: ok (v<version from /health>) | unreachable
|
||||
daemon autostart: Running | Stopped | NotInstalled
|
||||
pending messages: <total pending across all mailboxes — sum of pendingForYou from `claude-mailbox list`>
|
||||
```
|
||||
|
||||
If the new version matches `LATEST` and daemon is `Running`, end with: "Update complete."
|
||||
Otherwise, end with the first thing that went wrong.
|
||||
End with one of:
|
||||
|
||||
- New CLI version matches `LATEST` AND `/health` returns `version === LATEST` → **"Update complete."**
|
||||
- Anything else → **"Update incomplete: <the first concrete failure from Step 4 or 5>."**
|
||||
|
||||
## Operating notes
|
||||
|
||||
- **Always use the scope override flag** (`--@kuns:registry=...`) on every npm call. The user's `.npmrc` may be on a network drive that npm can't read.
|
||||
- **Never rely on `claude-mailbox status` alone** to decide "the daemon is fine". Always cross-check with a `/health` probe — the status command only reflects whether the autostart task is in a Running state and doesn't notice if the process inside crashed at boot.
|
||||
- **Never run `npm install` without first locking in the current state.** If the install fails, the safe rollback is to do nothing — the old daemon is still running.
|
||||
- **Never `claude-mailbox stop` before the install succeeds.** Downtime is paid only after we know the new binary is on disk.
|
||||
|
||||
37
plugin/skills/mailbox-collaborate/SKILL.md
Normal file
37
plugin/skills/mailbox-collaborate/SKILL.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: mailbox-collaborate
|
||||
description: Start the Claude-Mailbox push-delivery watch loop for this session so peers can wake Claude mid-task. Use when the user says "let's collaborate", "wait for mail", "watch the mailbox", "listen for peers", "enter collaboration mode", or invokes /collaborate. Without this skill the watcher is OFF — peers can still leave messages but Claude only sees them on the next user prompt. Do NOT launch the watcher without this opt-in.
|
||||
---
|
||||
|
||||
You are now in **mailbox collaboration mode**. Peers can wake you mid-task by sending mail to your mailbox.
|
||||
|
||||
## Launch the watcher
|
||||
|
||||
Your mailbox name was announced at SessionStart (format: `<project>-<short-session-id>`). Use it in place of `<NAME>` below.
|
||||
|
||||
Launch the watcher as a background bash task immediately:
|
||||
|
||||
```
|
||||
Bash(command="claude-mailbox watch --block --name <NAME>", run_in_background=true)
|
||||
```
|
||||
|
||||
The watcher is short-lived by design — it exits after exactly one event (delivered message, 25-second timeout, rename, or daemon error). Claude Code's BashOutput notifications fire only on task completion, so every exit MUST be followed by a fresh background relaunch with the same command. Gaps in the loop = missed pushes.
|
||||
|
||||
## Handling watcher completions
|
||||
|
||||
When a watcher task completes, inspect its stdout and exit code:
|
||||
|
||||
- **stdout contains `[Claude-Mailbox] Mail from <peer>: <body>`** → treat the body as an inbound interrupt with priority over your current plan. Address it, then relaunch the watcher.
|
||||
- **stdout contains `[Claude-Mailbox] Mailbox renamed to '<new>'`** → relaunch with `--name <new>`, and use `<new>` for all future `mcp__mailbox__*` calls (update your identity).
|
||||
- **exit code 3 with no stdout** → silent timeout, just relaunch.
|
||||
- **exit code 2** → daemon unreachable; wait ~5 seconds, then relaunch.
|
||||
- **any other exit code** → report it to the user, then relaunch.
|
||||
|
||||
## Stopping
|
||||
|
||||
Keep the loop running until the user says "stop watching", "stop collaborating", "end collaboration", or similar. When they do:
|
||||
|
||||
- Stop relaunching after the next completion.
|
||||
- If a watcher is currently mid-poll and the user wants it killed immediately, use `TaskStop` on its task id.
|
||||
|
||||
Do not re-enter collaboration mode on your own after stopping — wait for the user to invoke this skill again.
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>claude-mailbox</AssemblyName>
|
||||
<RootNamespace>ClaudeMailbox</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,104 +0,0 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClaudeMailbox.Cli;
|
||||
|
||||
public static class ClientCommands
|
||||
{
|
||||
private const string DefaultUrl = "http://127.0.0.1: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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using ClaudeMailbox.Cli;
|
||||
|
||||
namespace ClaudeMailbox.Config;
|
||||
|
||||
public static class ConfigResolver
|
||||
{
|
||||
public static DaemonConfig Build(string[] serveArgs, FileConfig file)
|
||||
{
|
||||
var cliPort = ParseIntOption(serveArgs, "--port");
|
||||
var cliBind = ClientCommands.GetOption(serveArgs, "--bind");
|
||||
var cliDbPath = ClientCommands.GetOption(serveArgs, "--db-path");
|
||||
|
||||
var port = cliPort ?? file.Port ?? DaemonConfig.DefaultPort;
|
||||
var bind = cliBind ?? file.Bind ?? DaemonConfig.DefaultBindAddress;
|
||||
var dbPathRaw = cliDbPath ?? file.DbPath ?? Paths.DefaultDbPath();
|
||||
|
||||
return new DaemonConfig
|
||||
{
|
||||
Port = port,
|
||||
BindAddress = bind,
|
||||
DbPath = Paths.Expand(dbPathRaw),
|
||||
};
|
||||
}
|
||||
|
||||
private static int? ParseIntOption(string[] args, string name)
|
||||
{
|
||||
var raw = ClientCommands.GetOption(args, name);
|
||||
return int.TryParse(raw, out var v) ? v : null;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace ClaudeMailbox.Config;
|
||||
|
||||
public sealed class DaemonConfig
|
||||
{
|
||||
public const int DefaultPort = 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}";
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClaudeMailbox.Config;
|
||||
|
||||
public sealed class FileConfig
|
||||
{
|
||||
[JsonPropertyName("port")]
|
||||
public int? Port { get; set; }
|
||||
|
||||
[JsonPropertyName("bind")]
|
||||
public string? Bind { get; set; }
|
||||
|
||||
[JsonPropertyName("dbPath")]
|
||||
public string? DbPath { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public static FileConfig Load(string? explicitPath, string? defaultPath)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(explicitPath))
|
||||
{
|
||||
if (!File.Exists(explicitPath))
|
||||
throw new FileNotFoundException($"Config file not found: {explicitPath}", explicitPath);
|
||||
return Parse(File.ReadAllText(explicitPath));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(defaultPath) && File.Exists(defaultPath))
|
||||
return Parse(File.ReadAllText(defaultPath));
|
||||
|
||||
return new FileConfig();
|
||||
}
|
||||
|
||||
private static FileConfig Parse(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<FileConfig>(json, Options) ?? new FileConfig();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
namespace ClaudeMailbox.Config;
|
||||
|
||||
public static class Paths
|
||||
{
|
||||
public static string Expand(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return path;
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(path);
|
||||
if (expanded.StartsWith("~"))
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
expanded = home + expanded[1..];
|
||||
}
|
||||
return Path.GetFullPath(expanded);
|
||||
}
|
||||
|
||||
public static string DefaultDbPath()
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return Path.Combine(home, ".claude-mailbox", "mailbox.db");
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using ClaudeMailbox.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeMailbox.Data.Configuration;
|
||||
|
||||
public sealed class MailboxConfiguration : IEntityTypeConfiguration<Mailbox>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Mailbox> builder)
|
||||
{
|
||||
builder.ToTable("mailboxes");
|
||||
|
||||
builder.HasKey(m => m.Name);
|
||||
builder.Property(m => m.Name).HasColumnName("name").IsRequired();
|
||||
builder.Property(m => m.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(m => m.LastSeenAt).HasColumnName("last_seen_at").IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using ClaudeMailbox.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeMailbox.Data.Configuration;
|
||||
|
||||
public sealed class MessageConfiguration : IEntityTypeConfiguration<Message>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Message> builder)
|
||||
{
|
||||
builder.ToTable("messages");
|
||||
|
||||
builder.HasKey(m => m.Id);
|
||||
builder.Property(m => m.Id).HasColumnName("id").ValueGeneratedOnAdd();
|
||||
builder.Property(m => m.ToMailbox).HasColumnName("to_mailbox").IsRequired();
|
||||
builder.Property(m => m.FromMailbox).HasColumnName("from_mailbox").IsRequired();
|
||||
builder.Property(m => m.Body).HasColumnName("body").IsRequired();
|
||||
builder.Property(m => m.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(m => m.DeliveredAt).HasColumnName("delivered_at");
|
||||
|
||||
builder.HasOne<Mailbox>()
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.ToMailbox)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne<Mailbox>()
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.FromMailbox)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(m => new { m.ToMailbox, m.DeliveredAt })
|
||||
.HasDatabaseName("ix_messages_to_delivered");
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using ClaudeMailbox.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeMailbox.Data;
|
||||
|
||||
public class MailboxDbContext : DbContext
|
||||
{
|
||||
public MailboxDbContext(DbContextOptions<MailboxDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Mailbox> Mailboxes => Set<Mailbox>();
|
||||
public DbSet<Message> Messages => Set<Message>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MailboxDbContext).Assembly);
|
||||
}
|
||||
|
||||
public static void EnsureReady(MailboxDbContext db)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(db.Database.GetDbConnection().DataSource);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var conn = db.Database.GetDbConnection();
|
||||
conn.Open();
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA journal_mode=WAL;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ClaudeMailbox.Data.Models;
|
||||
|
||||
public sealed class Mailbox
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime LastSeenAt { get; set; }
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace ClaudeMailbox.Data.Models;
|
||||
|
||||
public sealed class Message
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public required string ToMailbox { get; set; }
|
||||
public required string FromMailbox { get; set; }
|
||||
public required string Body { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? DeliveredAt { get; set; }
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using ClaudeMailbox.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeMailbox.Data.Repositories;
|
||||
|
||||
public sealed class MailboxRepository
|
||||
{
|
||||
private readonly MailboxDbContext _db;
|
||||
|
||||
public MailboxRepository(MailboxDbContext db) => _db = db;
|
||||
|
||||
public async Task<Mailbox> UpsertAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var row = await _db.Mailboxes.FirstOrDefaultAsync(m => m.Name == name, ct);
|
||||
if (row is null)
|
||||
{
|
||||
row = new Mailbox { Name = name, CreatedAt = now, LastSeenAt = now };
|
||||
_db.Mailboxes.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
row.LastSeenAt = now;
|
||||
}
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return row;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Mailbox>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _db.Mailboxes.AsNoTracking().OrderBy(m => m.Name).ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using ClaudeMailbox.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeMailbox.Data.Repositories;
|
||||
|
||||
public sealed class MessageRepository
|
||||
{
|
||||
private readonly MailboxDbContext _db;
|
||||
private readonly MailboxRepository _mailboxes;
|
||||
|
||||
public MessageRepository(MailboxDbContext db, MailboxRepository mailboxes)
|
||||
{
|
||||
_db = db;
|
||||
_mailboxes = mailboxes;
|
||||
}
|
||||
|
||||
public async Task<Message> SendAsync(string from, string to, string body, CancellationToken ct = default)
|
||||
{
|
||||
await _mailboxes.UpsertAsync(from, ct);
|
||||
await _mailboxes.UpsertAsync(to, ct);
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
FromMailbox = from,
|
||||
ToMailbox = to,
|
||||
Body = body,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
DeliveredAt = null,
|
||||
};
|
||||
_db.Messages.Add(message);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return message;
|
||||
}
|
||||
|
||||
public async Task<InboxStatus> PeekAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
var pending = await _db.Messages.AsNoTracking()
|
||||
.Where(m => m.ToMailbox == name && m.DeliveredAt == null)
|
||||
.OrderBy(m => m.Id)
|
||||
.Select(m => m.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new InboxStatus(pending.Count, pending.FirstOrDefault() == default ? null : pending.First());
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Message>> CheckInboxAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
// Atomic pull-and-mark: a transaction guarantees that two concurrent calls
|
||||
// don't deliver the same message twice.
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
var pending = await _db.Messages
|
||||
.Where(m => m.ToMailbox == name && m.DeliveredAt == null)
|
||||
.OrderBy(m => m.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var m in pending)
|
||||
m.DeliveredAt = now;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
public async Task<int> PendingCountForAsync(string recipient, string sender, CancellationToken ct = default)
|
||||
{
|
||||
return await _db.Messages.AsNoTracking()
|
||||
.CountAsync(m =>
|
||||
m.ToMailbox == recipient &&
|
||||
m.FromMailbox == sender &&
|
||||
m.DeliveredAt == null, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record InboxStatus(int Pending, DateTime? OldestAt);
|
||||
@@ -1,22 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ClaudeMailbox.Http;
|
||||
|
||||
public sealed class MailboxContextAccessor
|
||||
{
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public MailboxContextAccessor(IHttpContextAccessor http) => _http = http;
|
||||
|
||||
public string Current
|
||||
{
|
||||
get
|
||||
{
|
||||
var name = _http.HttpContext?.Items[MailboxHeaderMiddleware.ItemsKey] as string;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new InvalidOperationException(
|
||||
"No mailbox name on request. Set the X-Mailbox header in your .mcp.json.");
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using ClaudeMailbox.Data.Repositories;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ClaudeMailbox.Http;
|
||||
|
||||
public sealed class MailboxHeaderMiddleware
|
||||
{
|
||||
public const string HeaderName = "X-Mailbox";
|
||||
public const string ItemsKey = "Mailbox";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public MailboxHeaderMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx, MailboxRepository mailboxes)
|
||||
{
|
||||
// Health is always anonymous.
|
||||
if (ctx.Request.Path.StartsWithSegments("/health"))
|
||||
{
|
||||
await _next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
var name = ctx.Request.Headers[HeaderName].ToString().Trim();
|
||||
|
||||
// These endpoints work without identity (discovery / read-only status).
|
||||
var path = ctx.Request.Path;
|
||||
var isAnonymous =
|
||||
path.Equals("/v1/list", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals("/v1/peek", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
if (isAnonymous)
|
||||
{
|
||||
await _next(ctx);
|
||||
return;
|
||||
}
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.WriteAsync($"Missing {HeaderName} header.");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Items[ItemsKey] = name;
|
||||
await mailboxes.UpsertAsync(name, ctx.RequestAborted);
|
||||
await _next(ctx);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ClaudeMailbox.Config;
|
||||
using ClaudeMailbox.Data.Repositories;
|
||||
|
||||
namespace ClaudeMailbox.Http;
|
||||
|
||||
public static class RestEndpoints
|
||||
{
|
||||
public static void MapMailboxEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapGet("/health", (DaemonConfig cfg) => Results.Ok(new
|
||||
{
|
||||
status = "ok",
|
||||
version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown",
|
||||
dbPath = cfg.DbPath,
|
||||
}));
|
||||
|
||||
var group = app.MapGroup("/v1");
|
||||
|
||||
group.MapPost("/send", async (
|
||||
SendRequest body,
|
||||
MailboxContextAccessor accessor,
|
||||
MessageRepository messages,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.To) || string.IsNullOrWhiteSpace(body.Body))
|
||||
return Results.BadRequest(new { error = "to and body are required" });
|
||||
|
||||
var from = accessor.Current;
|
||||
var msg = await messages.SendAsync(from, body.To, body.Body, ct);
|
||||
return Results.Ok(new { id = msg.Id, queuedAt = msg.CreatedAt });
|
||||
});
|
||||
|
||||
group.MapGet("/peek", async (
|
||||
string name,
|
||||
MessageRepository messages,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var status = await messages.PeekAsync(name, ct);
|
||||
return Results.Ok(new { pending = status.Pending, oldestAt = status.OldestAt });
|
||||
});
|
||||
|
||||
group.MapPost("/check-inbox", async (
|
||||
string name,
|
||||
MailboxContextAccessor accessor,
|
||||
MessageRepository messages,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// Require the caller to be consuming their own inbox.
|
||||
if (!string.Equals(name, accessor.Current, StringComparison.Ordinal))
|
||||
return Results.StatusCode(403);
|
||||
|
||||
var pulled = await messages.CheckInboxAsync(name, ct);
|
||||
return Results.Ok(pulled.Select(m => new
|
||||
{
|
||||
id = m.Id,
|
||||
from = m.FromMailbox,
|
||||
body = m.Body,
|
||||
sentAt = m.CreatedAt,
|
||||
}));
|
||||
});
|
||||
|
||||
group.MapGet("/list", async (
|
||||
MailboxRepository mailboxes,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var all = await mailboxes.ListAsync(ct);
|
||||
return Results.Ok(all.Select(m => new
|
||||
{
|
||||
name = m.Name,
|
||||
createdAt = m.CreatedAt,
|
||||
lastSeenAt = m.LastSeenAt,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record SendRequest(string To, string Body);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeMailbox.Data.Repositories;
|
||||
using ClaudeMailbox.Http;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeMailbox.Mcp;
|
||||
|
||||
public sealed record SendResult(long Id, DateTime QueuedAt);
|
||||
public sealed record InboxMessage(long Id, string From, string Body, DateTime SentAt);
|
||||
public sealed record InboxStatusDto(int Pending, DateTime? OldestAt);
|
||||
public sealed record MailboxInfo(string Name, DateTime LastSeenAt, int PendingForYou);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class MailboxTools
|
||||
{
|
||||
private readonly MailboxContextAccessor _accessor;
|
||||
private readonly MailboxRepository _mailboxes;
|
||||
private readonly MessageRepository _messages;
|
||||
|
||||
public MailboxTools(
|
||||
MailboxContextAccessor accessor,
|
||||
MailboxRepository mailboxes,
|
||||
MessageRepository messages)
|
||||
{
|
||||
_accessor = accessor;
|
||||
_mailboxes = mailboxes;
|
||||
_messages = messages;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Send a message to another mailbox. The sender is the current session's X-Mailbox name.")]
|
||||
public async Task<SendResult> Send(
|
||||
[Description("Name of the recipient mailbox.")] string to,
|
||||
[Description("Message body (plain text or markdown).")] string body,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var from = _accessor.Current;
|
||||
var msg = await _messages.SendAsync(from, to, body, ct);
|
||||
return new SendResult(msg.Id, msg.CreatedAt);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Pull all undelivered messages for the current mailbox and mark them delivered. Returns an empty array when the inbox is empty.")]
|
||||
public async Task<IReadOnlyList<InboxMessage>> CheckInbox(CancellationToken ct)
|
||||
{
|
||||
var name = _accessor.Current;
|
||||
var pulled = await _messages.CheckInboxAsync(name, ct);
|
||||
return pulled.Select(m => new InboxMessage(m.Id, m.FromMailbox, m.Body, m.CreatedAt)).ToList();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Check whether the current mailbox has undelivered messages, without consuming them. Cheap; safe to call often.")]
|
||||
public async Task<InboxStatusDto> PeekInbox(CancellationToken ct)
|
||||
{
|
||||
var name = _accessor.Current;
|
||||
var status = await _messages.PeekAsync(name, ct);
|
||||
return new InboxStatusDto(status.Pending, status.OldestAt);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("List all known mailboxes with their last-seen timestamp and how many messages each has queued for the current mailbox.")]
|
||||
public async Task<IReadOnlyList<MailboxInfo>> ListMailboxes(CancellationToken ct)
|
||||
{
|
||||
var me = _accessor.Current;
|
||||
var all = await _mailboxes.ListAsync(ct);
|
||||
var result = new List<MailboxInfo>(all.Count);
|
||||
foreach (var m in all)
|
||||
{
|
||||
var pending = await _messages.PendingCountForAsync(me, m.Name, ct);
|
||||
result.Add(new MailboxInfo(m.Name, m.LastSeenAt, pending));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using ClaudeMailbox;
|
||||
using ClaudeMailbox.Cli;
|
||||
using ClaudeMailbox.Config;
|
||||
|
||||
if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
|
||||
{
|
||||
return await ClientCommands.RunAsync(args);
|
||||
}
|
||||
|
||||
if (args.Length > 0 && args[0] is "install-service" or "uninstall-service" or "start" or "stop" or "status")
|
||||
{
|
||||
return await ServiceCommands.RunAsync(args);
|
||||
}
|
||||
|
||||
var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args;
|
||||
|
||||
var explicitConfig = ClientCommands.GetOption(serveArgs, "--config");
|
||||
var defaultConfig = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"ClaudeMailbox", "mailbox.json");
|
||||
|
||||
var fileConfig = FileConfig.Load(explicitConfig, defaultConfig);
|
||||
var cfg = ConfigResolver.Build(serveArgs, fileConfig);
|
||||
|
||||
var builder = ServerHost.CreateBuilder(cfg, serveArgs);
|
||||
builder.WebHost.UseUrls(cfg.BaseUrl);
|
||||
|
||||
var app = builder.Build();
|
||||
ServerHost.ConfigurePipeline(app);
|
||||
|
||||
app.Logger.LogInformation("ClaudeMailbox listening on {Url} (db: {Db})", cfg.BaseUrl, cfg.DbPath);
|
||||
|
||||
try
|
||||
{
|
||||
await app.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
catch (IOException ex) when (ex.Message.Contains("address already in use", StringComparison.OrdinalIgnoreCase)
|
||||
|| ex.Message.Contains("Only one usage", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.Error.WriteLine($"Port {cfg.Port} is already in use. Another claude-mailbox instance may be running.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
public partial class Program { }
|
||||
@@ -1,48 +0,0 @@
|
||||
using ClaudeMailbox.Config;
|
||||
using ClaudeMailbox.Data;
|
||||
using ClaudeMailbox.Data.Repositories;
|
||||
using ClaudeMailbox.Http;
|
||||
using ClaudeMailbox.Mcp;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting.WindowsServices;
|
||||
|
||||
namespace ClaudeMailbox;
|
||||
|
||||
public static class ServerHost
|
||||
{
|
||||
public static WebApplicationBuilder CreateBuilder(DaemonConfig cfg, string[]? args = null)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>());
|
||||
builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox");
|
||||
|
||||
builder.Services.AddSingleton(cfg);
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddDbContext<MailboxDbContext>(opt =>
|
||||
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
||||
|
||||
builder.Services.AddScoped<MailboxRepository>();
|
||||
builder.Services.AddScoped<MessageRepository>();
|
||||
builder.Services.AddScoped<MailboxContextAccessor>();
|
||||
builder.Services.AddScoped<MailboxTools>();
|
||||
|
||||
builder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithTools<MailboxTools>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static void ConfigurePipeline(WebApplication app)
|
||||
{
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
MailboxDbContext.EnsureReady(
|
||||
scope.ServiceProvider.GetRequiredService<MailboxDbContext>());
|
||||
}
|
||||
|
||||
app.UseMiddleware<MailboxHeaderMiddleware>();
|
||||
app.MapMailboxEndpoints();
|
||||
app.MapMcp("/mcp");
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ClaudeMailbox\ClaudeMailbox.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,59 +0,0 @@
|
||||
using ClaudeMailbox.Config;
|
||||
|
||||
namespace ClaudeMailbox.Tests.Config;
|
||||
|
||||
public sealed class ConfigResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void CliFlag_WinsOverFile()
|
||||
{
|
||||
var file = new FileConfig { Port = 1000 };
|
||||
var cfg = ConfigResolver.Build(new[] { "--port", "9999" }, file);
|
||||
Assert.Equal(9999, cfg.Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_WinsOverDefault()
|
||||
{
|
||||
var file = new FileConfig { Port = 1000, Bind = "0.0.0.0", DbPath = "/tmp/x.db" };
|
||||
var cfg = ConfigResolver.Build(Array.Empty<string>(), file);
|
||||
Assert.Equal(1000, cfg.Port);
|
||||
Assert.Equal("0.0.0.0", cfg.BindAddress);
|
||||
Assert.Equal(Paths.Expand("/tmp/x.db"), cfg.DbPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_UsedWhenNeitherCliNorFile()
|
||||
{
|
||||
var cfg = ConfigResolver.Build(Array.Empty<string>(), new FileConfig());
|
||||
Assert.Equal(DaemonConfig.DefaultPort, cfg.Port);
|
||||
Assert.Equal(DaemonConfig.DefaultBindAddress, cfg.BindAddress);
|
||||
Assert.Equal(Paths.DefaultDbPath(), cfg.DbPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mixed_CliPort_FileDbPath_DefaultBind()
|
||||
{
|
||||
var file = new FileConfig { DbPath = "/tmp/mixed.db" };
|
||||
var cfg = ConfigResolver.Build(new[] { "--port", "7000" }, file);
|
||||
Assert.Equal(7000, cfg.Port);
|
||||
Assert.Equal(DaemonConfig.DefaultBindAddress, cfg.BindAddress);
|
||||
Assert.Equal(Paths.Expand("/tmp/mixed.db"), cfg.DbPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CliDbPath_ExpandsEnvVars()
|
||||
{
|
||||
var file = new FileConfig();
|
||||
var cfg = ConfigResolver.Build(new[] { "--db-path", "~/foo.db" }, file);
|
||||
Assert.DoesNotContain("~", cfg.DbPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidPortFlag_FallsBackToFileOrDefault()
|
||||
{
|
||||
var file = new FileConfig { Port = 4242 };
|
||||
var cfg = ConfigResolver.Build(new[] { "--port", "not-a-number" }, file);
|
||||
Assert.Equal(4242, cfg.Port);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using ClaudeMailbox.Config;
|
||||
|
||||
namespace ClaudeMailbox.Tests.Config;
|
||||
|
||||
public sealed class FileConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_ReturnsEmpty_WhenPathIsNullAndDefaultMissing()
|
||||
{
|
||||
var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json");
|
||||
var cfg = FileConfig.Load(explicitPath: null, defaultPath: missing);
|
||||
|
||||
Assert.Null(cfg.Port);
|
||||
Assert.Null(cfg.Bind);
|
||||
Assert.Null(cfg.DbPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ReadsDefaultPath_WhenExplicitPathNull()
|
||||
{
|
||||
var path = WriteTemp("""{"port":9000,"bind":"0.0.0.0","dbPath":"C:\\tmp\\a.db"}""");
|
||||
try
|
||||
{
|
||||
var cfg = FileConfig.Load(explicitPath: null, defaultPath: path);
|
||||
Assert.Equal(9000, cfg.Port);
|
||||
Assert.Equal("0.0.0.0", cfg.Bind);
|
||||
Assert.Equal(@"C:\tmp\a.db", cfg.DbPath);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ExplicitPath_WinsOverDefault()
|
||||
{
|
||||
var defaultPath = WriteTemp("""{"port":1111}""");
|
||||
var explicitPath = WriteTemp("""{"port":2222}""");
|
||||
try
|
||||
{
|
||||
var cfg = FileConfig.Load(explicitPath: explicitPath, defaultPath: defaultPath);
|
||||
Assert.Equal(2222, cfg.Port);
|
||||
}
|
||||
finally { File.Delete(defaultPath); File.Delete(explicitPath); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ExplicitPathMissing_Throws()
|
||||
{
|
||||
var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json");
|
||||
var ex = Assert.Throws<FileNotFoundException>(() =>
|
||||
FileConfig.Load(explicitPath: missing, defaultPath: null));
|
||||
Assert.Contains(missing, ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_MissingFields_AreNull()
|
||||
{
|
||||
var path = WriteTemp("""{"port":1234}""");
|
||||
try
|
||||
{
|
||||
var cfg = FileConfig.Load(explicitPath: path, defaultPath: null);
|
||||
Assert.Equal(1234, cfg.Port);
|
||||
Assert.Null(cfg.Bind);
|
||||
Assert.Null(cfg.DbPath);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_CaseInsensitive_PropertyNames()
|
||||
{
|
||||
var path = WriteTemp("""{"Port":1,"BIND":"x","DBPATH":"y"}""");
|
||||
try
|
||||
{
|
||||
var cfg = FileConfig.Load(explicitPath: path, defaultPath: null);
|
||||
Assert.Equal(1, cfg.Port);
|
||||
Assert.Equal("x", cfg.Bind);
|
||||
Assert.Equal("y", cfg.DbPath);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_MalformedJson_Throws()
|
||||
{
|
||||
var path = WriteTemp("not json");
|
||||
try
|
||||
{
|
||||
Assert.ThrowsAny<Exception>(() => FileConfig.Load(explicitPath: path, defaultPath: null));
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
private static string WriteTemp(string content)
|
||||
{
|
||||
var p = Path.Combine(Path.GetTempPath(), $"mailbox-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(p, content);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClaudeMailbox.Tests;
|
||||
|
||||
public sealed class MailboxEndToEndTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Health_Returns_Ok()
|
||||
{
|
||||
await using var host = await TestHost.StartAsync();
|
||||
|
||||
var res = await host.Client.GetAsync("/health");
|
||||
res.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await res.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("ok", body.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_Without_Header_Is_BadRequest()
|
||||
{
|
||||
await using var host = await TestHost.StartAsync();
|
||||
|
||||
var res = await host.Client.PostAsJsonAsync("/v1/send", new { to = "anyone", body = "hi" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_Mailboxes_Coordinate()
|
||||
{
|
||||
await using var host = await TestHost.StartAsync();
|
||||
using var backend = host.NewClientFor("backend");
|
||||
using var frontend = host.NewClientFor("frontend");
|
||||
|
||||
// backend sends to frontend
|
||||
var send = await backend.PostAsJsonAsync("/v1/send", new { to = "frontend", body = "API shape changed" });
|
||||
send.EnsureSuccessStatusCode();
|
||||
|
||||
// frontend peeks — expects 1
|
||||
var peek1 = await frontend.GetFromJsonAsync<JsonElement>("/v1/peek?name=frontend");
|
||||
Assert.Equal(1, peek1.GetProperty("pending").GetInt32());
|
||||
|
||||
// frontend consumes
|
||||
var check = await frontend.PostAsync("/v1/check-inbox?name=frontend", null);
|
||||
check.EnsureSuccessStatusCode();
|
||||
var messages = await check.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(1, messages.GetArrayLength());
|
||||
var msg = messages[0];
|
||||
Assert.Equal("backend", msg.GetProperty("from").GetString());
|
||||
Assert.Equal("API shape changed", msg.GetProperty("body").GetString());
|
||||
|
||||
// peek again — expects 0
|
||||
var peek2 = await frontend.GetFromJsonAsync<JsonElement>("/v1/peek?name=frontend");
|
||||
Assert.Equal(0, peek2.GetProperty("pending").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_Inbox_Rejects_Mismatched_Identity()
|
||||
{
|
||||
await using var host = await TestHost.StartAsync();
|
||||
using var backend = host.NewClientFor("backend");
|
||||
using var frontend = host.NewClientFor("frontend");
|
||||
|
||||
await backend.PostAsJsonAsync("/v1/send", new { to = "frontend", body = "hello" });
|
||||
|
||||
// backend tries to consume frontend's inbox — must be rejected
|
||||
var bad = await backend.PostAsync("/v1/check-inbox?name=frontend", null);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, bad.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_Returns_Known_Mailboxes()
|
||||
{
|
||||
await using var host = await TestHost.StartAsync();
|
||||
using var a = host.NewClientFor("alpha");
|
||||
using var b = host.NewClientFor("beta");
|
||||
|
||||
// Touch both mailboxes by having each peek its own inbox
|
||||
await a.GetAsync("/v1/peek?name=alpha");
|
||||
await b.GetAsync("/v1/peek?name=beta");
|
||||
|
||||
// /v1/list is the only endpoint that works without X-Mailbox
|
||||
var list = await host.Client.GetFromJsonAsync<JsonElement>("/v1/list");
|
||||
var names = new List<string>();
|
||||
foreach (var elem in list.EnumerateArray())
|
||||
names.Add(elem.GetProperty("name").GetString()!);
|
||||
|
||||
Assert.Contains("alpha", names);
|
||||
Assert.Contains("beta", names);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using ClaudeMailbox.Data;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeMailbox.Tests;
|
||||
|
||||
public sealed class MigrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EnsureReady_Creates_Schema_And_Is_Idempotent()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"claude-mailbox-migtest-{Guid.NewGuid():N}.db");
|
||||
try
|
||||
{
|
||||
using (var ctx = NewCtx(dbPath))
|
||||
MailboxDbContext.EnsureReady(ctx);
|
||||
|
||||
// Second call must not throw.
|
||||
using (var ctx = NewCtx(dbPath))
|
||||
MailboxDbContext.EnsureReady(ctx);
|
||||
|
||||
// Verify tables exist.
|
||||
await using var conn = new SqliteConnection($"Data Source={dbPath}");
|
||||
await conn.OpenAsync();
|
||||
|
||||
var tables = new List<string>();
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;";
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
Assert.Contains("mailboxes", tables);
|
||||
Assert.Contains("messages", tables);
|
||||
|
||||
// Verify the expected index exists.
|
||||
string? index;
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='index' AND name='ix_messages_to_delivered';";
|
||||
index = await cmd.ExecuteScalarAsync() as string;
|
||||
}
|
||||
Assert.Equal("ix_messages_to_delivered", index);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
foreach (var ext in new[] { "", "-wal", "-shm" })
|
||||
{
|
||||
var p = dbPath + ext;
|
||||
if (File.Exists(p))
|
||||
{
|
||||
try { File.Delete(p); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static MailboxDbContext NewCtx(string path)
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<MailboxDbContext>()
|
||||
.UseSqlite($"Data Source={path}")
|
||||
.Options;
|
||||
return new MailboxDbContext(opts);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClaudeMailbox.Tests;
|
||||
|
||||
public sealed class RaceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Parallel_CheckInbox_Delivers_Each_Message_Exactly_Once()
|
||||
{
|
||||
await using var host = await TestHost.StartAsync();
|
||||
using var sender = host.NewClientFor("sender");
|
||||
using var recipient = host.NewClientFor("recipient");
|
||||
|
||||
const int messageCount = 50;
|
||||
for (var i = 0; i < messageCount; i++)
|
||||
{
|
||||
var res = await sender.PostAsJsonAsync("/v1/send", new { to = "recipient", body = $"msg-{i}" });
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// Fire multiple concurrent checks. Each message must appear in exactly one result set.
|
||||
var tasks = Enumerable.Range(0, 8).Select(async _ =>
|
||||
{
|
||||
var res = await recipient.PostAsync("/v1/check-inbox?name=recipient", null);
|
||||
res.EnsureSuccessStatusCode();
|
||||
return await res.Content.ReadFromJsonAsync<JsonElement>();
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
var ids = new List<long>();
|
||||
foreach (var arr in results)
|
||||
foreach (var m in arr.EnumerateArray())
|
||||
ids.Add(m.GetProperty("id").GetInt64());
|
||||
|
||||
Assert.Equal(messageCount, ids.Count);
|
||||
Assert.Equal(messageCount, ids.Distinct().Count());
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using ClaudeMailbox;
|
||||
using ClaudeMailbox.Config;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace ClaudeMailbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Spins up a full ClaudeMailbox WebApplication on an ephemeral port against a temp SQLite file.
|
||||
/// Disposable — removes the DB and stops the host on dispose.
|
||||
/// </summary>
|
||||
public sealed class TestHost : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
private readonly string _dbPath;
|
||||
|
||||
public HttpClient Client { get; }
|
||||
public string BaseUrl { get; }
|
||||
public string DbPath => _dbPath;
|
||||
|
||||
private TestHost(WebApplication app, string dbPath, string baseUrl)
|
||||
{
|
||||
_app = app;
|
||||
_dbPath = dbPath;
|
||||
BaseUrl = baseUrl;
|
||||
Client = new HttpClient { BaseAddress = new Uri(baseUrl) };
|
||||
}
|
||||
|
||||
public static async Task<TestHost> StartAsync()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"claude-mailbox-test-{Guid.NewGuid():N}.db");
|
||||
var cfg = new DaemonConfig
|
||||
{
|
||||
Port = 0,
|
||||
BindAddress = "127.0.0.1",
|
||||
DbPath = dbPath,
|
||||
};
|
||||
|
||||
var builder = ServerHost.CreateBuilder(cfg);
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
|
||||
var app = builder.Build();
|
||||
ServerHost.ConfigurePipeline(app);
|
||||
|
||||
await app.StartAsync();
|
||||
|
||||
// Discover the port Kestrel picked.
|
||||
var server = app.Services.GetRequiredService<Microsoft.AspNetCore.Hosting.Server.IServer>();
|
||||
var feature = server.Features.Get<Microsoft.AspNetCore.Hosting.Server.Features.IServerAddressesFeature>();
|
||||
var url = feature?.Addresses.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("No bound URL after start.");
|
||||
|
||||
return new TestHost(app, dbPath, url);
|
||||
}
|
||||
|
||||
public HttpClient NewClientFor(string mailboxName)
|
||||
{
|
||||
var c = new HttpClient { BaseAddress = new Uri(BaseUrl) };
|
||||
c.DefaultRequestHeaders.Add("X-Mailbox", mailboxName);
|
||||
return c;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Client.Dispose();
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
|
||||
// Allow SQLite handle to release before deleting.
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
|
||||
foreach (var ext in new[] { "", "-wal", "-shm" })
|
||||
{
|
||||
var path = _dbPath + ext;
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user