Compare commits
3 Commits
840a3e32c8
...
debc6287e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
debc6287e2 | ||
|
|
fed74dc8f0 | ||
|
|
3ebf54e75d |
109
CLAUDE.md
Normal file
109
CLAUDE.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Repo layout (important)
|
||||
|
||||
The active codebase lives entirely in **`node/`** (TypeScript, Node 24+). The top-level `src/ClaudeMailbox/` and `tests/ClaudeMailbox.Tests/` directories are stale .NET build artifacts (`bin/`, `obj/` only) left over from an abandoned C# prototype — ignore them, don't build them, don't grep them.
|
||||
|
||||
Other top-level dirs:
|
||||
|
||||
- `plugin/` — the Claude Code plugin (`hooks/hooks.json`, slash `commands/`, the `mailbox-collaborate` skill). Loaded via `.claude-plugin/marketplace.json`.
|
||||
- `homebrew/`, `install.ps1`, `install.sh` — distribution shims for the published npm package `@kuns/claude-mailbox`.
|
||||
- `docs/superpowers/` — design specs and plans (history, not runtime).
|
||||
|
||||
## Development commands
|
||||
|
||||
All commands run from `node/`:
|
||||
|
||||
```sh
|
||||
cd node
|
||||
npm install
|
||||
npm run build # tsc → dist/
|
||||
npm test # pretest builds, then vitest run
|
||||
npm run test:watch # vitest in watch mode
|
||||
npx vitest run tests/cli-watch.test.ts # single test file
|
||||
npx vitest run -t "rename" # by test name pattern
|
||||
npm start # node dist/cli.js serve (run daemon in foreground)
|
||||
```
|
||||
|
||||
`npm test` runs `pretest` (build) first — vitest executes against `dist/`, not via a TS transformer, so always rebuild after editing `src/` if running vitest directly.
|
||||
|
||||
Vitest config: `tests/**/*.test.ts`, `pool: "forks"`, 15 s timeout. Tests spin up real Fastify servers and real SQLite files in temp dirs — they are integration tests, not unit tests with mocks.
|
||||
|
||||
## Runtime CLI surface (after `npm run build`)
|
||||
|
||||
`dist/cli.js` is the single entry point (bin name `claude-mailbox`):
|
||||
|
||||
```
|
||||
serve | send | peek | check [--hook] | watch --block | list | status
|
||||
session-announce # SessionStart hook helper, reads stdin JSON
|
||||
session-end # SessionEnd hook helper, deletes mailbox if empty
|
||||
install-hook / uninstall-hook # patch settings.json
|
||||
install-autostart / uninstall-autostart [--service] # OS autostart registration
|
||||
mcp-stdio # stdio MCP wrapper that proxies to the HTTP daemon
|
||||
```
|
||||
|
||||
All subcommands accept `--url <url>`; `CLAUDE_MAILBOX_URL` env overrides the default `http://127.0.0.1:37849`.
|
||||
|
||||
## Architecture
|
||||
|
||||
One long-running daemon, single SQLite file, multiple thin clients.
|
||||
|
||||
```
|
||||
clients (Claude sessions, CLI, scripts)
|
||||
│ HTTP loopback
|
||||
▼
|
||||
claude-mailbox serve (Fastify, port 37849)
|
||||
├─ /mcp MCP Streamable HTTP transport (registerMcp)
|
||||
├─ /v1/send /peek /check-inbox /watch /list REST
|
||||
└─ /health
|
||||
│
|
||||
▼
|
||||
~/.claude-mailbox/mailbox.db (SQLite WAL, two tables: mailboxes, messages)
|
||||
```
|
||||
|
||||
Module map under `node/src/`:
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `cli.ts` | Commander CLI; dispatches to every other module. Single entry. |
|
||||
| `server.ts` | Fastify app, request hook that enforces `X-Mailbox` header on non-anonymous paths, REST routes. |
|
||||
| `mcp.ts` | Registers MCP tools (`send`, `check_inbox`, `peek_inbox`, `list_mailboxes`, `rename`) on the same Fastify app at `/mcp`. |
|
||||
| `mcp-stdio.ts` | Stdio MCP wrapper used by the plugin's `.mcp.json` — proxies tool calls to the HTTP daemon (workaround for Claude Code not yet supporting env-var substitution in HTTP MCP URLs). |
|
||||
| `db.ts` | `MailboxStore` — all SQL via `node:sqlite` (no ORM). Owns DDL idempotency, atomic `check_inbox`, rename-with-message-transfer, long-poll `wait` for push delivery. |
|
||||
| `hook.ts` | Helpers shared by `session-announce` / `check --hook` / `install-hook`: stdin parsing, identity derivation (`<project>-<8hex>` from `session_id`), settings.json patching, peer formatting. |
|
||||
| `config.ts` | Config precedence: CLI flag > `mailbox.json` > defaults. Looks in `~/.claude-mailbox/mailbox.json` and on Windows also `%ProgramData%\ClaudeMailbox\mailbox.json`. |
|
||||
| `autostart/{windows,darwin,linux}.ts` | Per-OS autostart: Scheduled Task / HKCU Run / `node-windows` service / launchd LaunchAgent / systemd `--user`. Selected via `autostart/index.ts`. |
|
||||
|
||||
### Identity derivation
|
||||
|
||||
`hook.ts::deriveSessionName` builds the mailbox name from the SessionStart hook's stdin JSON (`session_id`, `cwd`): `<sanitized-project>-<first-8-hex-of-session-id>`. Project name is the git repo basename if inside a repo, else the cwd basename, sanitized (lowercased, non-alphanumerics → `-`, capped at 40 chars). Without a cwd it degrades to `claude-<8hex>`. This is how two parallel sessions in the same project stay distinct.
|
||||
|
||||
### Push delivery (`watch --block`)
|
||||
|
||||
Long-poll on `GET /v1/watch?name=…&timeout=…`. The daemon parks the request on an in-memory waiter list keyed by mailbox; `send` wakes the first waiter atomically (FIFO winner across concurrent watchers). Exit codes: `0` delivered (or `Mailbox renamed to '<new>'` on stdout), `2` daemon unreachable, `3` timeout. Push is **opt-in** — the plugin's `SessionStart` hook does *not* launch the watcher automatically; users invoke the `mailbox-collaborate` skill (or `/collaborate`) to enter collaboration mode. The pull path via `UserPromptSubmit` / `SubagentStop` hooks remains the always-on fallback.
|
||||
|
||||
### Plugin hooks (what runs without you doing anything)
|
||||
|
||||
`plugin/hooks/hooks.json` wires five hooks to the installed `claude-mailbox` binary:
|
||||
|
||||
- `SessionStart` → `claude-mailbox session-announce` (prints identity + active peers into context, registers session with daemon)
|
||||
- `UserPromptSubmit` → `claude-mailbox check --hook` (drains inbox, injects messages)
|
||||
- `SubagentStop` → `claude-mailbox check --hook` (same, when a subagent finishes)
|
||||
- `TaskCompleted` → `claude-mailbox check --hook` (same, when Claude marks a TaskCreate task completed — gives mid-run sync points between todo items)
|
||||
- `SessionEnd` → `claude-mailbox session-end` (deletes the session's mailbox if no pending messages — same semantics as the sweeper, just immediate; renamed mailboxes are preserved because the auto-derived name no longer exists)
|
||||
|
||||
`install-hook` / `uninstall-hook` patch the same five events into `settings.json` for users not using the plugin. The manual installer is multi-event aware — adding/removing a hook in `plugin/hooks/hooks.json` should be mirrored by `MANAGED_HOOK_EVENTS` and `buildPluginHookCommands` in `hook.ts`.
|
||||
|
||||
## Conventions worth knowing
|
||||
|
||||
- ES modules with `NodeNext` resolution — relative imports must use the `.js` extension even in `.ts` source (e.g. `import { … } from "./db.js"`).
|
||||
- `tsconfig.json` is strict including `noUnusedLocals` / `noUnusedParameters` — dead identifiers fail the build.
|
||||
- `node:sqlite` is used directly (no `better-sqlite3`) — Node 24+ requirement comes from this.
|
||||
- The daemon **never authenticates**: loopback bind + filesystem perms are the trust boundary. `X-Mailbox` header is identity, not auth — anything on loopback can claim any name. Don't add code that assumes otherwise.
|
||||
- `*.db`, `*.db-shm`, `*.db-wal` are gitignored — never commit a real mailbox DB.
|
||||
|
||||
## Release
|
||||
|
||||
`node/package.json` is the published artifact (`@kuns/claude-mailbox`, registry `https://git.kuns.dev/api/packages/releases/npm/`). Version bumps land as `chore(release): X.Y.Z` commits (see git log). The plugin and the npm package version-lockstep — `mailbox-doctor` and `mailbox-update` slash commands run `npm install -g @kuns/claude-mailbox` against that registry.
|
||||
@@ -191,7 +191,7 @@ claude-mailbox watch --block --name <mailbox> [--timeout 25]
|
||||
claude-mailbox list
|
||||
claude-mailbox status
|
||||
claude-mailbox session-announce # hook helper, reads stdin JSON
|
||||
claude-mailbox install-hook --name <mailbox> [--user|--project]
|
||||
claude-mailbox install-hook [--user|--project] [--url <url>]
|
||||
claude-mailbox uninstall-hook [--user|--project]
|
||||
```
|
||||
|
||||
|
||||
@@ -19,25 +19,35 @@ claude-mailbox install-autostart # registers per-OS autostart, no admin needed
|
||||
|
||||
See the [repository README](https://git.kuns.dev/releases/ClaudeMailbox/src/branch/main/README.md) for the full architecture, MCP tool reference, and `.mcp.json` snippet.
|
||||
|
||||
## Claude Code hook (auto-check inbox)
|
||||
## Claude Code hooks (auto-check inbox)
|
||||
|
||||
Register a `UserPromptSubmit` hook so Claude pulls pending mailbox messages before every prompt:
|
||||
Register the full plugin-equivalent hook set so Claude pulls pending mailbox messages at every natural sync point:
|
||||
|
||||
```sh
|
||||
claude-mailbox install-hook --name alice # patches ~/.claude/settings.json
|
||||
claude-mailbox install-hook --name alice --project # patches <cwd>/.claude/settings.json
|
||||
claude-mailbox install-hook # patches ~/.claude/settings.json
|
||||
claude-mailbox install-hook --project # patches <cwd>/.claude/settings.json
|
||||
claude-mailbox uninstall-hook # remove again
|
||||
```
|
||||
|
||||
The hook is idempotent (running `install-hook` twice does nothing the second time) and only touches the `UserPromptSubmit` block — other hooks and settings are preserved.
|
||||
This installs five hooks:
|
||||
|
||||
Under the hood the hook runs `claude-mailbox check --name <mailbox> --hook`, which:
|
||||
| Event | Command | When it fires |
|
||||
|---|---|---|
|
||||
| `SessionStart` | `session-announce` | Announces this session's mailbox identity + active peers. |
|
||||
| `UserPromptSubmit` | `check --hook` | Before each user prompt. |
|
||||
| `SubagentStop` | `check --hook` | When a subagent finishes. |
|
||||
| `TaskCompleted` | `check --hook` | When Claude marks a `TaskCreate` task completed — gives mid-run sync points. |
|
||||
| `SessionEnd` | `session-end` | Cleans up the auto-derived mailbox if empty. |
|
||||
|
||||
The mailbox name is auto-derived from the session-id stdin payload — no `--name` required. Install is idempotent and only touches our own commands; other hooks and settings are preserved.
|
||||
|
||||
`check --hook`:
|
||||
|
||||
- prints unread messages in a Claude-friendly format,
|
||||
- silently exits 0 if the inbox is empty or the daemon is unreachable (no context noise),
|
||||
- marks the messages delivered so they aren't injected again next prompt.
|
||||
|
||||
Cost: one local HTTP round-trip plus Node coldstart per prompt (~100ms on Windows).
|
||||
Cost: one local HTTP round-trip plus Node coldstart per fire (~100ms on Windows).
|
||||
|
||||
## Push delivery (watch)
|
||||
|
||||
|
||||
4
node/package-lock.json
generated
4
node/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@kuns/claude-mailbox",
|
||||
"version": "1.5.5",
|
||||
"version": "1.5.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@kuns/claude-mailbox",
|
||||
"version": "1.5.5",
|
||||
"version": "1.5.6",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kuns/claude-mailbox",
|
||||
"version": "1.5.5",
|
||||
"version": "1.5.6",
|
||||
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { runStdioMcp } from "./mcp-stdio.js";
|
||||
import {
|
||||
applyInstall,
|
||||
applyUninstall,
|
||||
buildHookCommand,
|
||||
buildPluginHookCommands,
|
||||
buildSessionAnnounceLines,
|
||||
deriveSessionName,
|
||||
formatMessagesForHook,
|
||||
@@ -365,13 +365,12 @@ program
|
||||
program
|
||||
.command("install-hook")
|
||||
.description(
|
||||
"Install a Claude Code UserPromptSubmit hook that checks the mailbox on every prompt. Idempotent.",
|
||||
"Install the full set of Claude Code hooks that mirror the plugin's hooks.json: SessionStart announces identity, UserPromptSubmit/SubagentStop/TaskCompleted drain the inbox, SessionEnd cleans up. Mailbox name is auto-derived from session stdin. 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 }) => {
|
||||
.option("--url <url>", "Daemon base URL to embed in each hook command")
|
||||
.action(async (opts: { user?: boolean; project?: boolean; url?: string }) => {
|
||||
if (opts.user && opts.project) {
|
||||
console.error("Pick either --user or --project, not both.");
|
||||
process.exit(1);
|
||||
@@ -379,20 +378,34 @@ program
|
||||
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) {
|
||||
const hooks = buildPluginHookCommands(opts.url);
|
||||
|
||||
const added: string[] = [];
|
||||
const alreadyPresent: string[] = [];
|
||||
for (const h of hooks) {
|
||||
const r = applyInstall(settings, h.event, h.command);
|
||||
if (r.changed) added.push(h.event);
|
||||
else alreadyPresent.push(h.event);
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
writeSettings(path, settings);
|
||||
console.log(`Hook installed in ${path}`);
|
||||
console.log(`Command: ${command}`);
|
||||
console.log(`Hooks installed in ${path}:`);
|
||||
for (const event of added) console.log(` + ${event}`);
|
||||
if (alreadyPresent.length > 0) {
|
||||
console.log("Already present:");
|
||||
for (const event of alreadyPresent) console.log(` · ${event}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Hook already present in ${path}; nothing to do.`);
|
||||
console.log(`All hooks already present in ${path}; nothing to do.`);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("uninstall-hook")
|
||||
.description("Remove the claude-mailbox UserPromptSubmit hook from Claude Code settings.")
|
||||
.description(
|
||||
"Remove all claude-mailbox hooks (SessionStart, UserPromptSubmit, SubagentStop, TaskCompleted, SessionEnd) 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 }) => {
|
||||
|
||||
@@ -194,12 +194,31 @@ interface ClaudeSettings {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const HOOK_EVENT = "UserPromptSubmit";
|
||||
export interface ManagedHook {
|
||||
event: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export function buildHookCommand(name: string, url?: string): string {
|
||||
const parts = ["claude-mailbox", "check", "--name", quoteIfNeeded(name), "--hook"];
|
||||
if (url) parts.push("--url", quoteIfNeeded(url));
|
||||
return parts.join(" ");
|
||||
export const MANAGED_HOOK_EVENTS = [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"SubagentStop",
|
||||
"TaskCompleted",
|
||||
"SessionEnd",
|
||||
] as const;
|
||||
|
||||
export function buildPluginHookCommands(url?: string): ManagedHook[] {
|
||||
const urlSuffix = url ? ` --url ${quoteIfNeeded(url)}` : "";
|
||||
const check = `claude-mailbox check --hook${urlSuffix}`;
|
||||
const announce = `claude-mailbox session-announce${urlSuffix}`;
|
||||
const end = `claude-mailbox session-end${urlSuffix}`;
|
||||
return [
|
||||
{ event: "SessionStart", command: announce },
|
||||
{ event: "UserPromptSubmit", command: check },
|
||||
{ event: "SubagentStop", command: check },
|
||||
{ event: "TaskCompleted", command: check },
|
||||
{ event: "SessionEnd", command: end },
|
||||
];
|
||||
}
|
||||
|
||||
function quoteIfNeeded(value: string): string {
|
||||
@@ -209,7 +228,10 @@ function quoteIfNeeded(value: string): string {
|
||||
|
||||
function isOurHookCommand(command: string): boolean {
|
||||
const c = command.trim();
|
||||
return /(^|\W)claude-mailbox\s+check\s/.test(c) && /(^|\s)--hook(\s|$)/.test(c);
|
||||
if (/(^|\W)claude-mailbox\s+check\s/.test(c) && /(^|\s)--hook(\s|$)/.test(c)) return true;
|
||||
if (/(^|\W)claude-mailbox\s+session-announce(\s|$)/.test(c)) return true;
|
||||
if (/(^|\W)claude-mailbox\s+session-end(\s|$)/.test(c)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function readSettings(path: string): ClaudeSettings {
|
||||
@@ -229,10 +251,14 @@ export interface PatchResult {
|
||||
reason: "added" | "already-present" | "removed" | "not-present";
|
||||
}
|
||||
|
||||
export function applyInstall(settings: ClaudeSettings, command: string): PatchResult {
|
||||
export function applyInstall(
|
||||
settings: ClaudeSettings,
|
||||
event: string,
|
||||
command: string,
|
||||
): PatchResult {
|
||||
settings.hooks ??= {};
|
||||
settings.hooks[HOOK_EVENT] ??= [];
|
||||
const groups = settings.hooks[HOOK_EVENT];
|
||||
settings.hooks[event] ??= [];
|
||||
const groups = settings.hooks[event];
|
||||
|
||||
for (const group of groups) {
|
||||
for (const hook of group.hooks) {
|
||||
@@ -252,21 +278,26 @@ export function applyInstall(settings: ClaudeSettings, command: string): PatchRe
|
||||
}
|
||||
|
||||
export function applyUninstall(settings: ClaudeSettings): PatchResult {
|
||||
const groups = settings.hooks?.[HOOK_EVENT];
|
||||
if (!groups || groups.length === 0) return { changed: false, reason: "not-present" };
|
||||
if (!settings.hooks) return { changed: false, reason: "not-present" };
|
||||
|
||||
let removed = false;
|
||||
for (const event of MANAGED_HOOK_EVENTS) {
|
||||
const groups = settings.hooks[event];
|
||||
if (!groups || groups.length === 0) continue;
|
||||
|
||||
for (const group of groups) {
|
||||
const before = group.hooks.length;
|
||||
group.hooks = group.hooks.filter((h) => !isOurHookCommand(h.command));
|
||||
if (group.hooks.length !== before) removed = true;
|
||||
}
|
||||
|
||||
settings.hooks![HOOK_EVENT] = groups.filter((g) => g.hooks.length > 0);
|
||||
if (settings.hooks![HOOK_EVENT].length === 0) {
|
||||
delete settings.hooks![HOOK_EVENT];
|
||||
settings.hooks[event] = groups.filter((g) => g.hooks.length > 0);
|
||||
if (settings.hooks[event].length === 0) {
|
||||
delete settings.hooks[event];
|
||||
}
|
||||
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
||||
}
|
||||
|
||||
if (Object.keys(settings.hooks).length === 0) {
|
||||
delete settings.hooks;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
applyInstall,
|
||||
applyUninstall,
|
||||
buildHookCommand,
|
||||
buildPluginHookCommands,
|
||||
buildSessionAnnounceLines,
|
||||
deriveProjectName,
|
||||
deriveSessionName,
|
||||
formatActivePeerList,
|
||||
formatMessagesForHook,
|
||||
MANAGED_HOOK_EVENTS,
|
||||
parseHookStdin,
|
||||
readSettings,
|
||||
sanitizeProjectName,
|
||||
@@ -53,34 +54,49 @@ describe("formatMessagesForHook", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHookCommand", () => {
|
||||
it("builds a basic command", () => {
|
||||
expect(buildHookCommand("alice")).toBe("claude-mailbox check --name alice --hook");
|
||||
describe("buildPluginHookCommands", () => {
|
||||
it("returns one entry per managed event", () => {
|
||||
const hooks = buildPluginHookCommands();
|
||||
expect(hooks.map((h) => h.event)).toEqual([...MANAGED_HOOK_EVENTS]);
|
||||
});
|
||||
|
||||
it("appends --url when provided", () => {
|
||||
expect(buildHookCommand("alice", "http://127.0.0.1:9000")).toBe(
|
||||
"claude-mailbox check --name alice --hook --url http://127.0.0.1:9000",
|
||||
);
|
||||
it("uses session-announce for SessionStart and session-end for SessionEnd", () => {
|
||||
const map = new Map(buildPluginHookCommands().map((h) => [h.event, h.command]));
|
||||
expect(map.get("SessionStart")).toBe("claude-mailbox session-announce");
|
||||
expect(map.get("SessionEnd")).toBe("claude-mailbox session-end");
|
||||
});
|
||||
|
||||
it("quotes names with spaces", () => {
|
||||
const cmd = buildHookCommand("my mailbox");
|
||||
expect(cmd).toContain('--name "my mailbox"');
|
||||
it("uses check --hook for the three drain events", () => {
|
||||
const map = new Map(buildPluginHookCommands().map((h) => [h.event, h.command]));
|
||||
expect(map.get("UserPromptSubmit")).toBe("claude-mailbox check --hook");
|
||||
expect(map.get("SubagentStop")).toBe("claude-mailbox check --hook");
|
||||
expect(map.get("TaskCompleted")).toBe("claude-mailbox check --hook");
|
||||
});
|
||||
|
||||
it("appends --url to every command when provided", () => {
|
||||
for (const h of buildPluginHookCommands("http://127.0.0.1:9000")) {
|
||||
expect(h.command).toContain("--url http://127.0.0.1:9000");
|
||||
}
|
||||
});
|
||||
|
||||
it("quotes URLs that need it", () => {
|
||||
for (const h of buildPluginHookCommands("http://has space/")) {
|
||||
expect(h.command).toContain('--url "http://has space/"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInstall", () => {
|
||||
it("creates hooks structure from empty settings", () => {
|
||||
const s: Record<string, unknown> = {};
|
||||
const r = applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||
const r = applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||
expect(r).toEqual({ changed: true, reason: "added" });
|
||||
expect(s).toMatchObject({
|
||||
hooks: {
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
matcher: "",
|
||||
hooks: [{ type: "command", command: "claude-mailbox check --name bob --hook" }],
|
||||
hooks: [{ type: "command", command: "claude-mailbox check --hook" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -89,13 +105,20 @@ describe("applyInstall", () => {
|
||||
|
||||
it("is idempotent — does not duplicate the same command", () => {
|
||||
const s: Record<string, unknown> = {};
|
||||
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||
const r = applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||
const r = applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||
expect(r).toEqual({ changed: false, reason: "already-present" });
|
||||
const groups = (s.hooks as { UserPromptSubmit: { hooks: unknown[] }[] }).UserPromptSubmit;
|
||||
expect(groups[0]!.hooks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("installs into the target event, not always UserPromptSubmit", () => {
|
||||
const s: Record<string, unknown> = {};
|
||||
applyInstall(s, "TaskCompleted", "claude-mailbox check --hook");
|
||||
expect((s.hooks as Record<string, unknown>).TaskCompleted).toBeDefined();
|
||||
expect((s.hooks as Record<string, unknown>).UserPromptSubmit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves existing unrelated hooks", () => {
|
||||
const s: Record<string, unknown> = {
|
||||
hooks: {
|
||||
@@ -108,13 +131,11 @@ describe("applyInstall", () => {
|
||||
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "logger" }] }],
|
||||
},
|
||||
};
|
||||
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||
const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] };
|
||||
expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(2);
|
||||
expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else");
|
||||
expect(hooks.UserPromptSubmit[0]!.hooks[1]!.command).toBe(
|
||||
"claude-mailbox check --name bob --hook",
|
||||
);
|
||||
expect(hooks.UserPromptSubmit[0]!.hooks[1]!.command).toBe("claude-mailbox check --hook");
|
||||
expect((s.hooks as Record<string, unknown>).PostToolUse).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -126,7 +147,7 @@ describe("applyInstall", () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||
const groups = (s.hooks as { UserPromptSubmit: { matcher?: string }[] }).UserPromptSubmit;
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[1]!.matcher).toBe("");
|
||||
@@ -134,14 +155,40 @@ describe("applyInstall", () => {
|
||||
});
|
||||
|
||||
describe("applyUninstall", () => {
|
||||
it("removes the hook and cleans up empty structures", () => {
|
||||
it("removes a single-event install and cleans up empty structures", () => {
|
||||
const s: Record<string, unknown> = {};
|
||||
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||
const r = applyUninstall(s);
|
||||
expect(r).toEqual({ changed: true, reason: "removed" });
|
||||
expect(s.hooks).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes hooks across all managed events in one pass", () => {
|
||||
const s: Record<string, unknown> = {};
|
||||
for (const h of buildPluginHookCommands()) {
|
||||
applyInstall(s, h.event, h.command);
|
||||
}
|
||||
const r = applyUninstall(s);
|
||||
expect(r).toEqual({ changed: true, reason: "removed" });
|
||||
expect(s.hooks).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognizes legacy hooks with --name and removes them too", () => {
|
||||
const s: Record<string, unknown> = {
|
||||
hooks: {
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
matcher: "",
|
||||
hooks: [{ type: "command", command: "claude-mailbox check --name alice --hook" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const r = applyUninstall(s);
|
||||
expect(r.changed).toBe(true);
|
||||
expect(s.hooks).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves unrelated hooks in the same group", () => {
|
||||
const s: Record<string, unknown> = {
|
||||
hooks: {
|
||||
@@ -150,7 +197,7 @@ describe("applyUninstall", () => {
|
||||
matcher: "",
|
||||
hooks: [
|
||||
{ type: "command", command: "echo something-else" },
|
||||
{ type: "command", command: "claude-mailbox check --name bob --hook" },
|
||||
{ type: "command", command: "claude-mailbox check --hook" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -170,9 +217,11 @@ describe("applyUninstall", () => {
|
||||
expect(r).toEqual({ changed: false, reason: "not-present" });
|
||||
});
|
||||
|
||||
it("removes hooks installed with --url arg", () => {
|
||||
it("removes hooks installed with --url arg across every event", () => {
|
||||
const s: Record<string, unknown> = {};
|
||||
applyInstall(s, "claude-mailbox check --name bob --hook --url http://x");
|
||||
for (const h of buildPluginHookCommands("http://x")) {
|
||||
applyInstall(s, h.event, h.command);
|
||||
}
|
||||
applyUninstall(s);
|
||||
expect(s.hooks).toBeUndefined();
|
||||
});
|
||||
@@ -419,11 +468,11 @@ describe("readSettings / writeSettings roundtrip", () => {
|
||||
const path = join(dir, "settings.json");
|
||||
const s = readSettings(path);
|
||||
expect(s).toEqual({});
|
||||
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||
writeSettings(path, s);
|
||||
const reloaded = readSettings(path);
|
||||
expect(reloaded.hooks?.UserPromptSubmit?.[0]?.hooks[0]?.command).toBe(
|
||||
"claude-mailbox check --name bob --hook",
|
||||
"claude-mailbox check --hook",
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -451,7 +500,7 @@ describe("readSettings / writeSettings roundtrip", () => {
|
||||
JSON.stringify({ model: "sonnet", permissions: { allow: ["Bash"] } }, null, 2),
|
||||
);
|
||||
const s = readSettings(path);
|
||||
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||
writeSettings(path, s);
|
||||
const reloaded = readSettings(path) as {
|
||||
model?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mailbox",
|
||||
"version": "1.5.5",
|
||||
"version": "1.5.6",
|
||||
"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": {
|
||||
"name": "Mika Kuns"
|
||||
|
||||
@@ -41,11 +41,13 @@ 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. |
|
||||
| `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. |
|
||||
| `SubagentStop` | `claude-mailbox check --hook` | Same as `UserPromptSubmit`, but fires when a subagent finishes. Lets the parent see peer messages that arrived during a long-running subagent run, instead of waiting until the next user prompt. |
|
||||
| `TaskCompleted` | `claude-mailbox check --hook` | Fires whenever Claude marks a `TaskCreate` task completed — gives peers mid-run injection points between todo items without needing the opt-in watcher. |
|
||||
| `SessionEnd` | `claude-mailbox session-end` | Deletes this session's auto-derived mailbox if it's empty (no pending messages). Renamed mailboxes are preserved. |
|
||||
|
||||
Cost: one local HTTP round-trip per prompt and per subagent stop + Node coldstart (~100ms on Windows).
|
||||
Cost: one local HTTP round-trip per fire + Node coldstart (~100ms on Windows).
|
||||
|
||||
The SessionStart announcement also instructs Claude to start `claude-mailbox watch --block --name <derived-name>` as a background bash task on its first turn. While that watcher is alive, peers can `mcp__mailbox__send(...)` and Claude reacts mid-turn — no user prompt needed. After processing each completion (delivery, timeout, rename, or daemon-down), Claude relaunches the watcher in the background. The pull hook (`UserPromptSubmit`) remains as a fallback for any messages that arrive while no watcher is running.
|
||||
Push delivery (real-time mid-turn wakeup via `claude-mailbox watch --block`) is **opt-in**. Invoke the `mailbox-collaborate` skill or the `/collaborate` slash command when you want peers to wake the session mid-task. Without it, the four pull hooks above are the always-on delivery path.
|
||||
|
||||
## MCP tools
|
||||
|
||||
|
||||
@@ -30,6 +30,16 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"TaskCompleted": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "claude-mailbox check --hook"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
Reference in New Issue
Block a user