Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06a2ea6b7b | ||
|
|
01c22ff9a3 | ||
|
|
7b65545600 | ||
|
|
b10ac36ed0 | ||
|
|
8832eab6c7 | ||
|
|
8747d638fb | ||
|
|
d456f29138 | ||
|
|
d37d2419d6 | ||
|
|
ee0b72f43b | ||
|
|
d3abc762fd | ||
|
|
d0eb2af183 | ||
|
|
42237149a1 | ||
|
|
ac626f678b | ||
|
|
73a49e405f |
@@ -36,7 +36,17 @@ jobs:
|
|||||||
- name: Set package version
|
- name: Set package version
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.ver.outputs.version }}
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
run: npm version --no-git-tag-version "$VERSION"
|
run: npm version --no-git-tag-version --allow-same-version "$VERSION"
|
||||||
|
|
||||||
|
- name: Sync plugin.json version
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
jq --arg v "$VERSION" '.version = $v' plugin/.claude-plugin/plugin.json > plugin/.claude-plugin/plugin.json.tmp
|
||||||
|
mv plugin/.claude-plugin/plugin.json.tmp plugin/.claude-plugin/plugin.json
|
||||||
|
cat plugin/.claude-plugin/plugin.json
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -44,9 +54,6 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Pack
|
- name: Pack
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.ver.outputs.version }}
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -15,7 +15,7 @@ Pick one path. Most users want path A.
|
|||||||
Inside Claude Code:
|
Inside Claude Code:
|
||||||
|
|
||||||
```
|
```
|
||||||
/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox
|
/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox.git
|
||||||
/plugin install claude-mailbox@claude-mailbox
|
/plugin install claude-mailbox@claude-mailbox
|
||||||
/claude-mailbox:mailbox-doctor
|
/claude-mailbox:mailbox-doctor
|
||||||
```
|
```
|
||||||
@@ -73,7 +73,7 @@ Then drop this into your project's `.mcp.json`:
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"mailbox": {
|
"mailbox": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://127.0.0.1:47822/mcp"
|
"url": "http://127.0.0.1:37849/mcp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ dotnet publish src/ClaudeMailbox -c Release -r win-x64 --self-contained -p:Publi
|
|||||||
Put the resulting `claude-mailbox.exe` on `PATH`. Windows-only `install-service` verbs (admin shell):
|
Put the resulting `claude-mailbox.exe` on `PATH`. Windows-only `install-service` verbs (admin shell):
|
||||||
|
|
||||||
```
|
```
|
||||||
claude-mailbox install-service [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
|
claude-mailbox install-service [--port 37849] [--bind 127.0.0.1] [--db-path <path>]
|
||||||
claude-mailbox uninstall-service [--purge]
|
claude-mailbox uninstall-service [--purge]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -104,15 +104,26 @@ claude-mailbox uninstall-service [--purge]
|
|||||||
|
|
||||||
## How identity works
|
## How identity works
|
||||||
|
|
||||||
Every Claude Code session gets a unique mailbox name derived from its UUID:
|
Every Claude Code session gets a unique mailbox name automatically derived as `<project>-<8-hex-of-session-id>`:
|
||||||
|
|
||||||
| Setup | Resulting mailbox name |
|
| Setup | Resulting mailbox name |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Default | `claude-<8-hex-of-session-id>` |
|
| Inside a git repo | `<repo-basename>-<8-hex>` (e.g. `claude-mailbox-a3f91b2c`) |
|
||||||
| `CLAUDE_MAILBOX_NAME=backend` (in `.claude/settings.json` env) | `backend-<8-hex>` |
|
| Outside a git repo | `<cwd-basename>-<8-hex>` |
|
||||||
|
| No cwd available (rare) | `claude-<8-hex>` |
|
||||||
| Manual `.mcp.json` with `X-Mailbox: backend` header (no plugin) | `backend` (legacy mode) |
|
| Manual `.mcp.json` with `X-Mailbox: backend` header (no plugin) | `backend` (legacy mode) |
|
||||||
|
|
||||||
The plugin's `SessionStart` hook prints the session's identity and the list of peers active in the last hour into the conversation context, so Claude knows who it is and who's around without needing to call any tools first.
|
Project names are sanitized (lowercased, non-alphanumerics → dashes, capped at 40 chars). The plugin's `SessionStart` hook prints the session's identity and the list of peers active in the last hour into the conversation context, so Claude knows who it is and who's around without needing to call any tools first.
|
||||||
|
|
||||||
|
### Renaming at runtime
|
||||||
|
|
||||||
|
Claude can refine its own mailbox name during the session — useful when a session focuses on a specific area (e.g. only frontend work):
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__mailbox__rename(current_name="claude-mailbox-a3f91b2c", new_name="claude-mailbox-frontend-a3f91b2c")
|
||||||
|
```
|
||||||
|
|
||||||
|
Pending messages are transferred to the new name in a single transaction. The old name is removed — peers using it must re-discover via `list_mailboxes`. The endpoint returns `409` if the target name is already in use.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -127,7 +138,7 @@ claude-mailbox uninstall-autostart [--purge]
|
|||||||
|
|
||||||
| Platform | Default mechanism | `--service` mechanism |
|
| Platform | Default mechanism | `--service` mechanism |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Windows | Scheduled Task at logon (no admin) | Windows Service (admin, via `node-windows`) |
|
| Windows | Scheduled Task at logon (no admin); falls back to HKCU Run-key if Group Policy blocks schtasks | Windows Service (admin, via `node-windows`) |
|
||||||
| macOS | launchd LaunchAgent in `~/Library/LaunchAgents/` | n/a |
|
| macOS | launchd LaunchAgent in `~/Library/LaunchAgents/` | n/a |
|
||||||
| Linux | systemd `--user` unit in `~/.config/systemd/user/` | n/a |
|
| Linux | systemd `--user` unit in `~/.config/systemd/user/` | n/a |
|
||||||
|
|
||||||
@@ -193,7 +204,7 @@ CLI flag > mailbox.json > built-in defaults
|
|||||||
|
|
||||||
`mailbox.json` is searched at `~/.claude-mailbox/mailbox.json` (per-user), and on Windows additionally at `%ProgramData%\ClaudeMailbox\mailbox.json` (machine-wide, written by `--service` install). Override with `--config <path>`.
|
`mailbox.json` is searched at `~/.claude-mailbox/mailbox.json` (per-user), and on Windows additionally at `%ProgramData%\ClaudeMailbox\mailbox.json` (machine-wide, written by `--service` install). Override with `--config <path>`.
|
||||||
|
|
||||||
Defaults: port `47822`, bind `127.0.0.1`, database at `~/.claude-mailbox/mailbox.db`.
|
Defaults: port `37849`, bind `127.0.0.1`, database at `~/.claude-mailbox/mailbox.db`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -48,5 +48,5 @@ Cost: one local HTTP round-trip plus Node coldstart per prompt (~100ms on Window
|
|||||||
npm config set //git.kuns.dev/api/packages/releases/npm/:_authToken=<token>
|
npm config set //git.kuns.dev/api/packages/releases/npm/:_authToken=<token>
|
||||||
```
|
```
|
||||||
|
|
||||||
`gyp ERR! find VS` on Windows during install
|
`Cannot find module 'node:sqlite'` or similar
|
||||||
: `better-sqlite3` ships prebuilt binaries for current Node LTS versions. If yours isn't covered, npm falls back to building from source and needs the Visual Studio Build Tools. Either install them or pin to a Node version with a matching prebuild.
|
: claude-mailbox uses Node's built-in `node:sqlite`, stable since Node 24. On Node 22.5–23.x it works only with `--experimental-sqlite`. Upgrade to Node 24 LTS or newer: `nvm install 24 && nvm use 24` (or `winget install OpenJS.NodeJS.LTS` on Windows).
|
||||||
|
|||||||
2005
node/package-lock.json
generated
2005
node/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "0.1.0",
|
"version": "1.4.1",
|
||||||
"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": {
|
||||||
@@ -20,11 +20,10 @@
|
|||||||
"prepack": "npm run build"
|
"prepack": "npm run build"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"better-sqlite3": "^11.3.0",
|
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"zod": "^3.25.0"
|
"zod": "^3.25.0"
|
||||||
@@ -33,10 +32,9 @@
|
|||||||
"node-windows": "^1.0.0-beta.8"
|
"node-windows": "^1.0.0-beta.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
|
||||||
"@types/node": "^22.7.4",
|
"@types/node": "^22.7.4",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vitest": "^2.1.1"
|
"vitest": "^4.1.6"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mcp",
|
"mcp",
|
||||||
|
|||||||
@@ -1,10 +1,40 @@
|
|||||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
|
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
|
||||||
import { userConfigPath } from "../config.js";
|
import { userConfigPath } from "../config.js";
|
||||||
|
|
||||||
|
function markerPath(): string {
|
||||||
|
return join(dirname(userConfigPath()), MARKER_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readActiveMode(): "task" | "run-key" | null {
|
||||||
|
const path = markerPath();
|
||||||
|
if (!existsSync(path)) return null;
|
||||||
|
const raw = readFileSync(path, "utf8").trim();
|
||||||
|
if (raw === "task" || raw === "run-key") return raw;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeActiveMode(mode: "task" | "run-key"): void {
|
||||||
|
const path = markerPath();
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, mode, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveMode(): void {
|
||||||
|
const path = markerPath();
|
||||||
|
if (existsSync(path)) rmSync(path, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAccessDenied(stderr: string): boolean {
|
||||||
|
return /access is denied|0x80070005/i.test(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
const TASK_NAME = "ClaudeMailbox";
|
const TASK_NAME = "ClaudeMailbox";
|
||||||
const SERVICE_NAME = "ClaudeMailbox";
|
const SERVICE_NAME = "ClaudeMailbox";
|
||||||
|
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
||||||
|
const RUN_VALUE = "ClaudeMailbox";
|
||||||
|
const MARKER_FILE = "autostart-mode";
|
||||||
|
|
||||||
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
|
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
|
||||||
const path = userConfigPath();
|
const path = userConfigPath();
|
||||||
@@ -24,10 +54,14 @@ function buildServeCommand(): { node: string; script: string; configPath: string
|
|||||||
return { node, script, configPath: userConfigPath() };
|
return { node, script, configPath: userConfigPath() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduledTaskInstall(opts: AutostartInstallOpts): void {
|
function buildServeCommandString(configPath: string): string {
|
||||||
const configPath = ensureConfigSeeded(opts);
|
|
||||||
const { node, script } = buildServeCommand();
|
const { node, script } = buildServeCommand();
|
||||||
const tr = `"${node}" "${script}" serve --config "${configPath}"`;
|
return `"${node}" "${script}" serve --config "${configPath}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryScheduledTaskInstall(opts: AutostartInstallOpts): { ok: boolean; stderr: string } {
|
||||||
|
const configPath = ensureConfigSeeded(opts);
|
||||||
|
const tr = buildServeCommandString(configPath);
|
||||||
const r = run("schtasks.exe", [
|
const r = run("schtasks.exe", [
|
||||||
"/Create",
|
"/Create",
|
||||||
"/SC",
|
"/SC",
|
||||||
@@ -40,28 +74,110 @@ function scheduledTaskInstall(opts: AutostartInstallOpts): void {
|
|||||||
"LIMITED",
|
"LIMITED",
|
||||||
"/F",
|
"/F",
|
||||||
]);
|
]);
|
||||||
|
if (r.status !== 0) return { ok: false, stderr: r.stderr || r.stdout };
|
||||||
|
run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
|
||||||
|
return { ok: true, stderr: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runKeyInstall(opts: AutostartInstallOpts): void {
|
||||||
|
const configPath = ensureConfigSeeded(opts);
|
||||||
|
const cmd = buildServeCommandString(configPath);
|
||||||
|
const r = run("reg.exe", [
|
||||||
|
"add",
|
||||||
|
RUN_KEY,
|
||||||
|
"/v",
|
||||||
|
RUN_VALUE,
|
||||||
|
"/t",
|
||||||
|
"REG_SZ",
|
||||||
|
"/d",
|
||||||
|
cmd,
|
||||||
|
"/f",
|
||||||
|
]);
|
||||||
if (r.status !== 0) {
|
if (r.status !== 0) {
|
||||||
throw new Error(`schtasks /Create failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
throw new Error(`reg add (HKCU Run) failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
||||||
}
|
}
|
||||||
const start = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
|
spawnRunKeyDaemon(configPath);
|
||||||
if (start.status !== 0) {
|
}
|
||||||
console.warn(`Task created but /Run returned ${start.status}: ${start.stderr.trim()}`);
|
|
||||||
|
function spawnRunKeyDaemon(configPath: string): void {
|
||||||
|
if (runKeyDaemonRunning()) return;
|
||||||
|
const { node, script } = buildServeCommand();
|
||||||
|
const ps = `Start-Process -WindowStyle Hidden -FilePath "${node}" -ArgumentList @('"${script}"','serve','--config','"${configPath}"')`;
|
||||||
|
run("powershell.exe", ["-NoProfile", "-Command", ps]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runKeyDaemonRunning(): boolean {
|
||||||
|
const ps = `Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*claude-mailbox*serve*' } | Select-Object -First 1 -ExpandProperty ProcessId`;
|
||||||
|
const r = run("powershell.exe", ["-NoProfile", "-Command", ps]);
|
||||||
|
return r.status === 0 && r.stdout.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function killRunKeyDaemon(): void {
|
||||||
|
const ps = `Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*claude-mailbox*serve*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`;
|
||||||
|
run("powershell.exe", ["-NoProfile", "-Command", ps]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runKeyUninstall(): void {
|
||||||
|
const r = run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
|
||||||
|
if (r.status !== 0 && !/unable to find/i.test(r.stderr)) {
|
||||||
|
throw new Error(`reg delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
||||||
}
|
}
|
||||||
|
killRunKeyDaemon();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduledTaskInstall(opts: AutostartInstallOpts): void {
|
||||||
|
const attempt = tryScheduledTaskInstall(opts);
|
||||||
|
if (attempt.ok) {
|
||||||
|
writeActiveMode("task");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAccessDenied(attempt.stderr)) {
|
||||||
|
console.warn(
|
||||||
|
"schtasks /Create denied by Windows policy — falling back to HKCU Run-key autostart (per-user, no admin).",
|
||||||
|
);
|
||||||
|
runKeyInstall(opts);
|
||||||
|
writeActiveMode("run-key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`schtasks /Create failed: ${attempt.stderr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduledTaskUninstall(purge: boolean): void {
|
function scheduledTaskUninstall(purge: boolean): void {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
runKeyUninstall();
|
||||||
|
clearActiveMode();
|
||||||
|
if (purge) purgeData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Default to task uninstall, also clean up Run-key in case of mixed state
|
||||||
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
|
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
|
||||||
const r = run("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"]);
|
const r = run("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"]);
|
||||||
if (r.status !== 0 && !/cannot find/i.test(r.stderr)) {
|
if (r.status !== 0 && !/cannot find/i.test(r.stderr) && !/does not exist/i.test(r.stderr)) {
|
||||||
throw new Error(`schtasks /Delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
// Fall through — try Run-key cleanup anyway
|
||||||
}
|
}
|
||||||
|
// Best-effort Run-key cleanup
|
||||||
|
run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
|
||||||
|
killRunKeyDaemon();
|
||||||
|
clearActiveMode();
|
||||||
if (purge) purgeData();
|
if (purge) purgeData();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" {
|
function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
const r = run("reg.exe", ["query", RUN_KEY, "/v", RUN_VALUE]);
|
||||||
|
if (r.status !== 0) return "NotInstalled";
|
||||||
|
return runKeyDaemonRunning() ? "Running" : "Stopped";
|
||||||
|
}
|
||||||
const r = run("schtasks.exe", ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]);
|
const r = run("schtasks.exe", ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]);
|
||||||
if (r.status !== 0) {
|
if (r.status !== 0) {
|
||||||
if (/cannot find/i.test(r.stderr) || /does not exist/i.test(r.stderr)) return "NotInstalled";
|
if (/cannot find/i.test(r.stderr) || /does not exist/i.test(r.stderr)) {
|
||||||
|
// Maybe a Run-key install happened without a marker (legacy / manual). Check reg.
|
||||||
|
const reg = run("reg.exe", ["query", RUN_KEY, "/v", RUN_VALUE]);
|
||||||
|
if (reg.status === 0) return runKeyDaemonRunning() ? "Running" : "Stopped";
|
||||||
|
return "NotInstalled";
|
||||||
|
}
|
||||||
return "Stopped";
|
return "Stopped";
|
||||||
}
|
}
|
||||||
if (/Status:\s*Running/i.test(r.stdout)) return "Running";
|
if (/Status:\s*Running/i.test(r.stdout)) return "Running";
|
||||||
@@ -69,11 +185,22 @@ function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduledTaskRun(): void {
|
function scheduledTaskRun(): void {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
const cfgPath = userConfigPath();
|
||||||
|
spawnRunKeyDaemon(cfgPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const r = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
|
const r = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
|
||||||
if (r.status !== 0) throw new Error(`schtasks /Run failed: ${r.stderr || r.stdout}`);
|
if (r.status !== 0) throw new Error(`schtasks /Run failed: ${r.stderr || r.stdout}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduledTaskEnd(): void {
|
function scheduledTaskEnd(): void {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
killRunKeyDaemon();
|
||||||
|
return;
|
||||||
|
}
|
||||||
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
|
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js";
|
import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js";
|
||||||
import { startServer } from "./server.js";
|
|
||||||
import { autostartManager } from "./autostart/index.js";
|
import { autostartManager } from "./autostart/index.js";
|
||||||
|
import { runStdioMcp } from "./mcp-stdio.js";
|
||||||
import {
|
import {
|
||||||
applyInstall,
|
applyInstall,
|
||||||
applyUninstall,
|
applyUninstall,
|
||||||
@@ -35,7 +35,9 @@ function readVersion(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
|
const HARDCODED_DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
|
||||||
|
const ENV_URL = (process.env["CLAUDE_MAILBOX_URL"] ?? "").trim();
|
||||||
|
const DEFAULT_URL = ENV_URL || HARDCODED_DEFAULT_URL;
|
||||||
|
|
||||||
async function callJson(
|
async function callJson(
|
||||||
method: string,
|
method: string,
|
||||||
@@ -79,6 +81,7 @@ program
|
|||||||
.action(async (opts: { port?: number; bind?: string; dbPath?: string; config?: string }) => {
|
.action(async (opts: { port?: number; bind?: string; dbPath?: string; config?: string }) => {
|
||||||
const cfg = resolveConfig(opts);
|
const cfg = resolveConfig(opts);
|
||||||
try {
|
try {
|
||||||
|
const { startServer } = await import("./server.js");
|
||||||
const { app } = await startServer(cfg);
|
const { app } = await startServer(cfg);
|
||||||
app.log.info(`ClaudeMailbox listening on ${baseUrl(cfg)} (db: ${cfg.dbPath})`);
|
app.log.info(`ClaudeMailbox listening on ${baseUrl(cfg)} (db: ${cfg.dbPath})`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -134,22 +137,19 @@ function resolveHookMailboxName(explicit: string | undefined): string | null {
|
|||||||
if (explicit && explicit.trim()) return explicit.trim();
|
if (explicit && explicit.trim()) return explicit.trim();
|
||||||
const stdin = parseHookStdin(readStdinIfPiped());
|
const stdin = parseHookStdin(readStdinIfPiped());
|
||||||
const sid = stdin?.session_id?.trim();
|
const sid = stdin?.session_id?.trim();
|
||||||
if (sid) {
|
if (!sid) return null;
|
||||||
const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
|
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||||
return deriveSessionName(sid, base);
|
return deriveSessionName(sid, cwd);
|
||||||
}
|
|
||||||
const envName = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim();
|
|
||||||
return envName || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("check")
|
.command("check")
|
||||||
.description(
|
.description(
|
||||||
"Pull pending messages and mark delivered. In --hook mode the name is auto-derived from the SessionStart/UserPromptSubmit stdin (session_id), optionally flavored by $CLAUDE_MAILBOX_NAME.",
|
"Pull pending messages and mark delivered. In --hook mode the name is auto-derived from the SessionStart/UserPromptSubmit stdin: <project>-<session-short>, where <project> is the git-repo or cwd basename from stdin.",
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--name <name>",
|
"--name <name>",
|
||||||
"Explicit mailbox name. Overrides hook stdin and $CLAUDE_MAILBOX_NAME.",
|
"Explicit mailbox name. Overrides hook stdin auto-derivation.",
|
||||||
)
|
)
|
||||||
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||||
.option(
|
.option(
|
||||||
@@ -159,10 +159,10 @@ program
|
|||||||
.action(async (opts: { name?: string; url: string; hook?: boolean }) => {
|
.action(async (opts: { name?: string; url: string; hook?: boolean }) => {
|
||||||
const name = opts.hook
|
const name = opts.hook
|
||||||
? resolveHookMailboxName(opts.name)
|
? resolveHookMailboxName(opts.name)
|
||||||
: (opts.name ?? process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
|
: (opts.name ?? "").trim() || null;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
if (opts.hook) return;
|
if (opts.hook) return;
|
||||||
console.error("Missing --name (or set CLAUDE_MAILBOX_NAME).");
|
console.error("Missing --name.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -214,12 +214,13 @@ program
|
|||||||
const stdin = parseHookStdin(readStdinIfPiped());
|
const stdin = parseHookStdin(readStdinIfPiped());
|
||||||
const sid = stdin?.session_id?.trim();
|
const sid = stdin?.session_id?.trim();
|
||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
|
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||||
const name = deriveSessionName(sid, base);
|
const name = deriveSessionName(sid, cwd);
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
|
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
|
||||||
`When using mcp__mailbox__* tools, ALWAYS pass this name explicitly:`,
|
`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__send: from="${name}"`,
|
||||||
` - mcp__mailbox__check_inbox: name="${name}"`,
|
` - mcp__mailbox__check_inbox: name="${name}"`,
|
||||||
` - mcp__mailbox__peek_inbox: name="${name}"`,
|
` - mcp__mailbox__peek_inbox: name="${name}"`,
|
||||||
@@ -265,6 +266,20 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("mcp-stdio")
|
||||||
|
.description(
|
||||||
|
"Run a stdio MCP server that proxies tool calls to the local daemon's REST API. The daemon URL comes from $CLAUDE_MAILBOX_URL (default http://127.0.0.1:37849). Used by the Claude Code plugin's .mcp.json so the URL is configurable per machine without env-substitution in the URL field.",
|
||||||
|
)
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
await runStdioMcp();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("install-hook")
|
.command("install-hook")
|
||||||
.description(
|
.description(
|
||||||
@@ -328,11 +343,49 @@ program
|
|||||||
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
|
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
|
||||||
.option("--bind <address>", "Bind address")
|
.option("--bind <address>", "Bind address")
|
||||||
.option("--db-path <path>", "SQLite database path")
|
.option("--db-path <path>", "SQLite database path")
|
||||||
.action(async (opts: { service?: boolean; port?: number; bind?: string; dbPath?: string }) => {
|
.option(
|
||||||
const mgr = await autostartManager(opts.service ? "service" : "default");
|
"--skip-port-check",
|
||||||
await mgr.install({ port: opts.port, bind: opts.bind, dbPath: opts.dbPath });
|
"Skip the pre-install probe for a foreign occupant on the daemon's port",
|
||||||
console.log("Autostart installed.");
|
)
|
||||||
});
|
.action(
|
||||||
|
async (opts: {
|
||||||
|
service?: boolean;
|
||||||
|
port?: number;
|
||||||
|
bind?: string;
|
||||||
|
dbPath?: string;
|
||||||
|
skipPortCheck?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!opts.skipPortCheck) {
|
||||||
|
const cfg = resolveConfig({ port: opts.port, bind: opts.bind, dbPath: opts.dbPath });
|
||||||
|
const probeUrl = `http://${cfg.bind}:${cfg.port}/health`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(probeUrl, { headers: { Accept: "application/json" } });
|
||||||
|
const text = await res.text();
|
||||||
|
let parsed: { status?: string; version?: string } | null = null;
|
||||||
|
try {
|
||||||
|
parsed = text.length ? (JSON.parse(text) as { status?: string; version?: string }) : null;
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
if (res.ok && parsed?.status === "ok" && parsed.version) {
|
||||||
|
console.log(
|
||||||
|
`Port ${cfg.port} already serves a claude-mailbox daemon (version ${parsed.version}). Autostart will manage that one.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Port ${cfg.port} is held by a non-claude-mailbox service (status ${res.status}). Pick a free port via \`--port <n>\` or write {"port": <n>} to ~/.claude-mailbox/mailbox.json. Use --skip-port-check to bypass.`,
|
||||||
|
);
|
||||||
|
process.exit(4);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Connection refused or similar — port is free, proceed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mgr = await autostartManager(opts.service ? "service" : "default");
|
||||||
|
await mgr.install({ port: opts.port, bind: opts.bind, dbPath: opts.dbPath });
|
||||||
|
console.log("Autostart installed.");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("uninstall-autostart")
|
.command("uninstall-autostart")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
export const DEFAULT_PORT = 47822;
|
export const DEFAULT_PORT = 37849;
|
||||||
export const DEFAULT_BIND = "127.0.0.1";
|
export const DEFAULT_BIND = "127.0.0.1";
|
||||||
|
|
||||||
export interface FileConfig {
|
export interface FileConfig {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Database from "better-sqlite3";
|
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
@@ -50,6 +50,15 @@ function nowIso(): string {
|
|||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RenameFailure = "invalid" | "source-missing" | "target-exists";
|
||||||
|
|
||||||
|
export class RenameError extends Error {
|
||||||
|
constructor(message: string, public readonly reason: RenameFailure) {
|
||||||
|
super(message);
|
||||||
|
this.name = "RenameError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseDate(s: string | null | undefined): Date | null {
|
function parseDate(s: string | null | undefined): Date | null {
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
const normalized = s.includes("T") ? s : s.replace(" ", "T") + (s.endsWith("Z") ? "" : "Z");
|
const normalized = s.includes("T") ? s : s.replace(" ", "T") + (s.endsWith("Z") ? "" : "Z");
|
||||||
@@ -57,28 +66,44 @@ function parseDate(s: string | null | undefined): Date | null {
|
|||||||
return isNaN(d.getTime()) ? null : d;
|
return isNaN(d.getTime()) ? null : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
||||||
|
db.exec("BEGIN");
|
||||||
|
try {
|
||||||
|
const result = fn();
|
||||||
|
db.exec("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
db.exec("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
// ignore: original error already on its way up
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class MailboxStore {
|
export class MailboxStore {
|
||||||
private readonly db: Database.Database;
|
private readonly db: DatabaseSync;
|
||||||
|
|
||||||
private readonly stmts: {
|
private readonly stmts: {
|
||||||
findMailbox: Database.Statement;
|
findMailbox: StatementSync;
|
||||||
insertMailbox: Database.Statement;
|
insertMailbox: StatementSync;
|
||||||
touchMailbox: Database.Statement;
|
touchMailbox: StatementSync;
|
||||||
listMailboxes: Database.Statement;
|
listMailboxes: StatementSync;
|
||||||
insertMessage: Database.Statement;
|
insertMessage: StatementSync;
|
||||||
countPending: Database.Statement;
|
countPending: StatementSync;
|
||||||
oldestPending: Database.Statement;
|
oldestPending: StatementSync;
|
||||||
selectPending: Database.Statement;
|
selectPending: StatementSync;
|
||||||
markDelivered: Database.Statement;
|
markDelivered: StatementSync;
|
||||||
pendingByRecipient: Database.Statement;
|
pendingByRecipient: StatementSync;
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(public readonly dbPath: string) {
|
constructor(public readonly dbPath: string) {
|
||||||
mkdirSync(dirname(dbPath), { recursive: true });
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
this.db = new Database(dbPath);
|
this.db = new DatabaseSync(dbPath);
|
||||||
this.db.pragma("journal_mode = WAL");
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
this.db.pragma("foreign_keys = ON");
|
this.db.exec("PRAGMA foreign_keys = ON");
|
||||||
for (const sql of DDL_STATEMENTS) this.db.prepare(sql).run();
|
for (const sql of DDL_STATEMENTS) this.db.exec(sql);
|
||||||
|
|
||||||
this.stmts = {
|
this.stmts = {
|
||||||
findMailbox: this.db.prepare("SELECT * FROM mailboxes WHERE name = ?"),
|
findMailbox: this.db.prepare("SELECT * FROM mailboxes WHERE name = ?"),
|
||||||
@@ -114,7 +139,7 @@ export class MailboxStore {
|
|||||||
|
|
||||||
upsertMailbox(name: string): void {
|
upsertMailbox(name: string): void {
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
const existing = this.stmts.findMailbox.get(name) as MailboxRow | undefined;
|
const existing = this.stmts.findMailbox.get(name) as unknown as MailboxRow | undefined;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.stmts.touchMailbox.run(now, name);
|
this.stmts.touchMailbox.run(now, name);
|
||||||
} else {
|
} else {
|
||||||
@@ -123,14 +148,13 @@ export class MailboxStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
send(from: string, to: string, body: string): { id: number; queuedAt: Date } {
|
send(from: string, to: string, body: string): { id: number; queuedAt: Date } {
|
||||||
const tx = this.db.transaction(() => {
|
return 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 result = this.stmts.insertMessage.run(to, from, body, createdAt);
|
||||||
return { id: Number(result.lastInsertRowid), queuedAt: new Date(createdAt) };
|
return { id: Number(result.lastInsertRowid), queuedAt: new Date(createdAt) };
|
||||||
});
|
});
|
||||||
return tx();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
peek(name: string): InboxStatus {
|
peek(name: string): InboxStatus {
|
||||||
@@ -141,19 +165,47 @@ export class MailboxStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkInbox(name: string): MessageRow[] {
|
checkInbox(name: string): MessageRow[] {
|
||||||
const tx = this.db.transaction(() => {
|
return runInTransaction(this.db, () => {
|
||||||
const pending = this.stmts.selectPending.all(name) as MessageRow[];
|
const pending = this.stmts.selectPending.all(name) as unknown as MessageRow[];
|
||||||
if (pending.length > 0) {
|
if (pending.length > 0) {
|
||||||
const ids = pending.map((m) => m.id);
|
const ids = pending.map((m) => m.id);
|
||||||
this.stmts.markDelivered.run(nowIso(), JSON.stringify(ids));
|
this.stmts.markDelivered.run(nowIso(), JSON.stringify(ids));
|
||||||
}
|
}
|
||||||
return pending;
|
return pending;
|
||||||
});
|
});
|
||||||
return tx();
|
}
|
||||||
|
|
||||||
|
rename(from: string, to: string): { from: string; to: string; messagesTransferred: number } {
|
||||||
|
const oldName = from.trim();
|
||||||
|
const newName = to.trim();
|
||||||
|
if (!oldName) throw new RenameError("from is required", "invalid");
|
||||||
|
if (!newName) throw new RenameError("to is required", "invalid");
|
||||||
|
if (oldName === newName) {
|
||||||
|
this.upsertMailbox(oldName);
|
||||||
|
return { from: oldName, to: newName, messagesTransferred: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return runInTransaction(this.db, () => {
|
||||||
|
const source = this.stmts.findMailbox.get(oldName) as unknown as MailboxRow | undefined;
|
||||||
|
if (!source) throw new RenameError(`Mailbox '${oldName}' does not exist.`, "source-missing");
|
||||||
|
const target = this.stmts.findMailbox.get(newName) as unknown as MailboxRow | undefined;
|
||||||
|
if (target) throw new RenameError(`Mailbox '${newName}' already exists.`, "target-exists");
|
||||||
|
|
||||||
|
const now = nowIso();
|
||||||
|
this.stmts.insertMailbox.run(newName, source.created_at, now);
|
||||||
|
const movedTo = this.db
|
||||||
|
.prepare("UPDATE messages SET to_mailbox = ? WHERE to_mailbox = ?")
|
||||||
|
.run(newName, oldName);
|
||||||
|
this.db
|
||||||
|
.prepare("UPDATE messages SET from_mailbox = ? WHERE from_mailbox = ?")
|
||||||
|
.run(newName, oldName);
|
||||||
|
this.db.prepare("DELETE FROM mailboxes WHERE name = ?").run(oldName);
|
||||||
|
return { from: oldName, to: newName, messagesTransferred: Number(movedTo.changes ?? 0) };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
listMailboxes(forName?: string): MailboxInfo[] {
|
listMailboxes(forName?: string): MailboxInfo[] {
|
||||||
const rows = this.stmts.listMailboxes.all() as MailboxRow[];
|
const rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[];
|
||||||
const pendingMap = new Map<string, number>();
|
const pendingMap = new Map<string, number>();
|
||||||
if (forName) {
|
if (forName) {
|
||||||
const counts = this.stmts.pendingByRecipient.all() as { to_mailbox: string; n: number }[];
|
const counts = this.stmts.pendingByRecipient.all() as { to_mailbox: string; n: number }[];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
|
|
||||||
export interface HookStdinPayload {
|
export interface HookStdinPayload {
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
@@ -34,11 +35,51 @@ export function shortSessionId(sessionId: string): string {
|
|||||||
return sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 8) || "00000000";
|
return sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 8) || "00000000";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveSessionName(sessionId: string, base?: string | null): string {
|
const MAX_PROJECT_NAME_LENGTH = 40;
|
||||||
|
|
||||||
|
export function sanitizeProjectName(raw: string | null | undefined): string {
|
||||||
|
if (!raw) return "";
|
||||||
|
const cleaned = raw
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
return cleaned.slice(0, MAX_PROJECT_NAME_LENGTH).replace(/-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveProjectName(cwd?: string | null): string {
|
||||||
|
const dir = (cwd ?? "").trim();
|
||||||
|
if (dir) {
|
||||||
|
const gitTop = gitToplevel(dir);
|
||||||
|
if (gitTop) {
|
||||||
|
const sanitized = sanitizeProjectName(basename(gitTop));
|
||||||
|
if (sanitized) return sanitized;
|
||||||
|
}
|
||||||
|
const sanitized = sanitizeProjectName(basename(dir));
|
||||||
|
if (sanitized) return sanitized;
|
||||||
|
}
|
||||||
|
return "claude";
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitToplevel(cwd: string): string | null {
|
||||||
|
try {
|
||||||
|
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 1500,
|
||||||
|
});
|
||||||
|
if (r.status !== 0) return null;
|
||||||
|
const out = (r.stdout ?? "").trim();
|
||||||
|
return out || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveSessionName(sessionId: string, cwd?: string | null): string {
|
||||||
const short = shortSessionId(sessionId);
|
const short = shortSessionId(sessionId);
|
||||||
const trimmed = (base ?? "").trim();
|
const project = deriveProjectName(cwd);
|
||||||
if (trimmed) return `${trimmed}-${short}`;
|
return `${project}-${short}`;
|
||||||
return `claude-${short}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PeerEntry {
|
export interface PeerEntry {
|
||||||
|
|||||||
195
node/src/mcp-stdio.ts
Normal file
195
node/src/mcp-stdio.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { DEFAULT_PORT } from "./config.js";
|
||||||
|
|
||||||
|
const HARDCODED_DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
|
||||||
|
|
||||||
|
function resolveDaemonUrl(): string {
|
||||||
|
const env = (process.env["CLAUDE_MAILBOX_URL"] ?? "").trim();
|
||||||
|
if (!env || env.includes("${")) return HARDCODED_DEFAULT_URL;
|
||||||
|
return env.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireIdentity(value: string | undefined, argName: "from" | "name"): string {
|
||||||
|
const v = (value ?? "").trim();
|
||||||
|
if (!v) {
|
||||||
|
throw new Error(
|
||||||
|
`Pass \`${argName}\` (your mailbox name from the SessionStart announcement).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rest(
|
||||||
|
method: "GET" | "POST",
|
||||||
|
url: string,
|
||||||
|
init: { headers?: Record<string, string>; body?: unknown } = {},
|
||||||
|
): Promise<unknown> {
|
||||||
|
const headers: Record<string, string> = { Accept: "application/json", ...(init.headers ?? {}) };
|
||||||
|
let body: string | undefined;
|
||||||
|
if (init.body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
body = JSON.stringify(init.body);
|
||||||
|
}
|
||||||
|
const res = await fetch(url, { method, headers, body });
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`${method} ${url} → ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return text.length ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStdioMcpServer(daemonUrl: string = resolveDaemonUrl()): McpServer {
|
||||||
|
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"send",
|
||||||
|
{
|
||||||
|
title: "Send mail",
|
||||||
|
description:
|
||||||
|
"Send a message to another mailbox. Pass `from` with your own mailbox name (see the SessionStart announcement).",
|
||||||
|
inputSchema: {
|
||||||
|
to: z.string().describe("Name of the recipient mailbox."),
|
||||||
|
body: z.string().describe("Message body (plain text or markdown)."),
|
||||||
|
from: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ to, body, from }) => {
|
||||||
|
const sender = requireIdentity(from, "from");
|
||||||
|
const out = (await rest("POST", `${daemonUrl}/v1/send`, {
|
||||||
|
headers: { "X-Mailbox": sender },
|
||||||
|
body: { to, body },
|
||||||
|
})) as { id: number; queuedAt: string };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
|
structuredContent: out,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"check_inbox",
|
||||||
|
{
|
||||||
|
title: "Check inbox",
|
||||||
|
description:
|
||||||
|
"Pull all undelivered messages for your mailbox and mark them delivered. Pass `name` with your own mailbox name.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }) => {
|
||||||
|
const me = requireIdentity(name, "name");
|
||||||
|
const messages = (await rest(
|
||||||
|
"POST",
|
||||||
|
`${daemonUrl}/v1/check-inbox?name=${encodeURIComponent(me)}`,
|
||||||
|
{ headers: { "X-Mailbox": me } },
|
||||||
|
)) as { id: number; from: string; body: string; sentAt: string }[];
|
||||||
|
const arr = Array.isArray(messages) ? messages : [];
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(arr) }],
|
||||||
|
structuredContent: { messages: arr },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"peek_inbox",
|
||||||
|
{
|
||||||
|
title: "Peek inbox",
|
||||||
|
description:
|
||||||
|
"Non-consuming check of your mailbox. Returns pending count and oldest pending timestamp. Pass `name` with your own mailbox name.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }) => {
|
||||||
|
const me = requireIdentity(name, "name");
|
||||||
|
const out = (await rest("GET", `${daemonUrl}/v1/peek?name=${encodeURIComponent(me)}`)) as {
|
||||||
|
pending: number;
|
||||||
|
oldestAt: string | null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
|
structuredContent: out,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_mailboxes",
|
||||||
|
{
|
||||||
|
title: "List mailboxes",
|
||||||
|
description:
|
||||||
|
"Discover known mailboxes and how many messages each has waiting for you. Pass `name` with your own mailbox name.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }) => {
|
||||||
|
const me = requireIdentity(name, "name");
|
||||||
|
const list = (await rest("GET", `${daemonUrl}/v1/list`, {
|
||||||
|
headers: { "X-Mailbox": me },
|
||||||
|
})) as { name: string; lastSeenAt: string; pendingForYou: number }[];
|
||||||
|
const arr = Array.isArray(list) ? list : [];
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(arr) }],
|
||||||
|
structuredContent: { mailboxes: arr },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rename",
|
||||||
|
{
|
||||||
|
title: "Rename your mailbox",
|
||||||
|
description:
|
||||||
|
"Rename your own mailbox (e.g. to add a working-area tag like `myproject-frontend-a3f9`). Pending messages are transferred to the new name. After this returns, USE THE NEW NAME for all subsequent send/check/peek/list calls. Peers using the old name will fail until they re-discover via list_mailboxes.",
|
||||||
|
inputSchema: {
|
||||||
|
current_name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your current mailbox name (from the SessionStart announcement or last rename).",
|
||||||
|
),
|
||||||
|
new_name: z
|
||||||
|
.string()
|
||||||
|
.describe("The new mailbox name. Must be unique. Convention: <project>-<area>-<short>."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ current_name, new_name }) => {
|
||||||
|
const from = requireIdentity(current_name, "name");
|
||||||
|
const out = (await rest("POST", `${daemonUrl}/v1/rename`, {
|
||||||
|
headers: { "X-Mailbox": from },
|
||||||
|
body: { to: new_name },
|
||||||
|
})) as { from: string; to: string; messagesTransferred: number };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
|
structuredContent: out,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runStdioMcp(): Promise<void> {
|
||||||
|
const server = buildStdioMcpServer();
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { MailboxStore, rowToMessage } from "./db.js";
|
import { MailboxStore, RenameError, rowToMessage } from "./db.js";
|
||||||
import { HEADER_NAME } from "./server.js";
|
import { HEADER_NAME } from "./server.js";
|
||||||
|
|
||||||
function headerFallback(extra: unknown): string {
|
function headerFallback(extra: unknown): string {
|
||||||
@@ -141,6 +141,42 @@ function buildMcpServer(store: MailboxStore): McpServer {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rename",
|
||||||
|
{
|
||||||
|
title: "Rename your mailbox",
|
||||||
|
description:
|
||||||
|
"Rename your own mailbox (e.g. to add a working-area tag like `myproject-frontend-a3f9`). Pending messages are transferred to the new name. After this returns, USE THE NEW NAME for all subsequent send/check/peek/list calls. Peers using the old name will fail until they re-discover via list_mailboxes.",
|
||||||
|
inputSchema: {
|
||||||
|
current_name: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Your current mailbox name (the one to rename away from). Required unless X-Mailbox is set in .mcp.json.",
|
||||||
|
),
|
||||||
|
new_name: z
|
||||||
|
.string()
|
||||||
|
.describe("The new mailbox name. Must be unique. Convention: <project>-<area>-<short>."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ current_name, new_name }, extra) => {
|
||||||
|
const from = resolveIdentity(current_name, extra, "name");
|
||||||
|
try {
|
||||||
|
const r = store.rename(from, new_name);
|
||||||
|
const out = { from: r.from, to: r.to, messagesTransferred: r.messagesTransferred };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
|
structuredContent: out,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RenameError) {
|
||||||
|
throw new Error(`${err.message} (${err.reason})`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest }
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { MailboxStore, rowToMessage } from "./db.js";
|
import { MailboxStore, RenameError, rowToMessage } from "./db.js";
|
||||||
import type { DaemonConfig } from "./config.js";
|
import type { DaemonConfig } from "./config.js";
|
||||||
import { registerMcp } from "./mcp.js";
|
import { registerMcp } from "./mcp.js";
|
||||||
|
|
||||||
@@ -100,6 +100,25 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: { to?: string } }>("/v1/rename", async (req, reply) => {
|
||||||
|
const from = req.mailboxName!;
|
||||||
|
const to = (req.body?.to ?? "").trim();
|
||||||
|
if (!to) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: "to is required" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = store.rename(from, to);
|
||||||
|
return { from: r.from, to: r.to, messagesTransferred: r.messagesTransferred };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RenameError) {
|
||||||
|
reply.code(err.reason === "target-exists" ? 409 : 400);
|
||||||
|
return { error: err.message, reason: err.reason };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await registerMcp(app, store);
|
await registerMcp(app, store);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ describe("`check --hook` CLI behavior", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exits 0 silently when no stdin, no --name, no env", () => {
|
it("exits 0 silently when no stdin and no --name", () => {
|
||||||
const r = runCli(["check", "--hook"], { env: { CLAUDE_MAILBOX_NAME: undefined } });
|
const r = runCli(["check", "--hook"]);
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
expect(r.stdout).toBe("");
|
expect(r.stdout).toBe("");
|
||||||
expect(r.stderr).toBe("");
|
expect(r.stderr).toBe("");
|
||||||
@@ -44,18 +44,6 @@ describe("`check --hook` CLI behavior", () => {
|
|||||||
|
|
||||||
it("derives session-id-based name from stdin and emits daemon hint when down", () => {
|
it("derives session-id-based name from stdin and emits daemon hint when down", () => {
|
||||||
const r = runCli(["check", "--hook", "--url", "http://127.0.0.1:1"], {
|
const r = runCli(["check", "--hook", "--url", "http://127.0.0.1:1"], {
|
||||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
|
||||||
stdin: HOOK_STDIN,
|
|
||||||
});
|
|
||||||
expect(r.status).toBe(0);
|
|
||||||
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses base prefix from CLAUDE_MAILBOX_NAME when both env and stdin present", () => {
|
|
||||||
// We can't directly assert the name from --hook output (it's only in the unreachable hint URL).
|
|
||||||
// The hint always contains the URL we passed, so this just confirms the path runs without error.
|
|
||||||
const r = runCli(["check", "--hook", "--url", "http://127.0.0.1:1"], {
|
|
||||||
env: { CLAUDE_MAILBOX_NAME: "backend" },
|
|
||||||
stdin: HOOK_STDIN,
|
stdin: HOOK_STDIN,
|
||||||
});
|
});
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
@@ -65,16 +53,25 @@ describe("`check --hook` CLI behavior", () => {
|
|||||||
it("explicit --name overrides session-id derivation", () => {
|
it("explicit --name overrides session-id derivation", () => {
|
||||||
const r = runCli(
|
const r = runCli(
|
||||||
["check", "--hook", "--name", "explicit", "--url", "http://127.0.0.1:1"],
|
["check", "--hook", "--name", "explicit", "--url", "http://127.0.0.1:1"],
|
||||||
{ env: { CLAUDE_MAILBOX_NAME: "ignored" }, stdin: HOOK_STDIN },
|
{ stdin: HOOK_STDIN },
|
||||||
);
|
);
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses CLAUDE_MAILBOX_URL env as default base URL when --url is not given", () => {
|
||||||
|
const r = runCli(["check", "--hook"], {
|
||||||
|
env: { CLAUDE_MAILBOX_URL: "http://127.0.0.1:1" },
|
||||||
|
stdin: HOOK_STDIN,
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable at http://127.0.0.1:1");
|
||||||
|
});
|
||||||
|
|
||||||
it("non-hook mode errors out when no name resolved", () => {
|
it("non-hook mode errors out when no name resolved", () => {
|
||||||
const r = runCli(["check"], { env: { CLAUDE_MAILBOX_NAME: undefined } });
|
const r = runCli(["check"]);
|
||||||
expect(r.status).not.toBe(0);
|
expect(r.status).not.toBe(0);
|
||||||
expect(r.stderr).toContain("CLAUDE_MAILBOX_NAME");
|
expect(r.stderr).toContain("Missing --name");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,38 +84,33 @@ describe("`session-announce` CLI behavior", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints the derived mailbox name from a SessionStart payload", () => {
|
it("prints the derived mailbox name from a SessionStart payload (project-prefixed)", () => {
|
||||||
|
// cwd "/tmp" is not a git repo → basename "tmp" → project prefix "tmp".
|
||||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
|
||||||
stdin: HOOK_STDIN,
|
stdin: HOOK_STDIN,
|
||||||
});
|
});
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
expect(r.stdout).toContain("`claude-abc12345`");
|
// The exact prefix depends on the runtime cwd if git resolves; the deterministic
|
||||||
|
// assertion is the session-short suffix and the announcement structure.
|
||||||
|
expect(r.stdout).toMatch(/`[a-z0-9-]+-abc12345`/);
|
||||||
expect(r.stdout).toContain("mcp__mailbox__send");
|
expect(r.stdout).toContain("mcp__mailbox__send");
|
||||||
expect(r.stdout).toContain(`from="claude-abc12345"`);
|
expect(r.stdout).toMatch(/from="[a-z0-9-]+-abc12345"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses base prefix when set", () => {
|
it("includes a hint about the rename tool", () => {
|
||||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
const r = runCli(["session-announce", "--url", UNREACHABLE], { stdin: HOOK_STDIN });
|
||||||
env: { CLAUDE_MAILBOX_NAME: "backend" },
|
|
||||||
stdin: HOOK_STDIN,
|
|
||||||
});
|
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
expect(r.stdout).toContain("`backend-abc12345`");
|
expect(r.stdout).toContain("mcp__mailbox__rename");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits daemon-not-reachable hint when daemon is down", () => {
|
it("emits daemon-not-reachable hint when daemon is down", () => {
|
||||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
const r = runCli(["session-announce", "--url", UNREACHABLE], { stdin: HOOK_STDIN });
|
||||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
|
||||||
stdin: HOOK_STDIN,
|
|
||||||
});
|
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stays silent when no session_id in stdin", () => {
|
it("stays silent when no session_id in stdin", () => {
|
||||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
|
||||||
stdin: JSON.stringify({ hook_event_name: "SessionStart" }),
|
stdin: JSON.stringify({ hook_event_name: "SessionStart" }),
|
||||||
});
|
});
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
@@ -126,9 +118,7 @@ describe("`session-announce` CLI behavior", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("stays silent when no stdin at all", () => {
|
it("stays silent when no stdin at all", () => {
|
||||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
const r = runCli(["session-announce", "--url", UNREACHABLE]);
|
||||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
|
||||||
});
|
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
expect(r.stdout).toBe("");
|
expect(r.stdout).toBe("");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { MailboxStore } from "../src/db.js";
|
import { MailboxStore, RenameError } from "../src/db.js";
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
let dbPath: string;
|
let dbPath: string;
|
||||||
@@ -75,6 +75,93 @@ describe("send / peek / check round-trip", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("rename", () => {
|
||||||
|
it("renames a mailbox and transfers undelivered messages", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob-old", "hi");
|
||||||
|
store.send("alice", "bob-old", "again");
|
||||||
|
|
||||||
|
const r = store.rename("bob-old", "bob-new");
|
||||||
|
expect(r.from).toBe("bob-old");
|
||||||
|
expect(r.to).toBe("bob-new");
|
||||||
|
expect(r.messagesTransferred).toBe(2);
|
||||||
|
|
||||||
|
// Old name is gone.
|
||||||
|
const list = store.listMailboxes().map((m) => m.name);
|
||||||
|
expect(list).toContain("bob-new");
|
||||||
|
expect(list).not.toContain("bob-old");
|
||||||
|
|
||||||
|
// Messages still pending under the new name.
|
||||||
|
const peek = store.peek("bob-new");
|
||||||
|
expect(peek.pending).toBe(2);
|
||||||
|
|
||||||
|
// checkInbox under the new name yields the original bodies and the original from.
|
||||||
|
const pulled = store.checkInbox("bob-new");
|
||||||
|
expect(pulled.map((m) => m.body)).toEqual(["hi", "again"]);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also rewrites the from-side when the renamed mailbox was a sender", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("sender-old", "bob", "msg-1");
|
||||||
|
store.rename("sender-old", "sender-new");
|
||||||
|
const pulled = store.checkInbox("bob");
|
||||||
|
expect(pulled).toHaveLength(1);
|
||||||
|
expect(pulled[0]!.from_mailbox).toBe("sender-new");
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats rename-to-same-name as a no-op touch", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
const r = store.rename("alice", "alice");
|
||||||
|
expect(r.messagesTransferred).toBe(0);
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).toEqual(["alice"]);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when target already exists", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
expect(() => store.rename("alice", "bob")).toThrow(RenameError);
|
||||||
|
try {
|
||||||
|
store.rename("alice", "bob");
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as RenameError).reason).toBe("target-exists");
|
||||||
|
}
|
||||||
|
// Source still present after the failed attempt.
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).toEqual(["alice", "bob"]);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when source is missing", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
store.rename("nope", "fresh");
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(RenameError);
|
||||||
|
expect((e as RenameError).reason).toBe("source-missing");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("listMailboxes", () => {
|
describe("listMailboxes", () => {
|
||||||
it("returns mailboxes alphabetically with pendingForYou for the caller", () => {
|
it("returns mailboxes alphabetically with pendingForYou for the caller", () => {
|
||||||
const store = new MailboxStore(dbPath);
|
const store = new MailboxStore(dbPath);
|
||||||
|
|||||||
@@ -2,15 +2,18 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
applyInstall,
|
applyInstall,
|
||||||
applyUninstall,
|
applyUninstall,
|
||||||
buildHookCommand,
|
buildHookCommand,
|
||||||
|
deriveProjectName,
|
||||||
deriveSessionName,
|
deriveSessionName,
|
||||||
formatActivePeerList,
|
formatActivePeerList,
|
||||||
formatMessagesForHook,
|
formatMessagesForHook,
|
||||||
parseHookStdin,
|
parseHookStdin,
|
||||||
readSettings,
|
readSettings,
|
||||||
|
sanitizeProjectName,
|
||||||
shortSessionId,
|
shortSessionId,
|
||||||
writeSettings,
|
writeSettings,
|
||||||
type PeerEntry,
|
type PeerEntry,
|
||||||
@@ -205,7 +208,7 @@ describe("parseHookStdin", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shortSessionId / deriveSessionName", () => {
|
describe("shortSessionId", () => {
|
||||||
it("takes first 8 hex chars from a UUID", () => {
|
it("takes first 8 hex chars from a UUID", () => {
|
||||||
expect(shortSessionId("abc12345-de67-89f0-1234-567890abcdef")).toBe("abc12345");
|
expect(shortSessionId("abc12345-de67-89f0-1234-567890abcdef")).toBe("abc12345");
|
||||||
});
|
});
|
||||||
@@ -217,26 +220,73 @@ describe("shortSessionId / deriveSessionName", () => {
|
|||||||
it("falls back to a sanitized prefix for non-hex ids", () => {
|
it("falls back to a sanitized prefix for non-hex ids", () => {
|
||||||
expect(shortSessionId("session-Test123")).toBe("sessiont");
|
expect(shortSessionId("session-Test123")).toBe("sessiont");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("derives anonymous name when no base", () => {
|
describe("sanitizeProjectName", () => {
|
||||||
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef")).toBe("claude-abc12345");
|
it("lowercases and replaces non-alnum with dashes", () => {
|
||||||
|
expect(sanitizeProjectName("My Project!")).toBe("my-project");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prepends base prefix when given", () => {
|
it("collapses runs of separators", () => {
|
||||||
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", "backend")).toBe(
|
expect(sanitizeProjectName("foo __ bar")).toBe("foo-bar");
|
||||||
"backend-abc12345",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats whitespace-only base as no base", () => {
|
it("trims leading/trailing dashes", () => {
|
||||||
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", " ")).toBe(
|
expect(sanitizeProjectName("--foo--")).toBe("foo");
|
||||||
"claude-abc12345",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives different names for different sessions with the same base", () => {
|
it("returns empty for purely non-alnum input", () => {
|
||||||
const a = deriveSessionName("aaaa1111-de67-89f0-1234-567890abcdef", "shared");
|
expect(sanitizeProjectName("---")).toBe("");
|
||||||
const b = deriveSessionName("bbbb2222-de67-89f0-1234-567890abcdef", "shared");
|
expect(sanitizeProjectName("")).toBe("");
|
||||||
|
expect(sanitizeProjectName(null)).toBe("");
|
||||||
|
expect(sanitizeProjectName(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps long names", () => {
|
||||||
|
const out = sanitizeProjectName("a".repeat(120));
|
||||||
|
expect(out.length).toBeLessThanOrEqual(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveProjectName", () => {
|
||||||
|
it("uses cwd basename when not in a git repo", () => {
|
||||||
|
// tmpdir is virtually never inside a git repo; basename is platform-dependent.
|
||||||
|
const got = deriveProjectName(tmpdir());
|
||||||
|
expect(got).toMatch(/^[a-z0-9-]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'claude' when cwd is empty", () => {
|
||||||
|
expect(deriveProjectName("")).toBe("claude");
|
||||||
|
expect(deriveProjectName(null)).toBe("claude");
|
||||||
|
expect(deriveProjectName(undefined)).toBe("claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses git toplevel basename when called from inside a repo", () => {
|
||||||
|
// The test harness itself runs inside the claude-mailbox checkout.
|
||||||
|
let inRepo = false;
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: "pipe" });
|
||||||
|
inRepo = true;
|
||||||
|
} catch {
|
||||||
|
inRepo = false;
|
||||||
|
}
|
||||||
|
if (!inRepo) return; // CI without git in PATH — skip.
|
||||||
|
const got = deriveProjectName(process.cwd());
|
||||||
|
// Anywhere in the repo, we should resolve to the repo's basename — sanitized.
|
||||||
|
expect(got).toMatch(/^[a-z0-9-]+$/);
|
||||||
|
expect(got.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveSessionName", () => {
|
||||||
|
it("composes <project>-<short>", () => {
|
||||||
|
const got = deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", "");
|
||||||
|
expect(got).toBe("claude-abc12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives different names for different sessions in the same project", () => {
|
||||||
|
const a = deriveSessionName("aaaa1111-de67-89f0-1234-567890abcdef", "");
|
||||||
|
const b = deriveSessionName("bbbb2222-de67-89f0-1234-567890abcdef", "");
|
||||||
expect(a).not.toBe(b);
|
expect(a).not.toBe(b);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -94,6 +94,63 @@ describe("REST surface", () => {
|
|||||||
expect(wrong.status).toBe(403);
|
expect(wrong.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /v1/rename transfers pending messages and exposes the new name", async () => {
|
||||||
|
// alice sends to bob-old.
|
||||||
|
await call("POST", "/v1/send", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: { to: "bob-old", body: "hi old bob" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rename = await call("POST", "/v1/rename", {
|
||||||
|
headers: { "X-Mailbox": "bob-old" },
|
||||||
|
body: { to: "bob-new" },
|
||||||
|
});
|
||||||
|
expect(rename.status).toBe(200);
|
||||||
|
expect(rename.body).toMatchObject({
|
||||||
|
from: "bob-old",
|
||||||
|
to: "bob-new",
|
||||||
|
messagesTransferred: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Peek under new name shows the pending msg; old name is empty.
|
||||||
|
const peekNew = await call("GET", "/v1/peek?name=bob-new");
|
||||||
|
expect(peekNew.body).toMatchObject({ pending: 1 });
|
||||||
|
const peekOld = await call("GET", "/v1/peek?name=bob-old");
|
||||||
|
expect(peekOld.body).toMatchObject({ pending: 0 });
|
||||||
|
|
||||||
|
// check-inbox under new name pulls the message.
|
||||||
|
const check = await call("POST", "/v1/check-inbox?name=bob-new", {
|
||||||
|
headers: { "X-Mailbox": "bob-new" },
|
||||||
|
});
|
||||||
|
const arr = check.body as Array<{ from: string; body: string }>;
|
||||||
|
expect(arr).toHaveLength(1);
|
||||||
|
expect(arr[0]!.body).toBe("hi old bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /v1/rename returns 409 when target name is taken", async () => {
|
||||||
|
await call("POST", "/v1/send", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: { to: "bob", body: "x" },
|
||||||
|
});
|
||||||
|
// 'taken' already exists thanks to upsert on X-Mailbox.
|
||||||
|
const r = await call("POST", "/v1/rename", {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
body: { to: "alice" },
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(409);
|
||||||
|
expect(r.body).toMatchObject({ reason: "target-exists" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /v1/rename requires X-Mailbox and body.to", async () => {
|
||||||
|
const missingHeader = await call("POST", "/v1/rename", { body: { to: "x" } });
|
||||||
|
expect(missingHeader.status).toBe(400);
|
||||||
|
const missingTo = await call("POST", "/v1/rename", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
expect(missingTo.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it("/v1/list and /v1/peek are anonymous", async () => {
|
it("/v1/list and /v1/peek are anonymous", async () => {
|
||||||
await call("POST", "/v1/send", {
|
await call("POST", "/v1/send", {
|
||||||
headers: { "X-Mailbox": "alice" },
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mailbox",
|
"name": "claude-mailbox",
|
||||||
"version": "0.1.0",
|
"version": "1.4.1",
|
||||||
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt 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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"mailbox": {
|
"mailbox": {
|
||||||
"type": "http",
|
"type": "stdio",
|
||||||
"url": "http://127.0.0.1:47822/mcp"
|
"command": "claude-mailbox",
|
||||||
|
"args": ["mcp-stdio"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Lets Claude Code pull unread messages from a local `claude-mailbox` daemon befor
|
|||||||
## Setup (three prompts, all inside Claude Code)
|
## Setup (three prompts, all inside Claude Code)
|
||||||
|
|
||||||
```
|
```
|
||||||
/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox
|
/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox.git
|
||||||
/plugin install claude-mailbox@claude-mailbox
|
/plugin install claude-mailbox@claude-mailbox
|
||||||
/claude-mailbox:mailbox-doctor
|
/claude-mailbox:mailbox-doctor
|
||||||
```
|
```
|
||||||
@@ -14,22 +14,24 @@ The doctor walks the rest:
|
|||||||
|
|
||||||
1. installs the `claude-mailbox` binary via `npm install -g @kuns/claude-mailbox` if missing (asks first)
|
1. installs the `claude-mailbox` binary via `npm install -g @kuns/claude-mailbox` if missing (asks first)
|
||||||
2. registers the daemon for autostart and starts it if needed
|
2. registers the daemon for autostart and starts it if needed
|
||||||
3. health-probes `http://127.0.0.1:47822/health`
|
3. health-probes `http://127.0.0.1:37849/health`
|
||||||
4. optionally lets you set a **base prefix** (e.g., `backend`) — without one, mailbox names are anonymous (`claude-XXXXXXXX`)
|
4. runs a self → self smoke test
|
||||||
5. runs a self → self smoke test
|
|
||||||
|
|
||||||
Restart Claude Code only if step 4 wrote a new prefix. After that, every prompt auto-pulls unread messages.
|
After that, every prompt auto-pulls unread messages.
|
||||||
|
|
||||||
## Mailbox identity (the important bit)
|
## Mailbox identity (the important bit)
|
||||||
|
|
||||||
Each Claude Code session gets its own mailbox name, derived from the session's UUID:
|
Each Claude Code session gets its own mailbox name, automatically derived as `<project>-<session-short>`:
|
||||||
|
|
||||||
| Configuration | Resulting mailbox name |
|
| Where the session runs | Resulting mailbox name |
|
||||||
|---|---|
|
|---|---|
|
||||||
| No `CLAUDE_MAILBOX_NAME` set | `claude-a8b3c1d2` (first 8 hex chars of session_id) |
|
| Inside a git repo | `<repo-basename>-a8b3c1d2` (e.g. `claude-mailbox-a8b3c1d2`) |
|
||||||
| `CLAUDE_MAILBOX_NAME=backend` in `.claude/settings.json` env | `backend-a8b3c1d2` |
|
| Outside a git repo | `<cwd-basename>-a8b3c1d2` |
|
||||||
|
| No cwd in stdin (rare) | `claude-a8b3c1d2` |
|
||||||
|
|
||||||
So if you open two Claude Code sessions in the same project, they'll be e.g. `backend-a8b3c1d2` and `backend-d4e5f6a7` — distinct, addressable, no manual setup.
|
So if you open two Claude Code sessions in the same project, they'll share the project prefix but differ in the session-short — e.g. `claude-mailbox-a8b3c1d2` and `claude-mailbox-d4e5f6a7`. No env-var, no manual prefix step.
|
||||||
|
|
||||||
|
If a session focuses on a sub-area (frontend, backend, …), Claude can call `mcp__mailbox__rename(current_name="…", new_name="claude-mailbox-frontend-a8b3c1d2")` to tag itself; pending messages are transferred. Peers using the old name re-discover via `list_mailboxes`.
|
||||||
|
|
||||||
The `SessionStart` hook announces the current session's mailbox name in the conversation context on startup. Peers discover each other via `claude-mailbox list` or the `mcp__mailbox__list_mailboxes` MCP tool.
|
The `SessionStart` hook announces the current session's mailbox name in the conversation context on startup. Peers discover each other via `claude-mailbox list` or the `mcp__mailbox__list_mailboxes` MCP tool.
|
||||||
|
|
||||||
@@ -39,12 +41,15 @@ The `SessionStart` hook announces the current session's mailbox name in the conv
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `SessionStart` | `claude-mailbox session-announce` | Registers the session with the daemon, then prints (a) this session's mailbox name, (b) the exact `from` / `name` args to pass to MCP tools, and (c) a list of other mailboxes active in the last hour — so Claude knows who's around without needing to call `list_mailboxes` first. |
|
| `SessionStart` | `claude-mailbox session-announce` | Registers the session with the daemon, then prints (a) this session's mailbox name, (b) the exact `from` / `name` args to pass to MCP tools, and (c) a list of other mailboxes active in the last hour — so Claude knows who's around without needing to call `list_mailboxes` first. |
|
||||||
| `UserPromptSubmit` | `claude-mailbox check --hook` | Pulls unread messages for the session's mailbox and injects them as context. Silent on empty inbox; emits a one-line setup hint when the daemon is unreachable. |
|
| `UserPromptSubmit` | `claude-mailbox check --hook` | Pulls unread messages for the session's mailbox and injects them as context. Silent on empty inbox; emits a one-line setup hint when the daemon is unreachable. |
|
||||||
|
| `SubagentStop` | `claude-mailbox check --hook` | Same as `UserPromptSubmit`, but fires when a subagent finishes (Task tool). Lets the parent see peer messages that arrived during a long-running subagent run, instead of waiting until the next user prompt. |
|
||||||
|
|
||||||
Cost: one local HTTP round-trip per prompt + Node coldstart (~100ms on Windows).
|
Cost: one local HTTP round-trip per prompt and per subagent stop + Node coldstart (~100ms on Windows).
|
||||||
|
|
||||||
## MCP tools
|
## MCP tools
|
||||||
|
|
||||||
The plugin also ships a `.mcp.json` so Claude has direct access to the mailbox via tool calls. Because the X-Mailbox header would be the same for two parallel sessions sharing one `.mcp.json`, **each MCP tool takes the caller's mailbox name as an explicit argument** (from the SessionStart announcement):
|
The plugin ships a `.mcp.json` that spawns a **stdio MCP wrapper** (`claude-mailbox mcp-stdio`) so the daemon URL is configurable per machine via the `CLAUDE_MAILBOX_URL` env var (Claude Code doesn't yet support env substitution in HTTP MCP URLs — see issue #46889). The wrapper proxies tool calls to the daemon's REST API.
|
||||||
|
|
||||||
|
Each MCP tool takes the caller's mailbox name as an explicit argument (from the SessionStart announcement):
|
||||||
|
|
||||||
| Tool | Required args | Purpose |
|
| Tool | Required args | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -52,6 +57,7 @@ The plugin also ships a `.mcp.json` so Claude has direct access to the mailbox v
|
|||||||
| `mcp__mailbox__check_inbox` | `name` | Pull all undelivered messages for your mailbox (marks delivered). |
|
| `mcp__mailbox__check_inbox` | `name` | Pull all undelivered messages for your mailbox (marks delivered). |
|
||||||
| `mcp__mailbox__peek_inbox` | `name` | Non-consuming count of pending messages. |
|
| `mcp__mailbox__peek_inbox` | `name` | Non-consuming count of pending messages. |
|
||||||
| `mcp__mailbox__list_mailboxes` | `name` | Discover known mailboxes and `pendingForYou` counts. |
|
| `mcp__mailbox__list_mailboxes` | `name` | Discover known mailboxes and `pendingForYou` counts. |
|
||||||
|
| `mcp__mailbox__rename` | `current_name`, `new_name` | Rename your own mailbox (e.g. add an area tag). Pending messages are transferred. Use the new name afterward. |
|
||||||
|
|
||||||
The SessionStart announcement spells out the exact args to pass, so Claude picks them up automatically.
|
The SessionStart announcement spells out the exact args to pass, so Claude picks them up automatically.
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
---
|
---
|
||||||
description: Diagnose and auto-fix the Claude-Mailbox setup (binary install, daemon autostart, smoke test, optional base-prefix).
|
description: Diagnose and auto-fix the Claude-Mailbox setup (Node version, binary install, port-conflict detection, daemon autostart, smoke test, optional base-prefix).
|
||||||
allowed-tools: Bash, Read, Edit, Write
|
allowed-tools: Bash, Read, Edit, Write
|
||||||
---
|
---
|
||||||
|
|
||||||
You are running the **Claude-Mailbox doctor**. Walk through these checks in order. After each step, print a one-line `✓` / `✗` with the action you took. End with a summary block.
|
You are running the **Claude-Mailbox doctor**. Walk through these checks in order. After each step, print a one-line `✓` / `✗` with the action you took. End with a summary block.
|
||||||
|
|
||||||
Use `Bash` only for `claude-mailbox` subcommands, `npm`, `where`/`which`, and HTTP probes. Use `Read`/`Edit`/`Write` for `.claude/settings.json`. Never run `sudo` automatically — if elevation is needed, stop and ask.
|
Use `Bash` only for `claude-mailbox` subcommands, `npm`, `node`, `where`/`which`, and HTTP probes. Use `Read`/`Edit`/`Write` for `.claude/settings.json` and `mailbox.json`. Never run `sudo` automatically — if elevation is needed, stop and ask.
|
||||||
|
|
||||||
## Step 1 — daemon binary on PATH
|
## Step 1 — Node.js version
|
||||||
|
|
||||||
|
Run: `node --version`
|
||||||
|
|
||||||
|
claude-mailbox uses Node's built-in `node:sqlite` and therefore requires **Node 24 or newer**. Parse the major version from the output.
|
||||||
|
|
||||||
|
- **Major ≥ 24** → ✓ record the version, continue.
|
||||||
|
- **Major == 22 or 23** → ✗ Stop. `node:sqlite` is experimental on these and requires `--experimental-sqlite`. Print:
|
||||||
|
> Found Node `<X.Y.Z>`. claude-mailbox needs Node 24 LTS or newer. Install via `nvm install 24 && nvm use 24` (or `nvs` / `winget install OpenJS.NodeJS.LTS` on Windows), then re-run the doctor.
|
||||||
|
- **Major < 22** → ✗ Stop with the same message; this Node is end-of-life.
|
||||||
|
- **Major ≥ 26** with `better-sqlite3` still installed globally from a previous version → just note: "Node `<X.Y.Z>` is fine for the current claude-mailbox (no native deps); ignore any old `better-sqlite3` build warnings from a prior install."
|
||||||
|
|
||||||
|
If `node --version` itself fails (`command not found`), stop and tell the user to install Node 24+ first.
|
||||||
|
|
||||||
|
## Step 2 — daemon binary on PATH
|
||||||
|
|
||||||
Run: `claude-mailbox --version`
|
Run: `claude-mailbox --version`
|
||||||
|
|
||||||
@@ -27,7 +41,31 @@ Run: `claude-mailbox --version`
|
|||||||
|
|
||||||
After install, re-run `claude-mailbox --version`. If it still fails, stop and report.
|
After install, re-run `claude-mailbox --version`. If it still fails, stop and report.
|
||||||
|
|
||||||
## Step 2 — daemon autostart and running state
|
## Step 3 — port-conflict check (before autostart!)
|
||||||
|
|
||||||
|
Default port is 37849. Probe whether anything is already on it:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -sf http://127.0.0.1:37849/health
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Returns a JSON body with `"status":"ok"` and a `version` field that matches `claude-mailbox --version`** → it's already our daemon, ✓ skip to Step 5.
|
||||||
|
- **Returns 200 with `"status":"ok"` but a different `version`** → it's an older claude-mailbox; treat as running, ✓.
|
||||||
|
- **Returns non-200, non-JSON, or any other foreign response** → **port conflict**. Some other process owns 37849.
|
||||||
|
- **Connection refused** → port is free, ✓ continue to Step 4.
|
||||||
|
|
||||||
|
If port conflict detected:
|
||||||
|
1. Tell the user which process holds the port (Windows: `Get-NetTCPConnection -LocalPort 37849 | Select-Object OwningProcess`, then `Get-Process -Id <pid>`; macOS/Linux: `lsof -i :37849`).
|
||||||
|
2. Pick a free port. Default suggestion: **47900**. Verify it's free: `curl -sf http://127.0.0.1:47900/health` should fail with connection refused.
|
||||||
|
3. Read `~/.claude-mailbox/mailbox.json` (create empty `{}` if missing) and merge `{"port": <chosen>}`. Write back.
|
||||||
|
4. Also write the override into `.claude/settings.json` env so the plugin's hooks find the right URL:
|
||||||
|
```json
|
||||||
|
"env": { "CLAUDE_MAILBOX_URL": "http://127.0.0.1:<chosen>" }
|
||||||
|
```
|
||||||
|
Merge into existing env, preserving other keys.
|
||||||
|
5. Mark `restart_needed = true`.
|
||||||
|
|
||||||
|
## Step 4 — daemon autostart and running state
|
||||||
|
|
||||||
Run: `claude-mailbox status`
|
Run: `claude-mailbox status`
|
||||||
|
|
||||||
@@ -35,51 +73,44 @@ Run: `claude-mailbox status`
|
|||||||
- `Stopped` → `claude-mailbox start`, re-check.
|
- `Stopped` → `claude-mailbox start`, re-check.
|
||||||
- `NotInstalled` → `claude-mailbox install-autostart`, then `claude-mailbox start`, re-check.
|
- `NotInstalled` → `claude-mailbox install-autostart`, then `claude-mailbox start`, re-check.
|
||||||
|
|
||||||
If status doesn't reach `Running`, stop and report.
|
**Behavior on `install-autostart`:** The CLI tries a Scheduled Task first (`schtasks /RL LIMITED`, no admin). If Windows Group Policy returns "Access is denied", it falls back transparently to an `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` registry entry plus a hidden `node serve` process — same per-user persistence, no admin needed. The chosen mechanism is recorded in `~/.claude-mailbox/autostart-mode` and respected by `status`/`start`/`stop`/`uninstall-autostart`.
|
||||||
|
|
||||||
## Step 3 — health probe
|
If `install-autostart` still fails after both attempts (very rare — would mean both `schtasks` and `reg add` are blocked), stop and report what `status` and `start` printed.
|
||||||
|
|
||||||
Hit `http://127.0.0.1:47822/health`. Expect a JSON body with `"status":"ok"`. If unreachable, stop and report — the daemon claims it's running but isn't accepting connections.
|
## Step 5 — health probe
|
||||||
|
|
||||||
## Step 4 — mailbox identity
|
Hit `http://127.0.0.1:<port>/health` (use the configured port, not necessarily 37849). Expect a JSON body with `"status":"ok"` AND a `version` matching `claude-mailbox --version`. If unreachable or version mismatch, stop and report.
|
||||||
|
|
||||||
**No prompt by default.** Each Claude Code session now gets a unique mailbox name auto-derived from its `session_id` (e.g., `claude-a8b3c1d2`), so two parallel sessions can never collide.
|
## Step 6 — mailbox identity
|
||||||
|
|
||||||
Read `.claude/settings.json` in the current working directory and look for `env.CLAUDE_MAILBOX_NAME`.
|
**No prompt.** Each Claude Code session gets a unique mailbox name auto-derived as `<project>-<short_session_id>`, where `<project>` is the git-repo basename of the session's `cwd` (or the cwd basename if not a git repo). Example: `claude-mailbox-a8b3c1d2`.
|
||||||
|
|
||||||
- If set → ✓ this is a **base prefix**. The real name will be `<base>-<short_session_id>`. Tell the user "Mailbox prefix is set to `X`."
|
✓ "Mailbox name will be auto-derived as `<project>-<short_session_id>`."
|
||||||
- If unset → ✓ tell the user "Mailbox name will be auto-derived (`claude-<short_session_id>`)."
|
|
||||||
|
|
||||||
Then **ask** the user (one question, not a deep prompt):
|
Sessions can also rename themselves at runtime via the `mcp__mailbox__rename` MCP tool — e.g. to add an area tag like `claude-mailbox-frontend-a8b3c1d2`. No config involved.
|
||||||
|
|
||||||
> "Want to flavor your mailbox names with a memorable prefix like `backend` or `frontend`? (yes / no / change to `<x>`)"
|
## Step 7 — smoke test
|
||||||
|
|
||||||
If they say yes or give a value:
|
Use two ephemeral names — we don't need the real session name here:
|
||||||
1. Read `.claude/settings.json` (empty `{}` if missing).
|
|
||||||
2. Merge `env.CLAUDE_MAILBOX_NAME` = chosen value, preserving anything else.
|
|
||||||
3. Write back with 2-space indentation.
|
|
||||||
4. Mark this as `restart_needed = true`.
|
|
||||||
|
|
||||||
If they say no or skip → leave as-is.
|
|
||||||
|
|
||||||
## Step 5 — smoke test
|
|
||||||
|
|
||||||
Use two ephemeral names (`doctor-probe-a` / `doctor-probe-b`) — we don't need the real session name here, we just need to prove the daemon round-trips:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
claude-mailbox send --from doctor-probe-a --to doctor-probe-b --body "ping from doctor"
|
claude-mailbox send --from doctor-probe-a --to doctor-probe-b --body "ping from doctor"
|
||||||
claude-mailbox check --name doctor-probe-b
|
claude-mailbox check --name doctor-probe-b
|
||||||
```
|
```
|
||||||
|
|
||||||
The `check` output must be a JSON array with one message: `from: doctor-probe-a`, body matches. If yes ✓. If no ✗ and report what came back.
|
(If the port was changed in Step 3, pass `--url http://127.0.0.1:<port>` to both.)
|
||||||
|
|
||||||
## Step 6 — summary
|
The `check` output must be a JSON array with one message: `from: doctor-probe-a`, body matches. ✓ on success, ✗ otherwise.
|
||||||
|
|
||||||
|
## Step 8 — summary
|
||||||
|
|
||||||
```
|
```
|
||||||
Claude-Mailbox doctor
|
Claude-Mailbox doctor
|
||||||
|
node: <version>
|
||||||
binary: <version>
|
binary: <version>
|
||||||
daemon: Running (and what you did, if anything)
|
daemon: Running (port: <port>, what you did if anything)
|
||||||
health: ok
|
health: ok
|
||||||
|
port conflict: none | resolved (moved from 37849 to <port>)
|
||||||
base prefix: <name from settings, or "auto-derived (anonymous)">
|
base prefix: <name from settings, or "auto-derived (anonymous)">
|
||||||
smoke test: passed | failed
|
smoke test: passed | failed
|
||||||
restart hint: yes if restart_needed, otherwise no
|
restart hint: yes if restart_needed, otherwise no
|
||||||
@@ -87,6 +118,6 @@ Claude-Mailbox doctor
|
|||||||
|
|
||||||
End with one of:
|
End with one of:
|
||||||
|
|
||||||
- All ✓ and no restart needed → "Setup is healthy. Your mailbox name this session will be revealed by the SessionStart hook on next session start (or run `claude-mailbox list` to see active mailboxes)."
|
- All ✓ and no restart needed → "Setup is healthy. Your mailbox name this session will be revealed by the SessionStart hook on next session start."
|
||||||
- All ✓ and restart needed → "Restart Claude Code (or open a new session) so the SessionStart hook picks up the new base prefix."
|
- All ✓ and restart needed → "Restart Claude Code (or open a new session) so the SessionStart hook picks up the new env values."
|
||||||
- Anything ✗ → "Setup incomplete: <first failure>."
|
- Anything ✗ → "Setup incomplete: <first failure>."
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ Print exactly this block, filling in each line:
|
|||||||
Claude-Mailbox status
|
Claude-Mailbox status
|
||||||
binary: <output of `claude-mailbox --version`, or "not installed">
|
binary: <output of `claude-mailbox --version`, or "not installed">
|
||||||
daemon: <output of `claude-mailbox status`>
|
daemon: <output of `claude-mailbox status`>
|
||||||
health: <"ok" if GET http://127.0.0.1:47822/health returns 200, else "unreachable">
|
health: <"ok" if GET http://127.0.0.1:37849/health returns 200, else "unreachable">
|
||||||
mailbox name: <value of env.CLAUDE_MAILBOX_NAME in ./.claude/settings.json, or "unset"; also note if ~/.claude/settings.json has a value>
|
mailbox name: auto-derived per session as <project>-<short-session-id> (see SessionStart announcement)
|
||||||
pending: <integer count from `claude-mailbox peek --name <resolved-name>` if name is set, else "n/a">
|
pending: n/a (the session's mailbox name isn't known until SessionStart runs in this session's context)
|
||||||
```
|
```
|
||||||
|
|
||||||
End with one line:
|
End with one line:
|
||||||
|
|
||||||
- All good → `Status: OK`
|
- All good → `Status: OK`
|
||||||
- Missing daemon or unset name → `Status: Setup incomplete. Run /claude-mailbox:mailbox-doctor to fix.`
|
- Missing daemon → `Status: Setup incomplete. Run /claude-mailbox:mailbox-doctor to fix.`
|
||||||
- Daemon installed but stopped → `Status: Daemon is not running. Try \`claude-mailbox start\` or run /claude-mailbox:mailbox-doctor.`
|
- Daemon installed but stopped → `Status: Daemon is not running. Try \`claude-mailbox start\` or run /claude-mailbox:mailbox-doctor.`
|
||||||
|
|||||||
@@ -19,6 +19,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"SubagentStop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox check --hook"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ClaudeMailbox.Cli;
|
|||||||
|
|
||||||
public static class ClientCommands
|
public static class ClientCommands
|
||||||
{
|
{
|
||||||
private const string DefaultUrl = "http://127.0.0.1:47822";
|
private const string DefaultUrl = "http://127.0.0.1:37849";
|
||||||
|
|
||||||
public static async Task<int> RunAsync(string[] args)
|
public static async Task<int> RunAsync(string[] args)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public static class ServiceCommands
|
|||||||
if (!File.Exists(configPath))
|
if (!File.Exists(configPath))
|
||||||
{
|
{
|
||||||
var portStr = ClientCommands.GetOption(args, "--port");
|
var portStr = ClientCommands.GetOption(args, "--port");
|
||||||
var port = int.TryParse(portStr, out var p) ? p : 47822;
|
var port = int.TryParse(portStr, out var p) ? p : 37849;
|
||||||
var bind = ClientCommands.GetOption(args, "--bind") ?? "127.0.0.1";
|
var bind = ClientCommands.GetOption(args, "--bind") ?? "127.0.0.1";
|
||||||
var dbPath = ClientCommands.GetOption(args, "--db-path") ?? defaultDbPath;
|
var dbPath = ClientCommands.GetOption(args, "--db-path") ?? defaultDbPath;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace ClaudeMailbox.Config;
|
|||||||
|
|
||||||
public sealed class DaemonConfig
|
public sealed class DaemonConfig
|
||||||
{
|
{
|
||||||
public const int DefaultPort = 47822;
|
public const int DefaultPort = 37849;
|
||||||
public const string DefaultBindAddress = "127.0.0.1";
|
public const string DefaultBindAddress = "127.0.0.1";
|
||||||
|
|
||||||
public int Port { get; init; } = DefaultPort;
|
public int Port { get; init; } = DefaultPort;
|
||||||
|
|||||||
Reference in New Issue
Block a user