Files
ClaudeMailbox/node/src/hook.ts
Mika Kuns 462d6561e1 feat(plugin): per-session mailbox identity + mailbox-update command
The hook now derives a unique mailbox name from the session_id supplied
on hook stdin, so two parallel Claude Code sessions in the same project
get distinct mailboxes (e.g. `claude-a8b3c1d2`, `claude-d4e5f6a7`)
instead of colliding on a shared env value. An optional
CLAUDE_MAILBOX_NAME base prefix flavors the names as `<base>-<sid>`.

Adds:
- `claude-mailbox session-announce` subcommand for the new SessionStart
  hook, which prints the current session's mailbox name to context
- `/claude-mailbox:mailbox-update` slash command for `npm update` +
  daemon restart
- stdin parsing helpers (parseHookStdin, deriveSessionName) with unit
  tests; the doctor no longer needs a mandatory name prompt
2026-05-19 11:39:14 +02:00

169 lines
5.0 KiB
TypeScript

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
export interface HookStdinPayload {
session_id?: string;
hook_event_name?: string;
[key: string]: unknown;
}
export function parseHookStdin(raw: string | null | undefined): HookStdinPayload | null {
if (!raw || !raw.trim()) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object") return parsed as HookStdinPayload;
return null;
} catch {
return null;
}
}
export function readStdinIfPiped(): string | null {
if (process.stdin.isTTY) return null;
try {
return readFileSync(0, "utf8");
} catch {
return null;
}
}
export function shortSessionId(sessionId: string): string {
const hex = sessionId.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
if (hex.length >= 8) return hex.slice(0, 8);
return sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 8) || "00000000";
}
export function deriveSessionName(sessionId: string, base?: string | null): string {
const short = shortSessionId(sessionId);
const trimmed = (base ?? "").trim();
if (trimmed) return `${trimmed}-${short}`;
return `claude-${short}`;
}
export interface HookMessage {
id: number;
from: string;
body: string;
sentAt: string;
}
export function formatMessagesForHook(name: string, messages: HookMessage[]): string {
if (messages.length === 0) return "";
const header =
messages.length === 1
? `You have 1 new mailbox message for "${name}":`
: `You have ${messages.length} new mailbox messages for "${name}":`;
const lines: string[] = [header, ""];
for (const m of messages) {
lines.push(`[#${m.id}] from ${m.from} (${m.sentAt}):`);
for (const bodyLine of m.body.split(/\r?\n/)) {
lines.push(` ${bodyLine}`);
}
lines.push("");
}
return lines.join("\n").trimEnd() + "\n";
}
export type HookScope = "user" | "project";
export function settingsPathFor(scope: HookScope, cwd: string = process.cwd()): string {
if (scope === "user") return join(homedir(), ".claude", "settings.json");
return join(cwd, ".claude", "settings.json");
}
interface ClaudeHookCommand {
type: "command";
command: string;
timeout?: number;
}
interface ClaudeHookGroup {
matcher?: string;
hooks: ClaudeHookCommand[];
}
interface ClaudeSettings {
hooks?: Record<string, ClaudeHookGroup[]>;
[key: string]: unknown;
}
const HOOK_EVENT = "UserPromptSubmit";
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(" ");
}
function quoteIfNeeded(value: string): string {
if (/^[A-Za-z0-9._:/@\-]+$/.test(value)) return value;
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
}
function isOurHookCommand(command: string): boolean {
const c = command.trim();
return /(^|\W)claude-mailbox\s+check\s/.test(c) && /(^|\s)--hook(\s|$)/.test(c);
}
export function readSettings(path: string): ClaudeSettings {
if (!existsSync(path)) return {};
const raw = readFileSync(path, "utf8");
if (!raw.trim()) return {};
return JSON.parse(raw) as ClaudeSettings;
}
export function writeSettings(path: string, settings: ClaudeSettings): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
}
export interface PatchResult {
changed: boolean;
reason: "added" | "already-present" | "removed" | "not-present";
}
export function applyInstall(settings: ClaudeSettings, command: string): PatchResult {
settings.hooks ??= {};
settings.hooks[HOOK_EVENT] ??= [];
const groups = settings.hooks[HOOK_EVENT];
for (const group of groups) {
for (const hook of group.hooks) {
if (hook.command.trim() === command.trim()) {
return { changed: false, reason: "already-present" };
}
}
}
let target = groups.find((g) => (g.matcher ?? "") === "");
if (!target) {
target = { matcher: "", hooks: [] };
groups.push(target);
}
target.hooks.push({ type: "command", command });
return { changed: true, reason: "added" };
}
export function applyUninstall(settings: ClaudeSettings): PatchResult {
const groups = settings.hooks?.[HOOK_EVENT];
if (!groups || groups.length === 0) return { changed: false, reason: "not-present" };
let removed = false;
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];
}
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
delete settings.hooks;
}
return removed ? { changed: true, reason: "removed" } : { changed: false, reason: "not-present" };
}