Fold daemonError into SessionAnnounceOptions so the helper owns the mutually-exclusive choice between peer list and daemon-down hint. Before this fix, a session-announce against an unreachable daemon emitted both "No other mailboxes seen within the last 60 minutes (0 total registered)." (misleading — the daemon was never asked) AND the daemon-unreachable hint. Now only the hint appears when the daemon is down. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
494 lines
17 KiB
JavaScript
494 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
import { Command } from "commander";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js";
|
|
import { autostartManager } from "./autostart/index.js";
|
|
import { runStdioMcp } from "./mcp-stdio.js";
|
|
import {
|
|
applyInstall,
|
|
applyUninstall,
|
|
buildHookCommand,
|
|
buildSessionAnnounceLines,
|
|
deriveSessionName,
|
|
formatMessagesForHook,
|
|
parseHookStdin,
|
|
readSettings,
|
|
readStdinIfPiped,
|
|
settingsPathFor,
|
|
writeSettings,
|
|
type HookMessage,
|
|
type HookScope,
|
|
type PeerEntry,
|
|
} from "./hook.js";
|
|
|
|
function readVersion(): string {
|
|
try {
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as {
|
|
version?: string;
|
|
};
|
|
return pkg.version ?? "unknown";
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
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(
|
|
method: string,
|
|
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;
|
|
}
|
|
|
|
function reportClientError(err: unknown, url: string): never {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.error(`Could not reach daemon at ${url}: ${msg}`);
|
|
console.error("Is 'claude-mailbox serve' running?");
|
|
process.exit(2);
|
|
}
|
|
|
|
const program = new Command();
|
|
program
|
|
.name("claude-mailbox")
|
|
.description("MCP mail server that lets parallel Claude sessions coordinate.")
|
|
.version(readVersion(), "-V, --version");
|
|
|
|
program
|
|
.command("serve")
|
|
.description("Run the daemon in the foreground.")
|
|
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
|
|
.option("--bind <address>", "Bind address")
|
|
.option("--db-path <path>", "SQLite database path")
|
|
.option("--config <path>", "Path to mailbox.json")
|
|
.option(
|
|
"--hide-after-minutes <n>",
|
|
"Hide mailboxes idle longer than N minutes from list responses (0 = disabled)",
|
|
(v) => parseInt(v, 10),
|
|
)
|
|
.option(
|
|
"--delete-after-minutes <n>",
|
|
"Hard-delete mailboxes idle longer than N minutes (0 = disabled)",
|
|
(v) => parseInt(v, 10),
|
|
)
|
|
.option(
|
|
"--sweep-interval-minutes <n>",
|
|
"Stale-mailbox sweep interval in minutes (0 = disabled)",
|
|
(v) => parseInt(v, 10),
|
|
)
|
|
.action(async (opts: {
|
|
port?: number;
|
|
bind?: string;
|
|
dbPath?: string;
|
|
config?: string;
|
|
hideAfterMinutes?: number;
|
|
deleteAfterMinutes?: number;
|
|
sweepIntervalMinutes?: number;
|
|
}) => {
|
|
const cfg = resolveConfig(opts);
|
|
try {
|
|
const { startServer } = await import("./server.js");
|
|
const { app } = await startServer(cfg);
|
|
app.log.info(`ClaudeMailbox listening on ${baseUrl(cfg)} (db: ${cfg.dbPath})`);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (/EADDRINUSE|already in use/i.test(msg)) {
|
|
console.error(
|
|
`Port ${cfg.port} is already in use. Another claude-mailbox instance may be running.`,
|
|
);
|
|
process.exit(3);
|
|
}
|
|
console.error(msg);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("send")
|
|
.description("Send a message via REST.")
|
|
.requiredOption("--to <name>", "Recipient mailbox")
|
|
.requiredOption("--from <name>", "Sender mailbox (X-Mailbox header)")
|
|
.requiredOption("--body <text>", "Message body")
|
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
|
.action(async (opts: { to: string; from: string; body: string; url: string }) => {
|
|
try {
|
|
const out = await callJson("POST", `${opts.url}/v1/send`, {
|
|
headers: { "X-Mailbox": opts.from },
|
|
body: { to: opts.to, body: opts.body },
|
|
});
|
|
console.log(JSON.stringify(out, null, 2));
|
|
} catch (err) {
|
|
reportClientError(err, opts.url);
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("peek")
|
|
.description("Non-consuming inbox status.")
|
|
.requiredOption("--name <name>", "Mailbox name")
|
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
|
.action(async (opts: { name: string; url: string }) => {
|
|
try {
|
|
const out = await callJson(
|
|
"GET",
|
|
`${opts.url}/v1/peek?name=${encodeURIComponent(opts.name)}`,
|
|
);
|
|
console.log(JSON.stringify(out, null, 2));
|
|
} catch (err) {
|
|
reportClientError(err, opts.url);
|
|
}
|
|
});
|
|
|
|
function resolveHookMailboxName(explicit: string | undefined): string | null {
|
|
if (explicit && explicit.trim()) return explicit.trim();
|
|
const stdin = parseHookStdin(readStdinIfPiped());
|
|
const sid = stdin?.session_id?.trim();
|
|
if (!sid) return null;
|
|
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
|
return deriveSessionName(sid, cwd);
|
|
}
|
|
|
|
program
|
|
.command("check")
|
|
.description(
|
|
"Pull pending messages and mark delivered. In --hook mode the name is auto-derived from the SessionStart/UserPromptSubmit stdin: <project>-<session-short>, where <project> is the git-repo or cwd basename from stdin.",
|
|
)
|
|
.option(
|
|
"--name <name>",
|
|
"Explicit mailbox name. Overrides hook stdin auto-derivation.",
|
|
)
|
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
|
.option(
|
|
"--hook",
|
|
"Hook mode: human-readable output, silent when no mailbox configured or daemon unreachable. Emits a one-line setup hint when name resolves but daemon is unreachable.",
|
|
)
|
|
.action(async (opts: { name?: string; url: string; hook?: boolean }) => {
|
|
const name = opts.hook
|
|
? resolveHookMailboxName(opts.name)
|
|
: (opts.name ?? "").trim() || null;
|
|
if (!name) {
|
|
if (opts.hook) return;
|
|
console.error("Missing --name.");
|
|
process.exit(1);
|
|
}
|
|
try {
|
|
const out = await callJson(
|
|
"POST",
|
|
`${opts.url}/v1/check-inbox?name=${encodeURIComponent(name)}`,
|
|
{ headers: { "X-Mailbox": name } },
|
|
);
|
|
if (opts.hook) {
|
|
const messages = (Array.isArray(out) ? out : []) as HookMessage[];
|
|
const text = formatMessagesForHook(name, messages);
|
|
if (text) process.stdout.write(text);
|
|
return;
|
|
}
|
|
console.log(JSON.stringify(out, null, 2));
|
|
} catch (err) {
|
|
if (opts.hook) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
|
|
process.stdout.write(
|
|
`[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.\n`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
reportClientError(err, opts.url);
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("session-announce")
|
|
.description(
|
|
"SessionStart-hook helper: derives the session's mailbox name from stdin session_id, registers it with the daemon, and announces the identity + currently active peers to context.",
|
|
)
|
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
|
.option(
|
|
"--peer-window-minutes <minutes>",
|
|
"Only show peers seen within this many minutes (default 60)",
|
|
(v) => parseInt(v, 10),
|
|
60,
|
|
)
|
|
.option(
|
|
"--max-peers <n>",
|
|
"Maximum number of peers to list (default 10)",
|
|
(v) => parseInt(v, 10),
|
|
10,
|
|
)
|
|
.action(async (opts: { url: string; peerWindowMinutes: number; maxPeers: number }) => {
|
|
const stdin = parseHookStdin(readStdinIfPiped());
|
|
const sid = stdin?.session_id?.trim();
|
|
if (!sid) return;
|
|
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
|
const name = deriveSessionName(sid, cwd);
|
|
|
|
let peers: PeerEntry[] = [];
|
|
let daemonError: string | null = null;
|
|
try {
|
|
const out = await callJson("GET", `${opts.url}/v1/list`, {
|
|
headers: { "X-Mailbox": name },
|
|
});
|
|
peers = (Array.isArray(out) ? out : []) as PeerEntry[];
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
|
|
daemonError = `[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`;
|
|
}
|
|
}
|
|
|
|
const lines = buildSessionAnnounceLines({
|
|
name,
|
|
peers,
|
|
windowMinutes: opts.peerWindowMinutes,
|
|
maxPeers: opts.maxPeers,
|
|
watcherCommand: `claude-mailbox watch --block --name ${name}`,
|
|
daemonError: daemonError ?? undefined,
|
|
});
|
|
lines.push("");
|
|
process.stdout.write(lines.join("\n"));
|
|
});
|
|
|
|
program
|
|
.command("list")
|
|
.description("List known mailboxes.")
|
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
|
.action(async (opts: { url: string }) => {
|
|
try {
|
|
const out = await callJson("GET", `${opts.url}/v1/list`);
|
|
console.log(JSON.stringify(out, null, 2));
|
|
} catch (err) {
|
|
reportClientError(err, opts.url);
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("watch")
|
|
.description(
|
|
"Block until one message arrives for --name, print it, and exit. Designed to be run as a Claude Code background bash task so its output surfaces via BashOutput.",
|
|
)
|
|
.requiredOption("--name <name>", "Mailbox to watch")
|
|
.option("--block", "Long-poll for a message (default behavior; flag accepted for clarity)")
|
|
.option(
|
|
"--timeout <seconds>",
|
|
"Long-poll timeout in seconds (1..300, default 25)",
|
|
(v) => parseInt(v, 10),
|
|
25,
|
|
)
|
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
|
.action(async (opts: { name: string; block?: boolean; timeout: number; url: string }) => {
|
|
const url = `${opts.url}/v1/watch?name=${encodeURIComponent(opts.name)}&timeout=${opts.timeout}`;
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, { headers: { "X-Mailbox": opts.name, Accept: "application/json" } });
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.error(`Could not reach daemon at ${opts.url}: ${msg}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
if (res.status === 204) {
|
|
process.exit(3);
|
|
}
|
|
|
|
if (res.status === 200) {
|
|
const body = (await res.json()) as { from: string; body: string; sentAt: string };
|
|
process.stdout.write(`[Claude-Mailbox] Mail from ${body.from}:\n${body.body}\n`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (res.status === 409) {
|
|
const body = (await res.json().catch(() => ({}))) as { to?: string };
|
|
const newName = body.to ?? "<unknown>";
|
|
process.stdout.write(
|
|
`[Claude-Mailbox] Mailbox renamed to '${newName}'. Restart watcher with --name ${newName}.\n`,
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
const text = await res.text().catch(() => "");
|
|
console.error(`watch failed: HTTP ${res.status}${text ? ` — ${text}` : ""}`);
|
|
process.exit(1);
|
|
});
|
|
|
|
program
|
|
.command("mcp-stdio")
|
|
.description(
|
|
"Run a stdio MCP server that proxies tool calls to the local daemon's REST API. The daemon URL comes from $CLAUDE_MAILBOX_URL (default http://127.0.0.1:37849). Used by the Claude Code plugin's .mcp.json so the URL is configurable per machine without env-substitution in the URL field.",
|
|
)
|
|
.action(async () => {
|
|
try {
|
|
await runStdioMcp();
|
|
} catch (err) {
|
|
console.error(err instanceof Error ? err.message : String(err));
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("install-hook")
|
|
.description(
|
|
"Install a Claude Code UserPromptSubmit hook that checks the mailbox on every prompt. Idempotent.",
|
|
)
|
|
.requiredOption("--name <name>", "Mailbox name to check")
|
|
.option("--user", "Patch ~/.claude/settings.json (default)")
|
|
.option("--project", "Patch <cwd>/.claude/settings.json")
|
|
.option("--url <url>", "Daemon base URL to embed in the hook command")
|
|
.action(async (opts: { name: string; user?: boolean; project?: boolean; url?: string }) => {
|
|
if (opts.user && opts.project) {
|
|
console.error("Pick either --user or --project, not both.");
|
|
process.exit(1);
|
|
}
|
|
const scope: HookScope = opts.project ? "project" : "user";
|
|
const path = settingsPathFor(scope);
|
|
const settings = readSettings(path);
|
|
const command = buildHookCommand(opts.name, opts.url);
|
|
const result = applyInstall(settings, command);
|
|
if (result.changed) {
|
|
writeSettings(path, settings);
|
|
console.log(`Hook installed in ${path}`);
|
|
console.log(`Command: ${command}`);
|
|
} else {
|
|
console.log(`Hook already present in ${path}; nothing to do.`);
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("uninstall-hook")
|
|
.description("Remove the claude-mailbox UserPromptSubmit hook from Claude Code settings.")
|
|
.option("--user", "Patch ~/.claude/settings.json (default)")
|
|
.option("--project", "Patch <cwd>/.claude/settings.json")
|
|
.action(async (opts: { user?: boolean; project?: boolean }) => {
|
|
if (opts.user && opts.project) {
|
|
console.error("Pick either --user or --project, not both.");
|
|
process.exit(1);
|
|
}
|
|
const scope: HookScope = opts.project ? "project" : "user";
|
|
const path = settingsPathFor(scope);
|
|
if (!existsSync(path)) {
|
|
console.log(`No settings file at ${path}; nothing to remove.`);
|
|
return;
|
|
}
|
|
const settings = readSettings(path);
|
|
const result = applyUninstall(settings);
|
|
if (result.changed) {
|
|
writeSettings(path, settings);
|
|
console.log(`Hook removed from ${path}`);
|
|
} else {
|
|
console.log(`No claude-mailbox hook found in ${path}; nothing to remove.`);
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("install-autostart")
|
|
.description(
|
|
"Register autostart for the current OS (Scheduled Task / launchd / systemd-user). Use --service on Windows for a Windows Service (admin).",
|
|
)
|
|
.option("--service", "Windows: install as a Windows Service (requires admin) instead of a Scheduled Task")
|
|
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
|
|
.option("--bind <address>", "Bind address")
|
|
.option("--db-path <path>", "SQLite database path")
|
|
.option(
|
|
"--skip-port-check",
|
|
"Skip the pre-install probe for a foreign occupant on the daemon's port",
|
|
)
|
|
.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
|
|
.command("uninstall-autostart")
|
|
.description("Remove autostart for the current OS.")
|
|
.option("--service", "Windows: uninstall the Windows Service variant")
|
|
.option("--purge", "Also delete database and config")
|
|
.action(async (opts: { service?: boolean; purge?: boolean }) => {
|
|
const mgr = await autostartManager(opts.service ? "service" : "default");
|
|
await mgr.uninstall(!!opts.purge);
|
|
console.log("Autostart removed.");
|
|
});
|
|
|
|
program
|
|
.command("start")
|
|
.description("Start the autostart-managed daemon.")
|
|
.option("--service", "Windows: target the Windows Service variant")
|
|
.action(async (opts: { service?: boolean }) => {
|
|
const mgr = await autostartManager(opts.service ? "service" : "default");
|
|
await mgr.start();
|
|
});
|
|
|
|
program
|
|
.command("stop")
|
|
.description("Stop the autostart-managed daemon.")
|
|
.option("--service", "Windows: target the Windows Service variant")
|
|
.action(async (opts: { service?: boolean }) => {
|
|
const mgr = await autostartManager(opts.service ? "service" : "default");
|
|
await mgr.stop();
|
|
});
|
|
|
|
program
|
|
.command("status")
|
|
.description("Print autostart status (Running | Stopped | NotInstalled).")
|
|
.option("--service", "Windows: target the Windows Service variant")
|
|
.action(async (opts: { service?: boolean }) => {
|
|
const mgr = await autostartManager(opts.service ? "service" : "default");
|
|
console.log(await mgr.status());
|
|
});
|
|
|
|
program.parseAsync(process.argv).catch((err) => {
|
|
console.error(err instanceof Error ? err.message : String(err));
|
|
process.exit(1);
|
|
});
|