Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
840a3e32c8 | ||
|
|
1f7585152e | ||
|
|
7b58db771a | ||
|
|
6592d428b7 | ||
|
|
22824bd35f | ||
|
|
951fb4f021 | ||
|
|
c1fc863047 | ||
|
|
8c8be67a98 | ||
|
|
307e15b05b | ||
|
|
efdc752890 | ||
|
|
9f8c1d9e9d | ||
|
|
1c2c1d2f7e | ||
|
|
bc53daf6e6 | ||
|
|
8169ebf4fe | ||
|
|
b05e6f2bd7 | ||
|
|
b74e969229 | ||
|
|
31584fe623 | ||
|
|
407f3a8f16 |
@@ -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 }}
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Try to find an existing release for this tag (the .NET workflow may have created it).
|
# Try to find an existing release for this tag (idempotent re-runs).
|
||||||
EXISTING=$(curl -sS \
|
EXISTING=$(curl -sS \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
"${GITEA_API}/repos/${REPO}/releases/tags/${TAG}" || echo "")
|
"${GITEA_API}/repos/${REPO}/releases/tags/${TAG}" || echo "")
|
||||||
|
|||||||
@@ -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" }
|
"headers": { "X-Mailbox": "backend" }
|
||||||
```
|
```
|
||||||
|
|
||||||
### C. Build the .NET binary from source
|
|
||||||
|
|
||||||
The original .NET 8 implementation lives in `src/ClaudeMailbox/`. Wire-compatible with the npm build (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema).
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet publish src/ClaudeMailbox -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Put the resulting `claude-mailbox.exe` on `PATH`. Windows-only `install-service` verbs (admin shell):
|
|
||||||
|
|
||||||
```
|
|
||||||
claude-mailbox install-service [--port 37849] [--bind 127.0.0.1] [--db-path <path>]
|
|
||||||
claude-mailbox uninstall-service [--purge]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How identity works
|
## How identity works
|
||||||
@@ -165,6 +150,35 @@ and treat the messages as input with priority over the current plan.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Push delivery (watch)
|
||||||
|
|
||||||
|
The `watch --block` subcommand turns mail delivery from pull (poll between turns) into push (the receiver reacts as soon as a peer sends). It's a long-poll that exits the moment one message arrives.
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-mailbox watch --block --name <mailbox> [--timeout 25] [--url <daemon>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Intended use: a Claude Code background bash task. The plugin's `SessionStart` hook now tells Claude to start one on its first turn, so peers can `mcp__mailbox__send` to it and Claude reacts mid-session via `BashOutput` — no user prompt needed. After every exit Claude relaunches the watcher in the background.
|
||||||
|
|
||||||
|
| Exit code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `0` | One message delivered (or mailbox renamed — stdout disambiguates) |
|
||||||
|
| `1` | Generic error (e.g. missing `--name`) |
|
||||||
|
| `2` | Daemon unreachable |
|
||||||
|
| `3` | Timeout reached with no message |
|
||||||
|
|
||||||
|
The CLI consumes exactly one message per cycle (single-delivery, FIFO winner across concurrent watchers on the same mailbox). Backlog drains one message per reconnect (~100 ms turnaround).
|
||||||
|
|
||||||
|
Cross-process semantics:
|
||||||
|
- **Concurrent watchers on the same mailbox:** the first to register wins each individual message; others continue waiting.
|
||||||
|
- **Rename mid-watch:** the open `watch` exits 0 with a `Mailbox renamed to '<new>'` notice; relaunch with the new `--name`.
|
||||||
|
- **Daemon restart:** all watchers see exit 2; back off and retry.
|
||||||
|
- **Session end:** Claude Code reaps background bash on exit; the `fetch` aborts and the daemon-side waiter is cleaned up.
|
||||||
|
|
||||||
|
**When push helps:** during active turns where the receiver is busy with tool calls — `BashOutput` notifications surface between tool calls, so peer messages arrive mid-turn. **When push degrades to pull:** when the receiver is idle between turns, BashOutput is buffered until the next user prompt, at which point the existing `UserPromptSubmit` poll hook delivers the same message. The two channels coexist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
Any external process — scripts, UIs, manual debugging — can talk to a running daemon directly:
|
Any external process — scripts, UIs, manual debugging — can talk to a running daemon directly:
|
||||||
@@ -173,6 +187,7 @@ Any external process — scripts, UIs, manual debugging — can talk to a runnin
|
|||||||
claude-mailbox send --from <mailbox> --to <mailbox> --body <text>
|
claude-mailbox send --from <mailbox> --to <mailbox> --body <text>
|
||||||
claude-mailbox peek --name <mailbox>
|
claude-mailbox peek --name <mailbox>
|
||||||
claude-mailbox check --name <mailbox> [--hook]
|
claude-mailbox check --name <mailbox> [--hook]
|
||||||
|
claude-mailbox watch --block --name <mailbox> [--timeout 25]
|
||||||
claude-mailbox list
|
claude-mailbox list
|
||||||
claude-mailbox status
|
claude-mailbox status
|
||||||
claude-mailbox session-announce # hook helper, reads stdin JSON
|
claude-mailbox session-announce # hook helper, reads stdin JSON
|
||||||
@@ -192,6 +207,7 @@ All subcommands accept `--url <url>` to target a non-default daemon address.
|
|||||||
| `POST` | `/v1/send` | yes (sender) | body: `{ to, body }` |
|
| `POST` | `/v1/send` | yes (sender) | body: `{ to, body }` |
|
||||||
| `GET` | `/v1/peek?name=<mailbox>` | no | read-only status |
|
| `GET` | `/v1/peek?name=<mailbox>` | no | read-only status |
|
||||||
| `POST` | `/v1/check-inbox?name=<mailbox>` | yes (must match `name`) | consume inbox |
|
| `POST` | `/v1/check-inbox?name=<mailbox>` | yes (must match `name`) | consume inbox |
|
||||||
|
| `GET` | `/v1/watch?name=<mailbox>&timeout=<sec>` | yes (must match `name`) | long-poll one message: `200` + body / `204` timeout / `409 { reason: "renamed", to }` |
|
||||||
| `GET` | `/v1/list` | optional (presence registers caller) | list all mailboxes |
|
| `GET` | `/v1/list` | optional (presence registers caller) | list all mailboxes |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -219,7 +235,7 @@ One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` a
|
|||||||
| HTTP | |
|
| HTTP | |
|
||||||
+--------------+-----------------+--------------------------+
|
+--------------+-----------------+--------------------------+
|
||||||
v
|
v
|
||||||
claude-mailbox serve (npm: Fastify; .NET: Kestrel)
|
claude-mailbox serve (Fastify)
|
||||||
/mcp MCP tools
|
/mcp MCP tools
|
||||||
/v1/* REST for non-MCP senders
|
/v1/* REST for non-MCP senders
|
||||||
/health
|
/health
|
||||||
@@ -232,19 +248,13 @@ One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` a
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Node port (the recommended runtime)
|
|
||||||
cd node
|
cd node
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
npm test
|
npm test
|
||||||
|
|
||||||
# .NET 8 port (wire-compatible alternative)
|
|
||||||
dotnet build
|
|
||||||
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj
|
|
||||||
dotnet run --project src/ClaudeMailbox -- serve
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The test suites cover end-to-end coordination, concurrent `check_inbox` race safety, schema idempotency, hook stdin parsing, session-id derivation, and settings-file patching.
|
The test suite covers end-to-end coordination, concurrent `check_inbox` race safety, schema idempotency, hook stdin parsing, session-id derivation, and settings-file patching.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"sdk": {
|
|
||||||
"version": "8.0.418",
|
|
||||||
"rollForward": "latestFeature"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# @kuns/claude-mailbox
|
# @kuns/claude-mailbox
|
||||||
|
|
||||||
Standalone MCP mail server that lets parallel Claude sessions coordinate with each other. TypeScript / Node port of the .NET `claude-mailbox` daemon — wire-compatible (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema).
|
Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -39,6 +39,16 @@ Under the hood the hook runs `claude-mailbox check --name <mailbox> --hook`, whi
|
|||||||
|
|
||||||
Cost: one local HTTP round-trip plus Node coldstart per prompt (~100ms on Windows).
|
Cost: one local HTTP round-trip plus Node coldstart per prompt (~100ms on Windows).
|
||||||
|
|
||||||
|
## Push delivery (watch)
|
||||||
|
|
||||||
|
For long-running autonomous sessions, run the watcher as a background bash task so peer messages surface immediately via `BashOutput`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude-mailbox watch --block --name <mailbox>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit codes: `0` delivered or renamed, `1` error, `2` daemon unreachable, `3` timeout. See the [repository README](https://git.kuns.dev/releases/ClaudeMailbox/src/branch/main/README.md#push-delivery-watch) for the full contract.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
`npm install` returns `401 Unauthorized`
|
`npm install` returns `401 Unauthorized`
|
||||||
|
|||||||
4
node/package-lock.json
generated
4
node/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "1.5.2",
|
"version": "1.5.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "1.5.2",
|
"version": "1.5.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "1.5.2",
|
"version": "1.5.5",
|
||||||
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
|
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const SERVICE_NAME = "ClaudeMailbox";
|
|||||||
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
||||||
const RUN_VALUE = "ClaudeMailbox";
|
const RUN_VALUE = "ClaudeMailbox";
|
||||||
const MARKER_FILE = "autostart-mode";
|
const MARKER_FILE = "autostart-mode";
|
||||||
|
const LAUNCHER_FILE = "autostart-launcher.vbs";
|
||||||
|
|
||||||
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
|
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
|
||||||
const path = userConfigPath();
|
const path = userConfigPath();
|
||||||
@@ -79,9 +80,33 @@ function tryScheduledTaskInstall(opts: AutostartInstallOpts): { ok: boolean; std
|
|||||||
return { ok: true, stderr: "" };
|
return { ok: true, stderr: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runKeyLauncherPath(): string {
|
||||||
|
return join(dirname(userConfigPath()), LAUNCHER_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRunKeyLauncher(configPath: string): string {
|
||||||
|
const { node, script } = buildServeCommand();
|
||||||
|
const cmd = `"${node}" "${script}" serve --config "${configPath}"`;
|
||||||
|
const escaped = cmd.replace(/"/g, '""');
|
||||||
|
const vbs =
|
||||||
|
`Set WshShell = CreateObject("WScript.Shell")\r\n` +
|
||||||
|
`WshShell.Run "${escaped}", 0, False\r\n` +
|
||||||
|
`Set WshShell = Nothing\r\n`;
|
||||||
|
const path = runKeyLauncherPath();
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, vbs, "utf8");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRunKeyLauncher(): void {
|
||||||
|
const path = runKeyLauncherPath();
|
||||||
|
if (existsSync(path)) rmSync(path, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
function runKeyInstall(opts: AutostartInstallOpts): void {
|
function runKeyInstall(opts: AutostartInstallOpts): void {
|
||||||
const configPath = ensureConfigSeeded(opts);
|
const configPath = ensureConfigSeeded(opts);
|
||||||
const cmd = buildServeCommandString(configPath);
|
const launcher = writeRunKeyLauncher(configPath);
|
||||||
|
const cmd = `wscript.exe "${launcher}"`;
|
||||||
const r = run("reg.exe", [
|
const r = run("reg.exe", [
|
||||||
"add",
|
"add",
|
||||||
RUN_KEY,
|
RUN_KEY,
|
||||||
@@ -122,6 +147,7 @@ function runKeyUninstall(): void {
|
|||||||
if (r.status !== 0 && !/unable to find/i.test(r.stderr)) {
|
if (r.status !== 0 && !/unable to find/i.test(r.stderr)) {
|
||||||
throw new Error(`reg delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
throw new Error(`reg delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
||||||
}
|
}
|
||||||
|
removeRunKeyLauncher();
|
||||||
killRunKeyDaemon();
|
killRunKeyDaemon();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +184,7 @@ function scheduledTaskUninstall(purge: boolean): void {
|
|||||||
}
|
}
|
||||||
// Best-effort Run-key cleanup
|
// Best-effort Run-key cleanup
|
||||||
run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
|
run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
|
||||||
|
removeRunKeyLauncher();
|
||||||
killRunKeyDaemon();
|
killRunKeyDaemon();
|
||||||
clearActiveMode();
|
clearActiveMode();
|
||||||
if (purge) purgeData();
|
if (purge) purgeData();
|
||||||
|
|||||||
107
node/src/cli.ts
107
node/src/cli.ts
@@ -10,8 +10,8 @@ import {
|
|||||||
applyInstall,
|
applyInstall,
|
||||||
applyUninstall,
|
applyUninstall,
|
||||||
buildHookCommand,
|
buildHookCommand,
|
||||||
|
buildSessionAnnounceLines,
|
||||||
deriveSessionName,
|
deriveSessionName,
|
||||||
formatActivePeerList,
|
|
||||||
formatMessagesForHook,
|
formatMessagesForHook,
|
||||||
parseHookStdin,
|
parseHookStdin,
|
||||||
readSettings,
|
readSettings,
|
||||||
@@ -240,42 +240,52 @@ program
|
|||||||
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||||
const name = deriveSessionName(sid, cwd);
|
const name = deriveSessionName(sid, cwd);
|
||||||
|
|
||||||
const lines = [
|
let peers: PeerEntry[] = [];
|
||||||
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
|
let daemonError: string | null = null;
|
||||||
`The name is auto-derived as <project>-<session-short>. You can rename it (e.g. to tag your working area) with mcp__mailbox__rename(current_name="${name}", new_name="<project>-<area>-<short>"); after that, use the new name everywhere.`,
|
|
||||||
`When using mcp__mailbox__* tools, ALWAYS pass your current name explicitly:`,
|
|
||||||
` - mcp__mailbox__send: from="${name}"`,
|
|
||||||
` - mcp__mailbox__check_inbox: name="${name}"`,
|
|
||||||
` - mcp__mailbox__peek_inbox: name="${name}"`,
|
|
||||||
` - mcp__mailbox__list_mailboxes: name="${name}"`,
|
|
||||||
`Peers reach you with: mcp__mailbox__send(from="<their-name>", to="${name}", body="...")`,
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const out = await callJson("GET", `${opts.url}/v1/list`, {
|
const out = await callJson("GET", `${opts.url}/v1/list`, {
|
||||||
headers: { "X-Mailbox": name },
|
headers: { "X-Mailbox": name },
|
||||||
});
|
});
|
||||||
const all = (Array.isArray(out) ? out : []) as PeerEntry[];
|
peers = (Array.isArray(out) ? out : []) as PeerEntry[];
|
||||||
lines.push(
|
|
||||||
"",
|
|
||||||
...formatActivePeerList(all, name, {
|
|
||||||
windowMinutes: opts.peerWindowMinutes,
|
|
||||||
maxPeers: opts.maxPeers,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
|
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
|
||||||
lines.push(
|
daemonError = `[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`;
|
||||||
"",
|
|
||||||
`[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lines = buildSessionAnnounceLines({
|
||||||
|
name,
|
||||||
|
peers,
|
||||||
|
windowMinutes: opts.peerWindowMinutes,
|
||||||
|
maxPeers: opts.maxPeers,
|
||||||
|
daemonError: daemonError ?? undefined,
|
||||||
|
});
|
||||||
lines.push("");
|
lines.push("");
|
||||||
process.stdout.write(lines.join("\n"));
|
process.stdout.write(lines.join("\n"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("session-end")
|
||||||
|
.description(
|
||||||
|
"SessionEnd-hook helper: derives the session's mailbox name from stdin session_id and asks the daemon to delete it if empty (no pending messages either direction). Silent on all errors — the daemon sweeper is the safety net.",
|
||||||
|
)
|
||||||
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||||
|
.action(async (opts: { url: string }) => {
|
||||||
|
const stdin = parseHookStdin(readStdinIfPiped());
|
||||||
|
const sid = stdin?.session_id?.trim();
|
||||||
|
if (!sid) return;
|
||||||
|
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||||
|
const name = deriveSessionName(sid, cwd);
|
||||||
|
try {
|
||||||
|
await callJson("POST", `${opts.url}/v1/session-end`, {
|
||||||
|
headers: { "X-Mailbox": name },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Daemon unreachable or other error — sweeper will clean up later.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List known mailboxes.")
|
.description("List known mailboxes.")
|
||||||
@@ -289,6 +299,55 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("watch")
|
||||||
|
.description(
|
||||||
|
"Block until one message arrives for --name, print it, and exit. Designed to be run as a Claude Code background bash task so its output surfaces via BashOutput.",
|
||||||
|
)
|
||||||
|
.requiredOption("--name <name>", "Mailbox to watch")
|
||||||
|
.option("--block", "Long-poll for a message (default behavior; flag accepted for clarity)")
|
||||||
|
.option(
|
||||||
|
"--timeout <seconds>",
|
||||||
|
"Long-poll timeout in seconds (1..300, default 25)",
|
||||||
|
(v) => parseInt(v, 10),
|
||||||
|
25,
|
||||||
|
)
|
||||||
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||||
|
.action(async (opts: { name: string; block?: boolean; timeout: number; url: string }) => {
|
||||||
|
const url = `${opts.url}/v1/watch?name=${encodeURIComponent(opts.name)}&timeout=${opts.timeout}`;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { headers: { "X-Mailbox": opts.name, Accept: "application/json" } });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`Could not reach daemon at ${opts.url}: ${msg}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
const body = (await res.json()) as { from: string; body: string; sentAt: string };
|
||||||
|
process.stdout.write(`[Claude-Mailbox] Mail from ${body.from}:\n${body.body}\n`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 409) {
|
||||||
|
const body = (await res.json().catch(() => ({}))) as { to?: string };
|
||||||
|
const newName = body.to ?? "<unknown>";
|
||||||
|
process.stdout.write(
|
||||||
|
`[Claude-Mailbox] Mailbox renamed to '${newName}'. Restart watcher with --name ${newName}.\n`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
console.error(`watch failed: HTTP ${res.status}${text ? ` — ${text}` : ""}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("mcp-stdio")
|
.command("mcp-stdio")
|
||||||
.description(
|
.description(
|
||||||
|
|||||||
128
node/src/db.ts
128
node/src/db.ts
@@ -52,6 +52,16 @@ function nowIso(): string {
|
|||||||
|
|
||||||
export type RenameFailure = "invalid" | "source-missing" | "target-exists";
|
export type RenameFailure = "invalid" | "source-missing" | "target-exists";
|
||||||
|
|
||||||
|
export type WaitResult =
|
||||||
|
| { kind: "message"; message: MessageRow }
|
||||||
|
| { kind: "timeout" }
|
||||||
|
| { kind: "renamed"; to: string }
|
||||||
|
| { kind: "aborted" };
|
||||||
|
|
||||||
|
interface Waiter {
|
||||||
|
resolve: (result: WaitResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export class RenameError extends Error {
|
export class RenameError extends Error {
|
||||||
constructor(message: string, public readonly reason: RenameFailure) {
|
constructor(message: string, public readonly reason: RenameFailure) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -84,6 +94,7 @@ function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
|||||||
|
|
||||||
export class MailboxStore {
|
export class MailboxStore {
|
||||||
private readonly db: DatabaseSync;
|
private readonly db: DatabaseSync;
|
||||||
|
private readonly waiters = new Map<string, Set<Waiter>>();
|
||||||
|
|
||||||
private readonly stmts: {
|
private readonly stmts: {
|
||||||
findMailbox: StatementSync;
|
findMailbox: StatementSync;
|
||||||
@@ -101,6 +112,7 @@ export class MailboxStore {
|
|||||||
findStaleCandidates: StatementSync;
|
findStaleCandidates: StatementSync;
|
||||||
deleteMessagesForNames: StatementSync;
|
deleteMessagesForNames: StatementSync;
|
||||||
deleteMailboxesByNames: StatementSync;
|
deleteMailboxesByNames: StatementSync;
|
||||||
|
selectOnePending: StatementSync;
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(public readonly dbPath: string) {
|
constructor(public readonly dbPath: string) {
|
||||||
@@ -162,6 +174,9 @@ export class MailboxStore {
|
|||||||
deleteMailboxesByNames: this.db.prepare(
|
deleteMailboxesByNames: this.db.prepare(
|
||||||
"DELETE FROM mailboxes WHERE name IN (SELECT value FROM json_each(?))",
|
"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",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +184,16 @@ export class MailboxStore {
|
|||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private consumeOne(name: string): MessageRow | null {
|
||||||
|
return runInTransaction(this.db, () => {
|
||||||
|
const row = this.stmts.selectOnePending.get(name) as MessageRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
const deliveredAt = nowIso();
|
||||||
|
this.stmts.markDelivered.run(deliveredAt, JSON.stringify([row.id]));
|
||||||
|
return { ...row, delivered_at: deliveredAt };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
upsertMailbox(name: string): void {
|
upsertMailbox(name: string): void {
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
const existing = this.stmts.findMailbox.get(name) as unknown as MailboxRow | undefined;
|
const existing = this.stmts.findMailbox.get(name) as unknown as MailboxRow | undefined;
|
||||||
@@ -180,13 +205,15 @@ export class MailboxStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
send(from: string, to: string, body: string): { id: number; queuedAt: Date } {
|
send(from: string, to: string, body: string): { id: number; queuedAt: Date } {
|
||||||
return runInTransaction(this.db, () => {
|
const result = runInTransaction(this.db, () => {
|
||||||
this.upsertMailbox(from);
|
this.upsertMailbox(from);
|
||||||
this.upsertMailbox(to);
|
this.upsertMailbox(to);
|
||||||
const createdAt = nowIso();
|
const createdAt = nowIso();
|
||||||
const result = this.stmts.insertMessage.run(to, from, body, createdAt);
|
const insert = this.stmts.insertMessage.run(to, from, body, createdAt);
|
||||||
return { id: Number(result.lastInsertRowid), queuedAt: new Date(createdAt) };
|
return { id: Number(insert.lastInsertRowid), queuedAt: new Date(createdAt) };
|
||||||
});
|
});
|
||||||
|
this.notifyOneWaiter(to);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
peek(name: string): InboxStatus {
|
peek(name: string): InboxStatus {
|
||||||
@@ -207,6 +234,78 @@ export class MailboxStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitForMessage(name: string, timeoutMs: number, signal: AbortSignal): Promise<WaitResult> {
|
||||||
|
const existing = this.consumeOne(name);
|
||||||
|
if (existing) return Promise.resolve({ kind: "message" as const, message: existing });
|
||||||
|
|
||||||
|
if (signal.aborted) return Promise.resolve({ kind: "aborted" as const });
|
||||||
|
|
||||||
|
return new Promise<WaitResult>((resolve) => {
|
||||||
|
const waiter: Waiter = { resolve };
|
||||||
|
let bucket = this.waiters.get(name);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = new Set();
|
||||||
|
this.waiters.set(name, bucket);
|
||||||
|
}
|
||||||
|
bucket.add(waiter);
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
const b = this.waiters.get(name);
|
||||||
|
if (b) {
|
||||||
|
b.delete(waiter);
|
||||||
|
if (b.size === 0) this.waiters.delete(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve({ kind: "timeout" });
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
cleanup();
|
||||||
|
resolve({ kind: "aborted" });
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invariant: synchronous from consumeOne to resolve. Introducing an `await` between them risks marking a message delivered with no listener to receive it.
|
||||||
|
private notifyOneWaiter(name: string): void {
|
||||||
|
const bucket = this.waiters.get(name);
|
||||||
|
if (!bucket || bucket.size === 0) return;
|
||||||
|
const first = bucket.values().next().value;
|
||||||
|
if (!first) return;
|
||||||
|
const msg = this.consumeOne(name);
|
||||||
|
if (!msg) return;
|
||||||
|
bucket.delete(first);
|
||||||
|
if (bucket.size === 0) this.waiters.delete(name);
|
||||||
|
first.resolve({ kind: "message", message: msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyRenamed(oldName: string, newName: string): void {
|
||||||
|
const bucket = this.waiters.get(oldName);
|
||||||
|
if (!bucket) return;
|
||||||
|
for (const w of bucket) w.resolve({ kind: "renamed", to: newName });
|
||||||
|
this.waiters.delete(oldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Test helper to wait for a long-poll waiter to register. Not part of the public contract. */
|
||||||
|
waiterCount(name: string): number {
|
||||||
|
return this.waiters.get(name)?.size ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectAllWaiters(): void {
|
||||||
|
for (const bucket of this.waiters.values()) {
|
||||||
|
for (const w of bucket) w.resolve({ kind: "aborted" });
|
||||||
|
}
|
||||||
|
this.waiters.clear();
|
||||||
|
}
|
||||||
|
|
||||||
rename(from: string, to: string): { from: string; to: string; messagesTransferred: number } {
|
rename(from: string, to: string): { from: string; to: string; messagesTransferred: number } {
|
||||||
const oldName = from.trim();
|
const oldName = from.trim();
|
||||||
const newName = to.trim();
|
const newName = to.trim();
|
||||||
@@ -217,7 +316,7 @@ export class MailboxStore {
|
|||||||
return { from: oldName, to: newName, messagesTransferred: 0 };
|
return { from: oldName, to: newName, messagesTransferred: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return runInTransaction(this.db, () => {
|
const result = runInTransaction(this.db, () => {
|
||||||
const source = this.stmts.findMailbox.get(oldName) as unknown as MailboxRow | undefined;
|
const source = this.stmts.findMailbox.get(oldName) as unknown as MailboxRow | undefined;
|
||||||
if (!source) throw new RenameError(`Mailbox '${oldName}' does not exist.`, "source-missing");
|
if (!source) throw new RenameError(`Mailbox '${oldName}' does not exist.`, "source-missing");
|
||||||
const target = this.stmts.findMailbox.get(newName) as unknown as MailboxRow | undefined;
|
const target = this.stmts.findMailbox.get(newName) as unknown as MailboxRow | undefined;
|
||||||
@@ -234,6 +333,8 @@ export class MailboxStore {
|
|||||||
this.db.prepare("DELETE FROM mailboxes WHERE name = ?").run(oldName);
|
this.db.prepare("DELETE FROM mailboxes WHERE name = ?").run(oldName);
|
||||||
return { from: oldName, to: newName, messagesTransferred: Number(movedTo.changes ?? 0) };
|
return { from: oldName, to: newName, messagesTransferred: Number(movedTo.changes ?? 0) };
|
||||||
});
|
});
|
||||||
|
this.notifyRenamed(oldName, newName);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
listMailboxes(forName?: string, options?: { hideAfterMinutes?: number }): MailboxInfo[] {
|
listMailboxes(forName?: string, options?: { hideAfterMinutes?: number }): MailboxInfo[] {
|
||||||
@@ -265,6 +366,25 @@ export class MailboxStore {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteIfEmpty(name: string): { deleted: boolean; reason: "deleted" | "not-found" | "has-pending" } {
|
||||||
|
return runInTransaction(this.db, () => {
|
||||||
|
const row = this.stmts.findMailbox.get(name) as MailboxRow | undefined;
|
||||||
|
if (!row) return { deleted: false, reason: "not-found" as const };
|
||||||
|
const pendingIn = (this.stmts.countPending.get(name) as { n: number } | undefined)?.n ?? 0;
|
||||||
|
if (pendingIn > 0) return { deleted: false, reason: "has-pending" as const };
|
||||||
|
const pendingOutRow = this.db
|
||||||
|
.prepare(
|
||||||
|
"SELECT 1 FROM messages WHERE from_mailbox = ? AND delivered_at IS NULL LIMIT 1",
|
||||||
|
)
|
||||||
|
.get(name);
|
||||||
|
if (pendingOutRow) return { deleted: false, reason: "has-pending" as const };
|
||||||
|
const namesJson = JSON.stringify([name]);
|
||||||
|
this.stmts.deleteMessagesForNames.run(namesJson, namesJson);
|
||||||
|
this.stmts.deleteMailboxesByNames.run(namesJson);
|
||||||
|
return { deleted: true, reason: "deleted" as const };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pruneStale(deleteAfterMinutes: number): { deletedMailboxes: number; deletedMessages: number } {
|
pruneStale(deleteAfterMinutes: number): { deletedMailboxes: number; deletedMessages: number } {
|
||||||
if (deleteAfterMinutes <= 0) return { deletedMailboxes: 0, deletedMessages: 0 };
|
if (deleteAfterMinutes <= 0) return { deletedMailboxes: 0, deletedMessages: 0 };
|
||||||
const cutoff = new Date(Date.now() - deleteAfterMinutes * 60_000).toISOString();
|
const cutoff = new Date(Date.now() - deleteAfterMinutes * 60_000).toISOString();
|
||||||
|
|||||||
@@ -117,6 +117,36 @@ export function formatActivePeerList(
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionAnnounceOptions {
|
||||||
|
name: string;
|
||||||
|
peers: PeerEntry[];
|
||||||
|
windowMinutes: number;
|
||||||
|
maxPeers: number;
|
||||||
|
daemonError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSessionAnnounceLines(opts: SessionAnnounceOptions): string[] {
|
||||||
|
const { name, peers, windowMinutes, maxPeers, daemonError } = opts;
|
||||||
|
const lines = [
|
||||||
|
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
|
||||||
|
`The name is auto-derived as <project>-<session-short>. You can rename it (e.g. to tag your working area) with mcp__mailbox__rename(current_name="${name}", new_name="<project>-<area>-<short>"); after that, use the new name everywhere.`,
|
||||||
|
`When using mcp__mailbox__* tools, ALWAYS pass your current name explicitly:`,
|
||||||
|
` - mcp__mailbox__send: from="${name}"`,
|
||||||
|
` - mcp__mailbox__check_inbox: name="${name}"`,
|
||||||
|
` - mcp__mailbox__peek_inbox: name="${name}"`,
|
||||||
|
` - mcp__mailbox__list_mailboxes: name="${name}"`,
|
||||||
|
`Peers reach you with: mcp__mailbox__send(from="<their-name>", to="${name}", body="...")`,
|
||||||
|
"",
|
||||||
|
`Push delivery is OPT-IN. Do NOT launch the watcher on your own. When the user wants peers to wake you mid-task, invoke the \`mailbox-collaborate\` skill (or the /collaborate slash command) to enter collaboration mode. Without it, peers can still leave messages — you'll see them on your next user prompt via the existing UserPromptSubmit hook.`,
|
||||||
|
];
|
||||||
|
if (daemonError) {
|
||||||
|
lines.push("", daemonError);
|
||||||
|
} else {
|
||||||
|
lines.push("", ...formatActivePeerList(peers, name, { windowMinutes, maxPeers }));
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HookMessage {
|
export interface HookMessage {
|
||||||
id: number;
|
id: number;
|
||||||
from: string;
|
from: string;
|
||||||
|
|||||||
@@ -27,9 +27,13 @@ function readVersion(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]);
|
const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]);
|
||||||
|
const SKIP_UPSERT_PATHS = new Set(["/v1/session-end"]);
|
||||||
|
|
||||||
export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promise<FastifyInstance> {
|
export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promise<FastifyInstance> {
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({
|
||||||
|
logger: true,
|
||||||
|
connectionTimeout: 310_000,
|
||||||
|
});
|
||||||
const version = readVersion();
|
const version = readVersion();
|
||||||
|
|
||||||
app.addHook("onRequest", async (req: FastifyRequest, reply: FastifyReply) => {
|
app.addHook("onRequest", async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
@@ -46,7 +50,9 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.mailboxName = name;
|
req.mailboxName = name;
|
||||||
|
if (!SKIP_UPSERT_PATHS.has(url)) {
|
||||||
store.upsertMailbox(name);
|
store.upsertMailbox(name);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/health", async () => ({
|
app.get("/health", async () => ({
|
||||||
@@ -121,6 +127,63 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const WATCH_DEFAULT_TIMEOUT_S = 25;
|
||||||
|
const WATCH_MAX_TIMEOUT_S = 300;
|
||||||
|
|
||||||
|
app.get<{ Querystring: { name?: string; timeout?: string } }>(
|
||||||
|
"/v1/watch",
|
||||||
|
async (req, reply) => {
|
||||||
|
const name = (req.query.name ?? "").trim();
|
||||||
|
if (!name) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: "name is required" };
|
||||||
|
}
|
||||||
|
if (name !== req.mailboxName) {
|
||||||
|
reply.code(403);
|
||||||
|
return { error: "X-Mailbox header must match name." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawTimeout = req.query.timeout;
|
||||||
|
const timeoutS = rawTimeout != null ? parseInt(rawTimeout, 10) : WATCH_DEFAULT_TIMEOUT_S;
|
||||||
|
if (!Number.isFinite(timeoutS) || timeoutS <= 0 || timeoutS > WATCH_MAX_TIMEOUT_S) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: `timeout must be 1..${WATCH_MAX_TIMEOUT_S} seconds` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
const onClose = (): void => ac.abort();
|
||||||
|
req.raw.once("close", onClose);
|
||||||
|
try {
|
||||||
|
const result = await store.waitForMessage(name, timeoutS * 1000, ac.signal);
|
||||||
|
|
||||||
|
if (result.kind === "message") {
|
||||||
|
const msg = rowToMessage(result.message);
|
||||||
|
reply.code(200);
|
||||||
|
return { ...msg, sentAt: msg.sentAt.toISOString() };
|
||||||
|
}
|
||||||
|
if (result.kind === "renamed") {
|
||||||
|
reply.code(409);
|
||||||
|
return { reason: "renamed", to: result.to };
|
||||||
|
}
|
||||||
|
if (result.kind === "timeout") {
|
||||||
|
reply.code(204);
|
||||||
|
return reply.send();
|
||||||
|
}
|
||||||
|
// aborted — client gone; hand control to Fastify without writing to the dead socket.
|
||||||
|
reply.hijack();
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
req.raw.off("close", onClose);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post("/v1/session-end", async (req) => {
|
||||||
|
const name = req.mailboxName!;
|
||||||
|
const result = store.deleteIfEmpty(name);
|
||||||
|
return { name, ...result };
|
||||||
|
});
|
||||||
|
|
||||||
await registerMcp(app, store, cfg.hideAfterMinutes);
|
await registerMcp(app, store, cfg.hideAfterMinutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -310,3 +310,54 @@ describe("pruneStale", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("deleteIfEmpty", () => {
|
||||||
|
it("deletes a fresh mailbox with no pending messages and wipes its delivered history", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob", "old");
|
||||||
|
store.checkInbox("bob");
|
||||||
|
const r = store.deleteIfEmpty("bob");
|
||||||
|
expect(r).toEqual({ deleted: true, reason: "deleted" });
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).not.toContain("bob");
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to delete when the mailbox has undelivered incoming mail", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob", "still pending");
|
||||||
|
const r = store.deleteIfEmpty("bob");
|
||||||
|
expect(r).toEqual({ deleted: false, reason: "has-pending" });
|
||||||
|
expect(store.peek("bob").pending).toBe(1);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to delete when the mailbox has undelivered outgoing mail", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob", "from alice");
|
||||||
|
const r = store.deleteIfEmpty("alice");
|
||||||
|
expect(r).toEqual({ deleted: false, reason: "has-pending" });
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).toContain("alice");
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for an unknown name (e.g. renamed mailbox)", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
const r = store.deleteIfEmpty("nope");
|
||||||
|
expect(r).toEqual({ deleted: false, reason: "not-found" });
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).toEqual(["alice"]);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
applyInstall,
|
applyInstall,
|
||||||
applyUninstall,
|
applyUninstall,
|
||||||
buildHookCommand,
|
buildHookCommand,
|
||||||
|
buildSessionAnnounceLines,
|
||||||
deriveProjectName,
|
deriveProjectName,
|
||||||
deriveSessionName,
|
deriveSessionName,
|
||||||
formatActivePeerList,
|
formatActivePeerList,
|
||||||
@@ -359,6 +360,58 @@ describe("formatActivePeerList", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildSessionAnnounceLines", () => {
|
||||||
|
it("includes the identity announcement and tool-call examples", () => {
|
||||||
|
const out = buildSessionAnnounceLines({
|
||||||
|
name: "alice-abc12345",
|
||||||
|
peers: [],
|
||||||
|
windowMinutes: 60,
|
||||||
|
maxPeers: 10,
|
||||||
|
}).join("\n");
|
||||||
|
expect(out).toContain("alice-abc12345");
|
||||||
|
expect(out).toContain("mcp__mailbox__send");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never auto-bootstraps the watcher — push delivery must be opt-in", () => {
|
||||||
|
const out = buildSessionAnnounceLines({
|
||||||
|
name: "alice-abc12345",
|
||||||
|
peers: [],
|
||||||
|
windowMinutes: 60,
|
||||||
|
maxPeers: 10,
|
||||||
|
}).join("\n");
|
||||||
|
expect(out).not.toContain("watch --block");
|
||||||
|
expect(out).not.toContain("run_in_background");
|
||||||
|
expect(out).not.toMatch(/REQUIRED FIRST ACTION/);
|
||||||
|
expect(out).not.toMatch(/MUST launch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("points the user to the opt-in collaborate skill / slash command", () => {
|
||||||
|
const out = buildSessionAnnounceLines({
|
||||||
|
name: "alice-abc12345",
|
||||||
|
peers: [],
|
||||||
|
windowMinutes: 60,
|
||||||
|
maxPeers: 10,
|
||||||
|
}).join("\n");
|
||||||
|
expect(out).toMatch(/mailbox-collaborate/);
|
||||||
|
expect(out).toMatch(/\/collaborate/);
|
||||||
|
expect(out).toMatch(/OPT-IN/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the peer list with the daemonError hint when daemon is unreachable", () => {
|
||||||
|
const out = buildSessionAnnounceLines({
|
||||||
|
name: "alice-abc12345",
|
||||||
|
peers: [],
|
||||||
|
windowMinutes: 60,
|
||||||
|
maxPeers: 10,
|
||||||
|
daemonError: "[Claude-Mailbox] Daemon not reachable at http://127.0.0.1:1.",
|
||||||
|
}).join("\n");
|
||||||
|
expect(out).toContain("Daemon not reachable");
|
||||||
|
// The misleading "no peers" line must NOT appear when the daemon is down.
|
||||||
|
expect(out).not.toMatch(/No other mailboxes seen/);
|
||||||
|
expect(out).not.toMatch(/Active peers/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("readSettings / writeSettings roundtrip", () => {
|
describe("readSettings / writeSettings roundtrip", () => {
|
||||||
it("survives an install → write → read cycle", () => {
|
it("survives an install → write → read cycle", () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
||||||
|
|||||||
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -210,4 +210,43 @@ describe("REST surface", () => {
|
|||||||
const peek = await call("GET", "/v1/peek?name=bob");
|
const peek = await call("GET", "/v1/peek?name=bob");
|
||||||
expect(peek.status).toBe(200);
|
expect(peek.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /v1/session-end deletes an empty mailbox and does not auto-recreate it", async () => {
|
||||||
|
await call("POST", "/v1/send", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: { to: "bob", body: "x" },
|
||||||
|
});
|
||||||
|
await call("POST", "/v1/check-inbox?name=bob", { headers: { "X-Mailbox": "bob" } });
|
||||||
|
|
||||||
|
const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "bob" } });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body).toMatchObject({ name: "bob", deleted: true, reason: "deleted" });
|
||||||
|
|
||||||
|
const list = await call("GET", "/v1/list");
|
||||||
|
const names = (list.body as Array<{ name: string }>).map((m) => m.name);
|
||||||
|
expect(names).not.toContain("bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /v1/session-end refuses to delete a mailbox with pending messages", async () => {
|
||||||
|
await call("POST", "/v1/send", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: { to: "bob", body: "still pending" },
|
||||||
|
});
|
||||||
|
const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "bob" } });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body).toMatchObject({ name: "bob", deleted: false, reason: "has-pending" });
|
||||||
|
|
||||||
|
const peek = await call("GET", "/v1/peek?name=bob");
|
||||||
|
expect(peek.body).toMatchObject({ pending: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /v1/session-end is a no-op for an unknown (e.g. renamed) name", async () => {
|
||||||
|
const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "renamed-away" } });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body).toMatchObject({ name: "renamed-away", deleted: false, reason: "not-found" });
|
||||||
|
|
||||||
|
const list = await call("GET", "/v1/list");
|
||||||
|
const names = (list.body as Array<{ name: string }>).map((m) => m.name);
|
||||||
|
expect(names).not.toContain("renamed-away");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mailbox",
|
"name": "claude-mailbox",
|
||||||
"version": "1.5.2",
|
"version": "1.5.5",
|
||||||
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.",
|
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mika Kuns"
|
"name": "Mika Kuns"
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ The `SessionStart` hook announces the current session's mailbox name in the conv
|
|||||||
|
|
||||||
Cost: one local HTTP round-trip per prompt and per subagent stop + Node coldstart (~100ms on Windows).
|
Cost: one local HTTP round-trip per prompt and per subagent stop + Node coldstart (~100ms on Windows).
|
||||||
|
|
||||||
|
The SessionStart announcement also instructs Claude to start `claude-mailbox watch --block --name <derived-name>` as a background bash task on its first turn. While that watcher is alive, peers can `mcp__mailbox__send(...)` and Claude reacts mid-turn — no user prompt needed. After processing each completion (delivery, timeout, rename, or daemon-down), Claude relaunches the watcher in the background. The pull hook (`UserPromptSubmit`) remains as a fallback for any messages that arrive while no watcher is running.
|
||||||
|
|
||||||
## MCP tools
|
## MCP tools
|
||||||
|
|
||||||
The plugin ships a `.mcp.json` that spawns a **stdio MCP wrapper** (`claude-mailbox mcp-stdio`) so the daemon URL is configurable per machine via the `CLAUDE_MAILBOX_URL` env var (Claude Code doesn't yet support env substitution in HTTP MCP URLs — see issue #46889). The wrapper proxies tool calls to the daemon's REST API.
|
The plugin ships a `.mcp.json` that spawns a **stdio MCP wrapper** (`claude-mailbox mcp-stdio`) so the daemon URL is configurable per machine via the `CLAUDE_MAILBOX_URL` env var (Claude Code doesn't yet support env substitution in HTTP MCP URLs — see issue #46889). The wrapper proxies tool calls to the daemon's REST API.
|
||||||
|
|||||||
36
plugin/commands/collaborate.md
Normal file
36
plugin/commands/collaborate.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
description: Enter Claude-Mailbox collaboration mode — start the push-delivery watch loop so peers can wake Claude mid-task.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are now in **mailbox collaboration mode**. Peers can wake you mid-task by sending mail to your mailbox.
|
||||||
|
|
||||||
|
## Launch the watcher
|
||||||
|
|
||||||
|
Your mailbox name was announced at SessionStart (format: `<project>-<short-session-id>`). Use it in place of `<NAME>` below.
|
||||||
|
|
||||||
|
Launch the watcher as a background bash task immediately:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bash(command="claude-mailbox watch --block --name <NAME>", run_in_background=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
The watcher is short-lived by design — it exits after exactly one event (delivered message, 25-second timeout, rename, or daemon error). Claude Code's BashOutput notifications fire only on task completion, so every exit MUST be followed by a fresh background relaunch with the same command. Gaps in the loop = missed pushes.
|
||||||
|
|
||||||
|
## Handling watcher completions
|
||||||
|
|
||||||
|
When a watcher task completes, inspect its stdout and exit code:
|
||||||
|
|
||||||
|
- **stdout contains `[Claude-Mailbox] Mail from <peer>: <body>`** → treat the body as an inbound interrupt with priority over your current plan. Address it, then relaunch the watcher.
|
||||||
|
- **stdout contains `[Claude-Mailbox] Mailbox renamed to '<new>'`** → relaunch with `--name <new>`, and use `<new>` for all future `mcp__mailbox__*` calls (update your identity).
|
||||||
|
- **exit code 3 with no stdout** → silent timeout, just relaunch.
|
||||||
|
- **exit code 2** → daemon unreachable; wait ~5 seconds, then relaunch.
|
||||||
|
- **any other exit code** → report it to the user, then relaunch.
|
||||||
|
|
||||||
|
## Stopping
|
||||||
|
|
||||||
|
Keep the loop running until the user says "stop watching", "stop collaborating", "end collaboration", or similar. When they do:
|
||||||
|
|
||||||
|
- Stop relaunching after the next completion.
|
||||||
|
- If a watcher is currently mid-poll and the user wants it killed immediately, use `TaskStop` on its task id.
|
||||||
|
|
||||||
|
Do not re-enter collaboration mode on your own after stopping — wait for the user to invoke this command again.
|
||||||
@@ -29,6 +29,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox session-end"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
plugin/skills/mailbox-collaborate/SKILL.md
Normal file
37
plugin/skills/mailbox-collaborate/SKILL.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: mailbox-collaborate
|
||||||
|
description: Start the Claude-Mailbox push-delivery watch loop for this session so peers can wake Claude mid-task. Use when the user says "let's collaborate", "wait for mail", "watch the mailbox", "listen for peers", "enter collaboration mode", or invokes /collaborate. Without this skill the watcher is OFF — peers can still leave messages but Claude only sees them on the next user prompt. Do NOT launch the watcher without this opt-in.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are now in **mailbox collaboration mode**. Peers can wake you mid-task by sending mail to your mailbox.
|
||||||
|
|
||||||
|
## Launch the watcher
|
||||||
|
|
||||||
|
Your mailbox name was announced at SessionStart (format: `<project>-<short-session-id>`). Use it in place of `<NAME>` below.
|
||||||
|
|
||||||
|
Launch the watcher as a background bash task immediately:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bash(command="claude-mailbox watch --block --name <NAME>", run_in_background=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
The watcher is short-lived by design — it exits after exactly one event (delivered message, 25-second timeout, rename, or daemon error). Claude Code's BashOutput notifications fire only on task completion, so every exit MUST be followed by a fresh background relaunch with the same command. Gaps in the loop = missed pushes.
|
||||||
|
|
||||||
|
## Handling watcher completions
|
||||||
|
|
||||||
|
When a watcher task completes, inspect its stdout and exit code:
|
||||||
|
|
||||||
|
- **stdout contains `[Claude-Mailbox] Mail from <peer>: <body>`** → treat the body as an inbound interrupt with priority over your current plan. Address it, then relaunch the watcher.
|
||||||
|
- **stdout contains `[Claude-Mailbox] Mailbox renamed to '<new>'`** → relaunch with `--name <new>`, and use `<new>` for all future `mcp__mailbox__*` calls (update your identity).
|
||||||
|
- **exit code 3 with no stdout** → silent timeout, just relaunch.
|
||||||
|
- **exit code 2** → daemon unreachable; wait ~5 seconds, then relaunch.
|
||||||
|
- **any other exit code** → report it to the user, then relaunch.
|
||||||
|
|
||||||
|
## Stopping
|
||||||
|
|
||||||
|
Keep the loop running until the user says "stop watching", "stop collaborating", "end collaboration", or similar. When they do:
|
||||||
|
|
||||||
|
- Stop relaunching after the next completion.
|
||||||
|
- If a watcher is currently mid-poll and the user wants it killed immediately, use `TaskStop` on its task id.
|
||||||
|
|
||||||
|
Do not re-enter collaboration mode on your own after stopping — wait for the user to invoke this skill again.
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<AssemblyName>claude-mailbox</AssemblyName>
|
|
||||||
<RootNamespace>ClaudeMailbox</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
|
||||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace ClaudeMailbox.Cli;
|
|
||||||
|
|
||||||
public static class ClientCommands
|
|
||||||
{
|
|
||||||
private const string DefaultUrl = "http://127.0.0.1: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