Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75a180279e | ||
|
|
9438b1d8dc | ||
|
|
f4539eb2c9 | ||
|
|
4b93641cf4 | ||
|
|
2cadc3a867 | ||
|
|
0c06e2cf4b | ||
|
|
06a2ea6b7b | ||
|
|
01c22ff9a3 |
4
node/package-lock.json
generated
4
node/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "1.4.0",
|
"version": "1.5.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "1.4.0",
|
"version": "1.5.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "1.4.0",
|
"version": "1.5.2",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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 { runStdioMcp } from "./mcp-stdio.js";
|
||||||
import {
|
import {
|
||||||
@@ -79,9 +78,33 @@ program
|
|||||||
.option("--bind <address>", "Bind address")
|
.option("--bind <address>", "Bind address")
|
||||||
.option("--db-path <path>", "SQLite database path")
|
.option("--db-path <path>", "SQLite database path")
|
||||||
.option("--config <path>", "Path to mailbox.json")
|
.option("--config <path>", "Path to mailbox.json")
|
||||||
.action(async (opts: { port?: number; bind?: string; dbPath?: string; config?: string }) => {
|
.option(
|
||||||
|
"--hide-after-minutes <n>",
|
||||||
|
"Hide mailboxes idle longer than N minutes from list responses (0 = disabled)",
|
||||||
|
(v) => parseInt(v, 10),
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--delete-after-minutes <n>",
|
||||||
|
"Hard-delete mailboxes idle longer than N minutes (0 = disabled)",
|
||||||
|
(v) => parseInt(v, 10),
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--sweep-interval-minutes <n>",
|
||||||
|
"Stale-mailbox sweep interval in minutes (0 = disabled)",
|
||||||
|
(v) => parseInt(v, 10),
|
||||||
|
)
|
||||||
|
.action(async (opts: {
|
||||||
|
port?: number;
|
||||||
|
bind?: string;
|
||||||
|
dbPath?: string;
|
||||||
|
config?: string;
|
||||||
|
hideAfterMinutes?: number;
|
||||||
|
deleteAfterMinutes?: number;
|
||||||
|
sweepIntervalMinutes?: number;
|
||||||
|
}) => {
|
||||||
const cfg = resolveConfig(opts);
|
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) {
|
||||||
|
|||||||
@@ -4,17 +4,26 @@ import { join, resolve } from "node:path";
|
|||||||
|
|
||||||
export const DEFAULT_PORT = 37849;
|
export const DEFAULT_PORT = 37849;
|
||||||
export const DEFAULT_BIND = "127.0.0.1";
|
export const DEFAULT_BIND = "127.0.0.1";
|
||||||
|
export const DEFAULT_HIDE_AFTER_MINUTES = 60 * 24;
|
||||||
|
export const DEFAULT_DELETE_AFTER_MINUTES = 60 * 24 * 7;
|
||||||
|
export const DEFAULT_SWEEP_INTERVAL_MINUTES = 60;
|
||||||
|
|
||||||
export interface FileConfig {
|
export interface FileConfig {
|
||||||
port?: number;
|
port?: number;
|
||||||
bind?: string;
|
bind?: string;
|
||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
|
hideAfterMinutes?: number;
|
||||||
|
deleteAfterMinutes?: number;
|
||||||
|
sweepIntervalMinutes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DaemonConfig {
|
export interface DaemonConfig {
|
||||||
port: number;
|
port: number;
|
||||||
bind: string;
|
bind: string;
|
||||||
dbPath: string;
|
dbPath: string;
|
||||||
|
hideAfterMinutes: number;
|
||||||
|
deleteAfterMinutes: number;
|
||||||
|
sweepIntervalMinutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultDbPath(): string {
|
export function defaultDbPath(): string {
|
||||||
@@ -65,6 +74,12 @@ export function loadFileConfig(explicitPath?: string): FileConfig {
|
|||||||
port: typeof parsed.port === "number" ? parsed.port : undefined,
|
port: typeof parsed.port === "number" ? parsed.port : undefined,
|
||||||
bind: typeof parsed.bind === "string" ? parsed.bind : undefined,
|
bind: typeof parsed.bind === "string" ? parsed.bind : undefined,
|
||||||
dbPath: typeof parsed.dbPath === "string" ? parsed.dbPath : undefined,
|
dbPath: typeof parsed.dbPath === "string" ? parsed.dbPath : undefined,
|
||||||
|
hideAfterMinutes:
|
||||||
|
typeof parsed.hideAfterMinutes === "number" ? parsed.hideAfterMinutes : undefined,
|
||||||
|
deleteAfterMinutes:
|
||||||
|
typeof parsed.deleteAfterMinutes === "number" ? parsed.deleteAfterMinutes : undefined,
|
||||||
|
sweepIntervalMinutes:
|
||||||
|
typeof parsed.sweepIntervalMinutes === "number" ? parsed.sweepIntervalMinutes : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +91,9 @@ export interface ServeOverrides {
|
|||||||
bind?: string;
|
bind?: string;
|
||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
config?: string;
|
config?: string;
|
||||||
|
hideAfterMinutes?: number;
|
||||||
|
deleteAfterMinutes?: number;
|
||||||
|
sweepIntervalMinutes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
|
export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
|
||||||
@@ -83,7 +101,20 @@ export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
|
|||||||
const port = overrides.port ?? file.port ?? DEFAULT_PORT;
|
const port = overrides.port ?? file.port ?? DEFAULT_PORT;
|
||||||
const bind = overrides.bind ?? file.bind ?? DEFAULT_BIND;
|
const bind = overrides.bind ?? file.bind ?? DEFAULT_BIND;
|
||||||
const dbPathRaw = overrides.dbPath ?? file.dbPath ?? defaultDbPath();
|
const dbPathRaw = overrides.dbPath ?? file.dbPath ?? defaultDbPath();
|
||||||
return { port, bind, dbPath: expandPath(dbPathRaw) };
|
const hideAfterMinutes =
|
||||||
|
overrides.hideAfterMinutes ?? file.hideAfterMinutes ?? DEFAULT_HIDE_AFTER_MINUTES;
|
||||||
|
const deleteAfterMinutes =
|
||||||
|
overrides.deleteAfterMinutes ?? file.deleteAfterMinutes ?? DEFAULT_DELETE_AFTER_MINUTES;
|
||||||
|
const sweepIntervalMinutes =
|
||||||
|
overrides.sweepIntervalMinutes ?? file.sweepIntervalMinutes ?? DEFAULT_SWEEP_INTERVAL_MINUTES;
|
||||||
|
return {
|
||||||
|
port,
|
||||||
|
bind,
|
||||||
|
dbPath: expandPath(dbPathRaw),
|
||||||
|
hideAfterMinutes,
|
||||||
|
deleteAfterMinutes,
|
||||||
|
sweepIntervalMinutes,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function baseUrl(cfg: { port: number; bind: string }): string {
|
export function baseUrl(cfg: { port: number; bind: string }): string {
|
||||||
|
|||||||
@@ -90,12 +90,17 @@ export class MailboxStore {
|
|||||||
insertMailbox: StatementSync;
|
insertMailbox: StatementSync;
|
||||||
touchMailbox: StatementSync;
|
touchMailbox: StatementSync;
|
||||||
listMailboxes: StatementSync;
|
listMailboxes: StatementSync;
|
||||||
|
listMailboxesFiltered: StatementSync;
|
||||||
|
listMailboxesFilteredAnon: StatementSync;
|
||||||
insertMessage: StatementSync;
|
insertMessage: StatementSync;
|
||||||
countPending: StatementSync;
|
countPending: StatementSync;
|
||||||
oldestPending: StatementSync;
|
oldestPending: StatementSync;
|
||||||
selectPending: StatementSync;
|
selectPending: StatementSync;
|
||||||
markDelivered: StatementSync;
|
markDelivered: StatementSync;
|
||||||
pendingByRecipient: StatementSync;
|
pendingByRecipient: StatementSync;
|
||||||
|
findStaleCandidates: StatementSync;
|
||||||
|
deleteMessagesForNames: StatementSync;
|
||||||
|
deleteMailboxesByNames: StatementSync;
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(public readonly dbPath: string) {
|
constructor(public readonly dbPath: string) {
|
||||||
@@ -112,6 +117,19 @@ export class MailboxStore {
|
|||||||
),
|
),
|
||||||
touchMailbox: this.db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?"),
|
touchMailbox: this.db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?"),
|
||||||
listMailboxes: this.db.prepare("SELECT * FROM mailboxes ORDER BY name"),
|
listMailboxes: this.db.prepare("SELECT * FROM mailboxes ORDER BY name"),
|
||||||
|
listMailboxesFiltered: this.db.prepare(
|
||||||
|
`SELECT * FROM mailboxes
|
||||||
|
WHERE last_seen_at >= ?
|
||||||
|
OR name = ?
|
||||||
|
OR name IN (
|
||||||
|
SELECT DISTINCT from_mailbox FROM messages
|
||||||
|
WHERE to_mailbox = ? AND delivered_at IS NULL
|
||||||
|
)
|
||||||
|
ORDER BY name`,
|
||||||
|
),
|
||||||
|
listMailboxesFilteredAnon: this.db.prepare(
|
||||||
|
"SELECT * FROM mailboxes WHERE last_seen_at >= ? ORDER BY name",
|
||||||
|
),
|
||||||
insertMessage: this.db.prepare(
|
insertMessage: this.db.prepare(
|
||||||
"INSERT INTO messages (to_mailbox, from_mailbox, body, created_at, delivered_at) VALUES (?, ?, ?, ?, NULL)",
|
"INSERT INTO messages (to_mailbox, from_mailbox, body, created_at, delivered_at) VALUES (?, ?, ?, ?, NULL)",
|
||||||
),
|
),
|
||||||
@@ -130,6 +148,20 @@ export class MailboxStore {
|
|||||||
pendingByRecipient: this.db.prepare(
|
pendingByRecipient: this.db.prepare(
|
||||||
"SELECT to_mailbox, COUNT(*) AS n FROM messages WHERE delivered_at IS NULL GROUP BY to_mailbox",
|
"SELECT to_mailbox, COUNT(*) AS n FROM messages WHERE delivered_at IS NULL GROUP BY to_mailbox",
|
||||||
),
|
),
|
||||||
|
findStaleCandidates: this.db.prepare(
|
||||||
|
`SELECT name FROM mailboxes
|
||||||
|
WHERE last_seen_at < ?
|
||||||
|
AND name NOT IN (SELECT to_mailbox FROM messages WHERE delivered_at IS NULL)
|
||||||
|
AND name NOT IN (SELECT from_mailbox FROM messages WHERE delivered_at IS NULL)`,
|
||||||
|
),
|
||||||
|
deleteMessagesForNames: this.db.prepare(
|
||||||
|
`DELETE FROM messages
|
||||||
|
WHERE to_mailbox IN (SELECT value FROM json_each(?))
|
||||||
|
OR from_mailbox IN (SELECT value FROM json_each(?))`,
|
||||||
|
),
|
||||||
|
deleteMailboxesByNames: this.db.prepare(
|
||||||
|
"DELETE FROM mailboxes WHERE name IN (SELECT value FROM json_each(?))",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,8 +236,23 @@ export class MailboxStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
listMailboxes(forName?: string): MailboxInfo[] {
|
listMailboxes(forName?: string, options?: { hideAfterMinutes?: number }): MailboxInfo[] {
|
||||||
const rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[];
|
const hideAfterMinutes = options?.hideAfterMinutes;
|
||||||
|
let rows: MailboxRow[];
|
||||||
|
if (hideAfterMinutes != null && hideAfterMinutes > 0) {
|
||||||
|
const cutoff = new Date(Date.now() - hideAfterMinutes * 60_000).toISOString();
|
||||||
|
if (forName) {
|
||||||
|
rows = this.stmts.listMailboxesFiltered.all(
|
||||||
|
cutoff,
|
||||||
|
forName,
|
||||||
|
forName,
|
||||||
|
) as unknown as MailboxRow[];
|
||||||
|
} else {
|
||||||
|
rows = this.stmts.listMailboxesFilteredAnon.all(cutoff) as unknown as MailboxRow[];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[];
|
||||||
|
}
|
||||||
const pendingMap = new Map<string, number>();
|
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 }[];
|
||||||
@@ -217,6 +264,22 @@ export class MailboxStore {
|
|||||||
pendingForYou: forName ? (pendingMap.get(forName) ?? 0) : 0,
|
pendingForYou: forName ? (pendingMap.get(forName) ?? 0) : 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pruneStale(deleteAfterMinutes: number): { deletedMailboxes: number; deletedMessages: number } {
|
||||||
|
if (deleteAfterMinutes <= 0) return { deletedMailboxes: 0, deletedMessages: 0 };
|
||||||
|
const cutoff = new Date(Date.now() - deleteAfterMinutes * 60_000).toISOString();
|
||||||
|
return runInTransaction(this.db, () => {
|
||||||
|
const candidates = this.stmts.findStaleCandidates.all(cutoff) as { name: string }[];
|
||||||
|
if (candidates.length === 0) return { deletedMailboxes: 0, deletedMessages: 0 };
|
||||||
|
const namesJson = JSON.stringify(candidates.map((c) => c.name));
|
||||||
|
const msgResult = this.stmts.deleteMessagesForNames.run(namesJson, namesJson);
|
||||||
|
const mbxResult = this.stmts.deleteMailboxesByNames.run(namesJson);
|
||||||
|
return {
|
||||||
|
deletedMailboxes: Number(mbxResult.changes ?? 0),
|
||||||
|
deletedMessages: Number(msgResult.changes ?? 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowToMessage(r: MessageRow): {
|
export function rowToMessage(r: MessageRow): {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function resolveIdentity(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMcpServer(store: MailboxStore): McpServer {
|
function buildMcpServer(store: MailboxStore, hideAfterMinutes: number): McpServer {
|
||||||
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
|
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
@@ -129,7 +129,7 @@ function buildMcpServer(store: MailboxStore): McpServer {
|
|||||||
},
|
},
|
||||||
async ({ name }, extra) => {
|
async ({ name }, extra) => {
|
||||||
const me = resolveIdentity(name, extra, "name");
|
const me = resolveIdentity(name, extra, "name");
|
||||||
const list = store.listMailboxes(me).map((m) => ({
|
const list = store.listMailboxes(me, { hideAfterMinutes }).map((m) => ({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
lastSeenAt: m.lastSeenAt.toISOString(),
|
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||||
pendingForYou: m.pendingForYou,
|
pendingForYou: m.pendingForYou,
|
||||||
@@ -180,8 +180,12 @@ function buildMcpServer(store: MailboxStore): McpServer {
|
|||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerMcp(app: FastifyInstance, store: MailboxStore): Promise<void> {
|
export async function registerMcp(
|
||||||
const mcpServer = buildMcpServer(store);
|
app: FastifyInstance,
|
||||||
|
store: MailboxStore,
|
||||||
|
hideAfterMinutes: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const mcpServer = buildMcpServer(store, hideAfterMinutes);
|
||||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||||
await mcpServer.connect(transport);
|
await mcpServer.connect(transport);
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
|
|||||||
|
|
||||||
app.get("/v1/list", async (req) => {
|
app.get("/v1/list", async (req) => {
|
||||||
const name = req.mailboxName;
|
const name = req.mailboxName;
|
||||||
return store.listMailboxes(name).map((m) => ({
|
return store
|
||||||
|
.listMailboxes(name, { hideAfterMinutes: cfg.hideAfterMinutes })
|
||||||
|
.map((m) => ({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
lastSeenAt: m.lastSeenAt.toISOString(),
|
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||||
pendingForYou: m.pendingForYou,
|
pendingForYou: m.pendingForYou,
|
||||||
@@ -119,14 +121,46 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await registerMcp(app, store);
|
await registerMcp(app, store, cfg.hideAfterMinutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startServer(cfg: DaemonConfig): Promise<{ app: FastifyInstance; store: MailboxStore }> {
|
function startSweep(
|
||||||
|
store: MailboxStore,
|
||||||
|
cfg: DaemonConfig,
|
||||||
|
log: FastifyInstance["log"],
|
||||||
|
): NodeJS.Timeout | null {
|
||||||
|
if (cfg.sweepIntervalMinutes <= 0 || cfg.deleteAfterMinutes <= 0) return null;
|
||||||
|
const runOnce = (): void => {
|
||||||
|
try {
|
||||||
|
const r = store.pruneStale(cfg.deleteAfterMinutes);
|
||||||
|
if (r.deletedMailboxes > 0 || r.deletedMessages > 0) {
|
||||||
|
log.info(
|
||||||
|
r,
|
||||||
|
`Pruned ${r.deletedMailboxes} stale mailbox(es) and ${r.deletedMessages} delivered message(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "Stale-mailbox sweep failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
runOnce();
|
||||||
|
const timer = setInterval(runOnce, cfg.sweepIntervalMinutes * 60_000);
|
||||||
|
timer.unref?.();
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startServer(
|
||||||
|
cfg: DaemonConfig,
|
||||||
|
): Promise<{ app: FastifyInstance; store: MailboxStore; sweepTimer: NodeJS.Timeout | null }> {
|
||||||
const store = new MailboxStore(cfg.dbPath);
|
const store = new MailboxStore(cfg.dbPath);
|
||||||
const app = await buildServer(cfg, store);
|
const app = await buildServer(cfg, store);
|
||||||
|
const timerRef: { current: NodeJS.Timeout | null } = { current: null };
|
||||||
|
app.addHook("onClose", async () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
});
|
||||||
await app.listen({ host: cfg.bind, port: cfg.port });
|
await app.listen({ host: cfg.bind, port: cfg.port });
|
||||||
return { app, store };
|
timerRef.current = startSweep(store, cfg, app.log);
|
||||||
|
return { app, store, sweepTimer: timerRef.current };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ 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 { DatabaseSync } from "node:sqlite";
|
||||||
import { MailboxStore, RenameError } from "../src/db.js";
|
import { MailboxStore, RenameError } from "../src/db.js";
|
||||||
|
|
||||||
|
function backdate(dbPath: string, name: string, minutesAgo: number): void {
|
||||||
|
const db = new DatabaseSync(dbPath);
|
||||||
|
const iso = new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
||||||
|
db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?").run(iso, name);
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
let dbPath: string;
|
let dbPath: string;
|
||||||
|
|
||||||
@@ -178,4 +186,127 @@ describe("listMailboxes", () => {
|
|||||||
store.close();
|
store.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides mailboxes older than hideAfterMinutes when filter is active", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("recent");
|
||||||
|
store.upsertMailbox("stale");
|
||||||
|
store.close();
|
||||||
|
backdate(dbPath, "stale", 90);
|
||||||
|
|
||||||
|
const store2 = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
const filtered = store2.listMailboxes(undefined, { hideAfterMinutes: 60 });
|
||||||
|
expect(filtered.map((m) => m.name)).toEqual(["recent"]);
|
||||||
|
const unfiltered = store2.listMailboxes();
|
||||||
|
expect(unfiltered.map((m) => m.name).sort()).toEqual(["recent", "stale"]);
|
||||||
|
} finally {
|
||||||
|
store2.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
store.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always includes the caller and senders with pending messages, even if stale", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("stale-sender", "me", "you have mail");
|
||||||
|
store.upsertMailbox("recent-other");
|
||||||
|
store.upsertMailbox("stale-other");
|
||||||
|
store.close();
|
||||||
|
backdate(dbPath, "stale-sender", 120);
|
||||||
|
backdate(dbPath, "stale-other", 120);
|
||||||
|
backdate(dbPath, "me", 120);
|
||||||
|
|
||||||
|
const store2 = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
const filtered = store2.listMailboxes("me", { hideAfterMinutes: 60 });
|
||||||
|
const names = filtered.map((m) => m.name).sort();
|
||||||
|
expect(names).toContain("me");
|
||||||
|
expect(names).toContain("stale-sender");
|
||||||
|
expect(names).toContain("recent-other");
|
||||||
|
expect(names).not.toContain("stale-other");
|
||||||
|
} finally {
|
||||||
|
store2.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
store.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pruneStale", () => {
|
||||||
|
it("deletes idle mailboxes with no pending messages and wipes their delivered history", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob", "old");
|
||||||
|
store.checkInbox("bob");
|
||||||
|
store.upsertMailbox("fresh");
|
||||||
|
store.close();
|
||||||
|
backdate(dbPath, "alice", 60 * 24 * 8);
|
||||||
|
backdate(dbPath, "bob", 60 * 24 * 8);
|
||||||
|
|
||||||
|
const store2 = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
const r = store2.pruneStale(60 * 24 * 7);
|
||||||
|
expect(r.deletedMailboxes).toBe(2);
|
||||||
|
expect(r.deletedMessages).toBe(1);
|
||||||
|
const remaining = store2.listMailboxes().map((m) => m.name);
|
||||||
|
expect(remaining).toEqual(["fresh"]);
|
||||||
|
} finally {
|
||||||
|
store2.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
store.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never deletes a mailbox that still has pending messages, even if idle", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob", "still pending");
|
||||||
|
store.close();
|
||||||
|
backdate(dbPath, "alice", 60 * 24 * 30);
|
||||||
|
backdate(dbPath, "bob", 60 * 24 * 30);
|
||||||
|
|
||||||
|
const store2 = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
const r = store2.pruneStale(60 * 24 * 7);
|
||||||
|
expect(r.deletedMailboxes).toBe(0);
|
||||||
|
expect(r.deletedMessages).toBe(0);
|
||||||
|
expect(store2.peek("bob").pending).toBe(1);
|
||||||
|
} finally {
|
||||||
|
store2.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
store.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns zero when deleteAfterMinutes is 0 (disabled)", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("x");
|
||||||
|
store.close();
|
||||||
|
backdate(dbPath, "x", 60 * 24 * 365);
|
||||||
|
|
||||||
|
const store2 = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
const r = store2.pruneStale(0);
|
||||||
|
expect(r).toEqual({ deletedMailboxes: 0, deletedMessages: 0 });
|
||||||
|
expect(store2.listMailboxes().map((m) => m.name)).toEqual(["x"]);
|
||||||
|
} finally {
|
||||||
|
store2.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
store.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +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 { DatabaseSync } from "node:sqlite";
|
||||||
import { MailboxStore } from "../src/db.js";
|
import { MailboxStore } from "../src/db.js";
|
||||||
import { buildServer } from "../src/server.js";
|
import { buildServer } from "../src/server.js";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
@@ -16,7 +17,17 @@ beforeEach(async () => {
|
|||||||
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-srv-"));
|
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-srv-"));
|
||||||
dbPath = join(dir, "test.db");
|
dbPath = join(dir, "test.db");
|
||||||
store = new MailboxStore(dbPath);
|
store = new MailboxStore(dbPath);
|
||||||
app = await buildServer({ port: 0, bind: "127.0.0.1", dbPath }, store);
|
app = await buildServer(
|
||||||
|
{
|
||||||
|
port: 0,
|
||||||
|
bind: "127.0.0.1",
|
||||||
|
dbPath,
|
||||||
|
hideAfterMinutes: 0,
|
||||||
|
deleteAfterMinutes: 0,
|
||||||
|
sweepIntervalMinutes: 0,
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
);
|
||||||
await app.listen({ host: "127.0.0.1", port: 0 });
|
await app.listen({ host: "127.0.0.1", port: 0 });
|
||||||
const addr = app.server.address();
|
const addr = app.server.address();
|
||||||
if (!addr || typeof addr === "string") throw new Error("no address");
|
if (!addr || typeof addr === "string") throw new Error("no address");
|
||||||
@@ -151,6 +162,42 @@ describe("REST surface", () => {
|
|||||||
expect(missingTo.status).toBe(400);
|
expect(missingTo.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("/v1/list filters out mailboxes idle beyond hideAfterMinutes", async () => {
|
||||||
|
await app.close();
|
||||||
|
store.close();
|
||||||
|
store = new MailboxStore(dbPath);
|
||||||
|
store.upsertMailbox("recent");
|
||||||
|
store.upsertMailbox("stale");
|
||||||
|
store.close();
|
||||||
|
const handle = new DatabaseSync(dbPath);
|
||||||
|
const past = new Date(Date.now() - 120 * 60_000).toISOString();
|
||||||
|
handle.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?").run(past, "stale");
|
||||||
|
handle.close();
|
||||||
|
|
||||||
|
store = new MailboxStore(dbPath);
|
||||||
|
app = await buildServer(
|
||||||
|
{
|
||||||
|
port: 0,
|
||||||
|
bind: "127.0.0.1",
|
||||||
|
dbPath,
|
||||||
|
hideAfterMinutes: 60,
|
||||||
|
deleteAfterMinutes: 0,
|
||||||
|
sweepIntervalMinutes: 0,
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
);
|
||||||
|
await app.listen({ host: "127.0.0.1", port: 0 });
|
||||||
|
const addr = app.server.address();
|
||||||
|
if (!addr || typeof addr === "string") throw new Error("no address");
|
||||||
|
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||||
|
|
||||||
|
const r = await call("GET", "/v1/list");
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
const names = (r.body as Array<{ name: string }>).map((m) => m.name);
|
||||||
|
expect(names).toContain("recent");
|
||||||
|
expect(names).not.toContain("stale");
|
||||||
|
});
|
||||||
|
|
||||||
it("/v1/list and /v1/peek are anonymous", async () => {
|
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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mailbox",
|
"name": "claude-mailbox",
|
||||||
"version": "1.4.0",
|
"version": "1.5.2",
|
||||||
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.",
|
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mika Kuns"
|
"name": "Mika Kuns"
|
||||||
|
|||||||
@@ -5,52 +5,106 @@ allowed-tools: Bash
|
|||||||
|
|
||||||
You are running the **Claude-Mailbox update** command. Update the `@kuns/claude-mailbox` npm package and restart the daemon. Never run `sudo` automatically — if elevation is needed, stop and ask.
|
You are running the **Claude-Mailbox update** command. Update the `@kuns/claude-mailbox` npm package and restart the daemon. Never run `sudo` automatically — if elevation is needed, stop and ask.
|
||||||
|
|
||||||
## Step 1 — current version
|
Throughout, treat the **daemon's `/health` endpoint** as the ground truth for "is the daemon running and on what version", not `claude-mailbox status` (which only reflects the autostart wrapper). Always use the registry override flag `--@kuns:registry=https://git.kuns.dev/api/packages/releases/npm/` on every `npm` call, so the upgrade works even when the user's `.npmrc` is unreachable (e.g. roaming `$HOME` on a network share).
|
||||||
|
|
||||||
Run: `claude-mailbox --version`
|
## Step 1 — current state (must run before anything is changed)
|
||||||
|
|
||||||
- Exit 0 → record the version string as `CURRENT`.
|
Run, in order, and remember each result:
|
||||||
- Non-zero → tell the user the daemon binary is not installed yet. Suggest `/claude-mailbox:mailbox-doctor` to do a full setup and stop.
|
|
||||||
|
1. `claude-mailbox --version`
|
||||||
|
- Exit 0 → `CURRENT_CLI = <stdout trimmed>`.
|
||||||
|
- Non-zero → stop. The binary isn't installed; suggest `/claude-mailbox:mailbox-doctor` and exit.
|
||||||
|
2. `claude-mailbox status`
|
||||||
|
- Record as `AUTOSTART_STATE ∈ { Running, Stopped, NotInstalled }`.
|
||||||
|
3. Read the configured port. Try `~/.claude-mailbox/mailbox.json`; if absent or no `port` field, use **37849**. Call this `PORT`.
|
||||||
|
4. Probe `curl -sf -m 2 http://127.0.0.1:$PORT/health`.
|
||||||
|
- On success, parse JSON → `CURRENT_HEALTH_VERSION = .version`. Set `DAEMON_REACHABLE = true`.
|
||||||
|
- On failure → `CURRENT_HEALTH_VERSION = null`, `DAEMON_REACHABLE = false`.
|
||||||
|
|
||||||
|
Note any inconsistencies (e.g. `AUTOSTART_STATE = NotInstalled` but `DAEMON_REACHABLE = true` means a manually-started foreground daemon) — they affect Step 5.
|
||||||
|
|
||||||
## Step 2 — latest published version
|
## Step 2 — latest published version
|
||||||
|
|
||||||
Run: `npm view @kuns/claude-mailbox version`
|
Run: `npm view --@kuns:registry=https://git.kuns.dev/api/packages/releases/npm/ @kuns/claude-mailbox version`
|
||||||
|
|
||||||
If the npm registry config is missing, the call may fail with a 404. Fall back to:
|
If that fails for any reason (network, registry), fall back to:
|
||||||
|
|
||||||
```
|
```
|
||||||
npm view --registry=https://git.kuns.dev/api/packages/releases/npm/ @kuns/claude-mailbox version
|
npm view --registry=https://git.kuns.dev/api/packages/releases/npm/ @kuns/claude-mailbox version
|
||||||
```
|
```
|
||||||
|
|
||||||
Record the result as `LATEST`.
|
Record the result as `LATEST`. If both calls fail, stop and report the network/registry error.
|
||||||
|
|
||||||
## Step 3 — compare
|
## Step 3 — compare
|
||||||
|
|
||||||
- If `CURRENT === LATEST`: print "Already up to date (vX.Y.Z)." and stop. Do not run any further steps.
|
- `CURRENT_CLI === LATEST` AND `CURRENT_HEALTH_VERSION === LATEST` (or `DAEMON_REACHABLE = false`) → print "Already up to date (vLATEST)." and stop.
|
||||||
- Otherwise: tell the user `CURRENT` → `LATEST` and ask for confirmation before proceeding.
|
- `CURRENT_CLI === LATEST` but `CURRENT_HEALTH_VERSION !== LATEST` → the CLI is fresh but the running daemon is the old binary. Tell the user "Binary already at LATEST but the running daemon is still v$CURRENT_HEALTH_VERSION — restart needed." Then jump to Step 5 (no npm install).
|
||||||
|
- Otherwise → tell the user `CURRENT_CLI` → `LATEST` and ask for confirmation before proceeding.
|
||||||
|
|
||||||
## Step 4 — perform the update
|
Also warn before confirmation if `AUTOSTART_STATE = NotInstalled` AND `DAEMON_REACHABLE = false`:
|
||||||
|
|
||||||
On user confirmation, run these in order. Stop on the first failure and report it:
|
> Heads-up: autostart is not installed and no daemon is reachable on port $PORT. After the upgrade I can install the new binary, but I won't be able to start the daemon automatically — you'd need `/claude-mailbox:mailbox-doctor` to wire up autostart, or run `claude-mailbox serve` manually. Proceed anyway?
|
||||||
|
|
||||||
1. `claude-mailbox stop`
|
## Step 4 — install the new package
|
||||||
2. `npm install -g @kuns/claude-mailbox@latest`
|
|
||||||
- On Linux/macOS this may fail with EACCES. **Do not run sudo automatically.** Ask the user how they want to proceed (e.g., `sudo npm install -g …`, or switch to a user-scoped Node setup with nvm/fnm).
|
|
||||||
3. `claude-mailbox start`
|
|
||||||
4. `claude-mailbox --version` to verify the upgrade landed.
|
|
||||||
5. `claude-mailbox status` to verify the daemon is `Running`.
|
|
||||||
|
|
||||||
## Step 5 — summary
|
On user confirmation:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install -g @kuns/claude-mailbox@latest --@kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
- The scope override is mandatory — do not omit it, even if `npm config get @kuns:registry` looks right. It costs nothing and protects against unreachable user-level `.npmrc`.
|
||||||
|
- On Linux/macOS the install may fail with EACCES. **Do not run sudo automatically.** Stop and ask how the user wants to proceed (e.g. `sudo`, switch to nvm/fnm).
|
||||||
|
- On any other failure, stop and report. Do **not** touch the daemon — leaving the old daemon running is the safe rollback.
|
||||||
|
|
||||||
|
After install, run `claude-mailbox --version` and confirm it now reports `LATEST`. If not (PATH shadowing, stale `which`), stop and report — the daemon is still on the old version, which is fine to keep running.
|
||||||
|
|
||||||
|
## Step 5 — restart the daemon
|
||||||
|
|
||||||
|
Now swap the daemon over to the new binary.
|
||||||
|
|
||||||
|
1. Stop the existing daemon if anything is running:
|
||||||
|
- If `AUTOSTART_STATE = Running` → `claude-mailbox stop` and wait up to 5s for `/health` on `PORT` to start failing (poll once per second).
|
||||||
|
- If `DAEMON_REACHABLE = true` but `AUTOSTART_STATE = NotInstalled` → a foreground/manual daemon is running. Tell the user:
|
||||||
|
> A daemon is reachable on port $PORT but autostart is not installed, so I can't stop it. Stop the manual `claude-mailbox serve` process yourself, then re-run this command to finish the restart.
|
||||||
|
Then stop here.
|
||||||
|
- Otherwise nothing to stop.
|
||||||
|
2. Start the daemon, picking the path that matches `AUTOSTART_STATE`:
|
||||||
|
- `Running` or `Stopped` (i.e. autostart is installed) → `claude-mailbox start`.
|
||||||
|
- `NotInstalled` → skip the start. After the loop below times out, tell the user to run `/claude-mailbox:mailbox-doctor` to install autostart, then exit.
|
||||||
|
3. Poll `curl -sf -m 1 http://127.0.0.1:$PORT/health` up to **10 times with 1s sleeps**. Stop polling as soon as one returns JSON with `"status":"ok"`.
|
||||||
|
4. Outcome:
|
||||||
|
- Health came up AND `version === LATEST` → ✓ proceed to Step 6.
|
||||||
|
- Health came up but `version !== LATEST` → the wrapper started an *old* binary somewhere on PATH. Dump `which claude-mailbox` / `where claude-mailbox` and stop with that info.
|
||||||
|
- Health did not come up → dump the most recent daemon log to help diagnose. Try, in order, the first one that exists:
|
||||||
|
- Windows Scheduled Task / fallback: `%LOCALAPPDATA%\ClaudeMailbox\logs\daemon.log` (tail the last 40 lines)
|
||||||
|
- Windows Service variant: `Get-WinEvent -ProviderName ClaudeMailbox -MaxEvents 20` via `powershell -NoProfile -Command "..."`
|
||||||
|
- macOS launchd: `~/Library/Logs/ClaudeMailbox/daemon.log` (last 40 lines)
|
||||||
|
- Linux systemd-user: `journalctl --user -u claude-mailbox -n 40 --no-pager`
|
||||||
|
If none exist or all are empty, just say "No daemon logs found; try `claude-mailbox serve` in a terminal to see the error directly."
|
||||||
|
Stop with that diagnostic; do not pretend the update succeeded.
|
||||||
|
|
||||||
|
## Step 6 — summary
|
||||||
|
|
||||||
Print exactly this block:
|
Print exactly this block:
|
||||||
|
|
||||||
```
|
```
|
||||||
Claude-Mailbox update
|
Claude-Mailbox update
|
||||||
previous version: <CURRENT>
|
previous version: <CURRENT_CLI>
|
||||||
new version: <whatever --version now reports>
|
new version: <claude-mailbox --version output>
|
||||||
daemon: Running | Stopped | NotInstalled
|
daemon health: ok (v<version from /health>) | unreachable
|
||||||
pending messages survived: <count of messages still in inbox via `claude-mailbox list`, if applicable>
|
daemon autostart: Running | Stopped | NotInstalled
|
||||||
|
pending messages: <total pending across all mailboxes — sum of pendingForYou from `claude-mailbox list`>
|
||||||
```
|
```
|
||||||
|
|
||||||
If the new version matches `LATEST` and daemon is `Running`, end with: "Update complete."
|
End with one of:
|
||||||
Otherwise, end with the first thing that went wrong.
|
|
||||||
|
- New CLI version matches `LATEST` AND `/health` returns `version === LATEST` → **"Update complete."**
|
||||||
|
- Anything else → **"Update incomplete: <the first concrete failure from Step 4 or 5>."**
|
||||||
|
|
||||||
|
## Operating notes
|
||||||
|
|
||||||
|
- **Always use the scope override flag** (`--@kuns:registry=...`) on every npm call. The user's `.npmrc` may be on a network drive that npm can't read.
|
||||||
|
- **Never rely on `claude-mailbox status` alone** to decide "the daemon is fine". Always cross-check with a `/health` probe — the status command only reflects whether the autostart task is in a Running state and doesn't notice if the process inside crashed at boot.
|
||||||
|
- **Never run `npm install` without first locking in the current state.** If the install fails, the safe rollback is to do nothing — the old daemon is still running.
|
||||||
|
- **Never `claude-mailbox stop` before the install succeeds.** Downtime is paid only after we know the new binary is on disk.
|
||||||
|
|||||||
Reference in New Issue
Block a user