Two production-readiness fixes so colleagues can install cleanly: 1. Plugin's MCP server now spawns `claude-mailbox mcp-stdio`, a small stdio MCP wrapper that proxies tool calls to the daemon's REST API. Claude Code does not support env-var substitution in HTTP MCP `url` fields (issue #46889), so the wrapper is the only way to make the daemon URL configurable per machine via CLAUDE_MAILBOX_URL. 2. Windows `install-autostart` now falls back from `schtasks /Create` to an HKCU\Software\Microsoft\Windows\CurrentVersion\Run entry when Group Policy blocks the Scheduled Task path. Both modes are per-user, no admin, persist across logoffs. The chosen mode is recorded in ~/.claude-mailbox/autostart-mode so status/start/stop/ uninstall-autostart pick the right cleanup path. Also bumps the npm version to 1.0.1 to align with the published 1.0.0 plus this patch.
354 lines
10 KiB
TypeScript
354 lines
10 KiB
TypeScript
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.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 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 {
|
|
const path = userConfigPath();
|
|
if (!existsSync(path)) {
|
|
mkdirSync(join(path, ".."), { recursive: true });
|
|
const seed: Record<string, unknown> = {};
|
|
if (opts.port !== undefined) seed.port = opts.port;
|
|
if (opts.bind !== undefined) seed.bind = opts.bind;
|
|
if (opts.dbPath !== undefined) seed.dbPath = opts.dbPath;
|
|
writeFileSync(path, JSON.stringify(seed, null, 2) + "\n", "utf8");
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function buildServeCommand(): { node: string; script: string; configPath: string } {
|
|
const { node, script } = cliEntry();
|
|
return { node, script, configPath: userConfigPath() };
|
|
}
|
|
|
|
function buildServeCommandString(configPath: string): string {
|
|
const { node, script } = buildServeCommand();
|
|
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", [
|
|
"/Create",
|
|
"/SC",
|
|
"ONLOGON",
|
|
"/TN",
|
|
TASK_NAME,
|
|
"/TR",
|
|
tr,
|
|
"/RL",
|
|
"LIMITED",
|
|
"/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) {
|
|
throw new Error(`reg add (HKCU Run) failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
|
}
|
|
spawnRunKeyDaemon(configPath);
|
|
}
|
|
|
|
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 {
|
|
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]);
|
|
const r = run("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"]);
|
|
if (r.status !== 0 && !/cannot find/i.test(r.stderr) && !/does not exist/i.test(r.stderr)) {
|
|
// 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();
|
|
}
|
|
|
|
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"]);
|
|
if (r.status !== 0) {
|
|
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";
|
|
}
|
|
if (/Status:\s*Running/i.test(r.stdout)) return "Running";
|
|
return "Stopped";
|
|
}
|
|
|
|
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]);
|
|
if (r.status !== 0) throw new Error(`schtasks /Run failed: ${r.stderr || r.stdout}`);
|
|
}
|
|
|
|
function scheduledTaskEnd(): void {
|
|
const mode = readActiveMode();
|
|
if (mode === "run-key") {
|
|
killRunKeyDaemon();
|
|
return;
|
|
}
|
|
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
|
|
}
|
|
|
|
interface NodeWindowsService {
|
|
on(event: "install" | "uninstall" | "alreadyinstalled" | "start" | "stop", cb: () => void): void;
|
|
install(): void;
|
|
uninstall(): void;
|
|
start(): void;
|
|
stop(): void;
|
|
exists?: boolean;
|
|
}
|
|
|
|
interface NodeWindowsModule {
|
|
Service: new (opts: {
|
|
name: string;
|
|
description?: string;
|
|
script: string;
|
|
nodeOptions?: string[];
|
|
workingDirectory?: string;
|
|
}) => NodeWindowsService;
|
|
}
|
|
|
|
async function loadNodeWindows(): Promise<NodeWindowsModule> {
|
|
try {
|
|
return (await import("node-windows")) as unknown as NodeWindowsModule;
|
|
} catch (err) {
|
|
throw new Error(
|
|
"node-windows is not installed. Install it with `npm i -g node-windows` or use the default Scheduled Task autostart instead.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function isAdministrator(): boolean {
|
|
const r = run("net.exe", ["session"]);
|
|
return r.status === 0;
|
|
}
|
|
|
|
async function serviceInstall(opts: AutostartInstallOpts): Promise<void> {
|
|
if (!isAdministrator()) {
|
|
throw new Error("install-autostart --service requires an Administrator shell.");
|
|
}
|
|
ensureConfigSeeded(opts);
|
|
const { script, configPath } = buildServeCommand();
|
|
const nw = await loadNodeWindows();
|
|
await new Promise<void>((resolveFn, rejectFn) => {
|
|
const svc = new nw.Service({
|
|
name: SERVICE_NAME,
|
|
description: "ClaudeMailbox MCP mail daemon for parallel Claude session coordination.",
|
|
script,
|
|
nodeOptions: [],
|
|
});
|
|
svc.on("install", () => {
|
|
svc.start();
|
|
resolveFn();
|
|
});
|
|
svc.on("alreadyinstalled", () => resolveFn());
|
|
try {
|
|
svc.install();
|
|
} catch (e) {
|
|
rejectFn(e);
|
|
}
|
|
void configPath;
|
|
});
|
|
}
|
|
|
|
async function serviceUninstall(purge: boolean): Promise<void> {
|
|
if (!isAdministrator()) {
|
|
throw new Error("uninstall-autostart --service requires an Administrator shell.");
|
|
}
|
|
const { script } = buildServeCommand();
|
|
const nw = await loadNodeWindows();
|
|
await new Promise<void>((resolveFn, rejectFn) => {
|
|
const svc = new nw.Service({ name: SERVICE_NAME, script });
|
|
svc.on("uninstall", () => resolveFn());
|
|
try {
|
|
svc.uninstall();
|
|
} catch (e) {
|
|
rejectFn(e);
|
|
}
|
|
});
|
|
if (purge) purgeData();
|
|
}
|
|
|
|
function serviceStatus(): "Running" | "Stopped" | "NotInstalled" {
|
|
const r = run("sc.exe", ["query", SERVICE_NAME]);
|
|
if (r.status !== 0) return "NotInstalled";
|
|
if (/STATE\s*:\s*\d+\s+RUNNING/i.test(r.stdout)) return "Running";
|
|
return "Stopped";
|
|
}
|
|
|
|
function serviceStart(): void {
|
|
const r = run("sc.exe", ["start", SERVICE_NAME]);
|
|
if (r.status !== 0) throw new Error(`sc start failed: ${r.stderr || r.stdout}`);
|
|
}
|
|
|
|
function serviceStop(): void {
|
|
const r = run("sc.exe", ["stop", SERVICE_NAME]);
|
|
if (r.status !== 0 && !/has not been started/i.test(r.stdout)) {
|
|
throw new Error(`sc stop failed: ${r.stderr || r.stdout}`);
|
|
}
|
|
}
|
|
|
|
function purgeData(): void {
|
|
const cfg = userConfigPath();
|
|
if (existsSync(cfg)) {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as { dbPath?: string };
|
|
void parsed;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
export function windowsManager(mode: "default" | "service"): AutostartManager {
|
|
if (mode === "service") {
|
|
return {
|
|
mode,
|
|
install: serviceInstall,
|
|
uninstall: serviceUninstall,
|
|
async start() {
|
|
serviceStart();
|
|
},
|
|
async stop() {
|
|
serviceStop();
|
|
},
|
|
async status() {
|
|
return serviceStatus();
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
mode,
|
|
async install(opts) {
|
|
scheduledTaskInstall(opts);
|
|
},
|
|
async uninstall(purge) {
|
|
scheduledTaskUninstall(purge);
|
|
},
|
|
async start() {
|
|
scheduledTaskRun();
|
|
},
|
|
async stop() {
|
|
scheduledTaskEnd();
|
|
},
|
|
async status() {
|
|
return scheduledTaskStatus();
|
|
},
|
|
};
|
|
}
|