SLOPSHOPPER

postmaster

File-driven trial of session messaging and mod runtime APIs.

new
★ 2v0.1.0no licenseupdated 2026-10-16natepiano/claude_commands/mods/postmaster
A shopper browsing a rack in a slop shop
README

Claude Commands

Personal slash commands, hooks and scripts for Claude Code and Codex.

  • commands/ — slash commands and skills
  • scripts/ — the hooks and tools they run
  • docs/ — design notes and as-built records
Source 4 files
hooks/register.ts 83 lines
1import type { EngineInterface, Register, SessionSendArgs } from "claude-code";
2import { startOutbox, reportOutboxError } from "./outbox";
3import { startReplies } from "./replies";
4import type { Outbox, PostmasterCalls, Replies } from "./protocol";
5
6type ModuleTimers = {
7  setInterval(fn: () => void, ms: number): unknown;
8  clearInterval(handle: unknown): void;
9};
10type WorkerServices = { outbox: Outbox; replies: Replies };
11type Worker = { status: "waiting" } | { status: "started"; calls: PostmasterCalls; services: Promise<WorkerServices> };
12
13function isModuleTimers(value: unknown): value is ModuleTimers {
14  return typeof value === "object" && value !== null
15    && "setInterval" in value && typeof value.setInterval === "function"
16    && "clearInterval" in value && typeof value.clearInterval === "function";
17}
18
19function callsFor($: EngineInterface): PostmasterCalls {
20  return {
21    env: { get: (name: string) => {
22      if (name === "HOME") return $.env.get("HOME");
23      if (name === "XDG_STATE_HOME") return $.env.get("XDG_STATE_HOME");
24      return Promise.reject(new Error(`Unsupported environment name: ${name}`));
25    } },
26    fs: {
27      read: (path: string) => $.fs.read(path),
28      write: (path: string, text: string) => $.fs.write(path, text),
29      exists: (path: string) => $.fs.exists(path),
30      list: (path?: string) => $.fs.list(path),
31    },
32    session: {
33      id: () => $.session.id(),
34      send: (message: SessionSendArgs) => $.session.send(message),
35    },
36    now: Date.now,
37    every: (ms, fn) => {
38      const timers: unknown = globalThis;
39      if (!isModuleTimers(timers)) throw new Error("Module interval timers are unavailable");
40      const interval = timers.setInterval(fn, ms);
41      return { cancel: () => timers.clearInterval(interval) };
42    },
43  };
44}
45
46export const register: Register = (on) => {
47  let worker: Worker = { status: "waiting" };
48  const startWorker = (calls: PostmasterCalls): Promise<WorkerServices> => {
49    if (worker.status === "started") return worker.services;
50    const services = startReplies(calls).then(replies => startOutbox(calls, replies).then(outbox => ({ outbox, replies })));
51    const active: Extract<Worker, { status: "started" }> = {
52      status: "started", calls,
53      services: services.catch(async error => {
54        await reportOutboxError(calls, error);
55        if (worker === active) worker = { status: "waiting" };
56        throw error;
57      }),
58    };
59    worker = active;
60    return active.services;
61  };
62
63  on("session.start", async ($, e, next) => {
64    await startWorker(callsFor($)).catch(() => {});
65    return next(e);
66  }).catch(async ($, e, next) => {
67    await reportOutboxError(callsFor($), e);
68    return next(e);
69  });
70
71  on("session.receive", async ($, e, next) => {
72    try {
73      const services = await startWorker(callsFor($));
74      const reply = await services.replies.receive(e);
75      if (reply.kind === "consumed") return { consumed: reply.reason };
76    } catch { /* A failed start or reply handler leaves the message for the session. */ }
77    return next(e);
78  }).catch(async ($, e, next) => {
79    await reportOutboxError(callsFor($), e);
80    return next(e);
81  });
82};
83
hooks/outbox.ts 161 lines
1import { BATCH, PATHS, POLL_MS, VERSION, isEntryId, isOutboxClaim, isOutboxEntry } from "./protocol";
2import type { Heartbeat, Outbox, OutboxClaim, OutboxEntry, OutboxResult, PostmasterCalls, Replies, RouteToken } from "./protocol";
3
4type EntryRead = { status: "valid"; entry: OutboxEntry } | { status: "unreadable" };
5type PendingResult = { path: string; result: OutboxResult };
6type RouteWithdrawal = { kind: "awaiting durable write"; token: RouteToken } | { kind: "durable" };
7
8let errorCount = 0;
9
10function errorText(error: unknown): string {
11  if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") return error.message;
12  return error instanceof Error ? error.message : String(error);
13}
14
15export async function stateDirectory(calls: PostmasterCalls): Promise<string> {
16  const xdg = await calls.env.get("XDG_STATE_HOME");
17  const root = typeof xdg === "string" && xdg !== "" ? xdg : `${await calls.env.get("HOME")}/.local/state`;
18  if (!root.startsWith("/")) throw new Error("No absolute home or XDG_STATE_HOME is available");
19  return `${root.replace(/\/$/, "")}/message/postmaster`;
20}
21
22async function writeError(calls: PostmasterCalls, directory: string, error: unknown): Promise<void> {
23  const at_ms = calls.now();
24  const n = ++errorCount;
25  await calls.fs.write(`${directory}/${PATHS.errors}/${at_ms}-${n}.json`, JSON.stringify({ at_ms, error: errorText(error) })).catch(() => {});
26}
27
28export async function reportOutboxError(calls: PostmasterCalls, error: unknown): Promise<void> {
29  try { await writeError(calls, await stateDirectory(calls), error); }
30  catch { /* Without a state directory there is no permitted error path. */ }
31}
32
33async function textHash(text: string): Promise<string> {
34  const bytes = new TextEncoder().encode(text);
35  const digest = await crypto.subtle.digest("SHA-256", bytes);
36  return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("");
37}
38
39export async function startOutbox(calls: PostmasterCalls, replies: Replies): Promise<Outbox> {
40  const directory = await stateDirectory(calls);
41  const session_id = await calls.session.id();
42  if (session_id === "") throw new Error("No session id is available");
43  const run = `${calls.now()}-${crypto.randomUUID()}`;
44  const pending = new Map<string, PendingResult>();
45  const pendingWithdrawals = new Map<string, RouteToken>();
46  let polling = false;
47  let cancelled = false;
48
49  const path = (folder: string, id: string) => `${directory}/${folder}/${id}.json`;
50  const heartbeat = async (): Promise<void> => {
51    const value: Heartbeat = { session_id, at_ms: calls.now(), version: VERSION, run };
52    await calls.fs.write(`${directory}/${PATHS.heartbeat}`, JSON.stringify(value));
53  };
54  const readEntry = async (id: string): Promise<EntryRead> => {
55    try {
56      const value: unknown = JSON.parse(await calls.fs.read(path(PATHS.outbox, id)));
57      return isOutboxEntry(value) && value.id === id ? { status: "valid", entry: value } : { status: "unreadable" };
58    } catch { return { status: "unreadable" }; }
59  };
60  const flush = async (): Promise<void> => {
61    for (const [id, item] of pending) {
62      try {
63        await calls.fs.write(item.path, JSON.stringify(item.result));
64        pending.delete(id);
65      } catch (error) { await writeError(calls, directory, error); }
66    }
67  };
68  const withdraw = async (token: RouteToken): Promise<RouteWithdrawal> => {
69    try {
70      await replies.notDelivered(token);
71      return { kind: "durable" };
72    } catch (error) {
73      await writeError(calls, directory, error);
74      return { kind: "awaiting durable write", token };
75    }
76  };
77  const flushWithdrawals = async (): Promise<void> => {
78    for (const [key, token] of pendingWithdrawals) {
79      const withdrawal = await withdraw(token);
80      if (withdrawal.kind === "durable") pendingWithdrawals.delete(key);
81    }
82  };
83  const result = async (id: string, claimed_ms: number, sha256: string, outcome: OutboxResult["outcome"], detail: string): Promise<void> => {
84    pending.set(id, { path: path(PATHS.results, id), result: { id, outcome, detail, sha256, claimed_ms, finished_ms: calls.now() } });
85    await flush();
86  };
87  const deliver = async (id: string): Promise<void> => {
88    if (pending.has(id) || await calls.fs.exists(path(PATHS.results, id)) || await calls.fs.exists(path(PATHS.claimed, id))) return;
89    const read = await readEntry(id);
90    const claimed_ms = calls.now();
91    const claim: OutboxClaim = { id, claimed_ms, run };
92    let sha256 = read.status === "valid" ? read.entry.sha256 : "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
93    let sendAttempted = false;
94    await calls.fs.write(path(PATHS.claimed, id), JSON.stringify(claim));
95    try {
96      const present = await calls.fs.exists(path(PATHS.outbox, id));
97      sha256 = await textHash(read.status === "valid" ? read.entry.text : "");
98      if (!present) return await result(id, claimed_ms, sha256, "withdrawn", "sender withdrew entry");
99      if (read.status === "unreadable") return await result(id, claimed_ms, sha256, "failed", "unreadable entry");
100      const entry = read.entry;
101      if (entry.deadline_ms < calls.now()) return await result(id, claimed_ms, sha256, "expired", "entry deadline passed");
102      const token = await replies.sending(entry, calls.now());
103      sendAttempted = true;
104      const answer = await calls.session.send({ to: { sessionId: entry.session_id }, text: entry.text });
105      if (!answer.isDelivered) {
106        const withdrawal = await withdraw(token);
107        if (withdrawal.kind === "awaiting durable write") pendingWithdrawals.set(token.key, withdrawal.token);
108      }
109      await result(id, claimed_ms, sha256, answer.isDelivered ? "delivered" : "failed", answer.isDelivered ? "" : answer.reason);
110    } catch (error) {
111      await writeError(calls, directory, error);
112      await result(id, claimed_ms, sha256, sendAttempted ? "abandoned" : "failed", errorText(error));
113    }
114  };
115  const oldClaim = async (id: string): Promise<void> => {
116    if (pending.has(id) || await calls.fs.exists(path(PATHS.results, id))) return;
117    const value: unknown = JSON.parse(await calls.fs.read(path(PATHS.claimed, id)));
118    if (!isOutboxClaim(value) || value.id !== id) throw new Error(`Unreadable claim: ${id}`);
119    if (value.run === run) return;
120    const read = await readEntry(id);
121    const sha256 = await textHash(read.status === "valid" ? read.entry.text : "");
122    await result(id, value.claimed_ms, sha256, "abandoned", "earlier worker left a claim without a result");
123  };
124  const listIds = async (folder: string): Promise<string[]> => {
125    if (!await calls.fs.exists(`${directory}/${folder}`)) {
126      try { return await listedIds(folder); } catch { return []; }
127    }
128    return listedIds(folder);
129  };
130  const listedIds = async (folder: string): Promise<string[]> => {
131    const files = await calls.fs.list(`${directory}/${folder}`);
132    return files.filter(file => file.kind === "file" && !file.isLink && file.name.endsWith(".json") && isEntryId(file.name.slice(0, -5)))
133      .sort((a, b) => a.mtimeMs - b.mtimeMs).map(file => file.name.slice(0, -5));
134  };
135  const pollBody = async (): Promise<void> => {
136    if (polling || cancelled) return;
137    polling = true;
138    try {
139      await heartbeat();
140      await flushWithdrawals();
141      await flush();
142      for (const id of await listIds(PATHS.claimed)) {
143        await oldClaim(id).catch(error => writeError(calls, directory, error));
144      }
145      let handled = 0;
146      for (const id of await listIds(PATHS.outbox)) {
147        if (handled === BATCH) break;
148        await (async () => {
149          if (pending.has(id) || await calls.fs.exists(path(PATHS.claimed, id)) || await calls.fs.exists(path(PATHS.results, id))) return;
150          handled++;
151          await heartbeat();
152          await deliver(id);
153        })().catch(error => writeError(calls, directory, error));
154      }
155    } finally { polling = false; }
156  };
157  const poll = (): Promise<void> => pollBody().catch(error => writeError(calls, directory, error));
158  const timer = calls.every(POLL_MS, () => { void poll(); });
159  return { poll, cancel: () => { cancelled = true; timer.cancel(); } };
160}
161
hooks/replies.ts 131 lines
1import type { SessionReceiveInput } from "claude-code";
2import { stateDirectory } from "./outbox";
3import { PATHS, REPLY_WINDOW_MS, isDeliveryRouteFile } from "./protocol";
4import type { DeliveryRoute, DeliveryRouteFile, PostmasterCalls, Replies, ReplyDestination, ReplyLog } from "./protocol";
5
6type ReplyWrapper = { kind: "reply"; socket: string; name: string; body: string } | { kind: "not a reply" };
7
8function wrapper(text: string): ReplyWrapper {
9  const lines = text.split("\n");
10  const first = lines[0];
11  const last = lines[lines.length - 1];
12  if (first === undefined || !/^<cross-session-message\s.*>\r?$/.test(first)
13    || last?.replace(/\r$/, "") !== "</cross-session-message>") return { kind: "not a reply" };
14  const socket = /\sfrom="([^"]+)"/.exec(first)?.[1];
15  const name = /\sfrom-name="([^"]+)"/.exec(first)?.[1];
16  if (socket === undefined || !socket.startsWith("uds:/") || name === undefined) return { kind: "not a reply" };
17  return { kind: "reply", socket, name, body: lines.slice(1, -1).join("\n") };
18}
19
20function destination(sessionId: string): ReplyDestination {
21  return sessionId === "" ? { kind: "unroutable" } : { kind: "session", sessionId };
22}
23
24function routeFile(route: DeliveryRoute, status: DeliveryRouteFile["route"]): DeliveryRouteFile {
25  return {
26    route: status, socket: route.socket, sender: route.sender,
27    reply_to_session_id: route.destination.kind === "session" ? route.destination.sessionId : "",
28    session_id: route.sessionId, at_ms: route.atMs,
29  };
30}
31
32export async function startReplies(calls: PostmasterCalls): Promise<Replies> {
33  const directory = await stateDirectory(calls);
34  const routes = new Map<string, Map<string, DeliveryRoute>>();
35  const pendingWithdrawals = new Map<string, DeliveryRoute>();
36  let routeCount = 0;
37  let replyCount = 0;
38  let forwardCount = 0;
39  const path = (folder: string, key: string) => `${directory}/${folder}/${key}.json`;
40  const inWindow = (atMs: number, now: number) => atMs <= now && atMs >= now - REPLY_WINDOW_MS;
41  const remember = (route: DeliveryRoute): void => {
42    let socketRoutes = routes.get(route.socket);
43    if (socketRoutes === undefined) {
44      socketRoutes = new Map();
45      routes.set(route.socket, socketRoutes);
46    }
47    socketRoutes.set(route.key, route);
48  };
49  const expire = (now: number): void => {
50    for (const [socket, socketRoutes] of routes) {
51      for (const [key, route] of socketRoutes) if (!inWindow(route.atMs, now)) socketRoutes.delete(key);
52      if (socketRoutes.size === 0) routes.delete(socket);
53    }
54  };
55  const delivered = `${directory}/${PATHS.delivered}`;
56  const present = await calls.fs.exists(delivered);
57  const files = await calls.fs.list(delivered).catch(error => {
58    if (present) throw error;
59    return [];
60  });
61  const now = calls.now();
62  for (const file of files) {
63    if (file.kind !== "file" || file.isLink || !/^\d+-\d+\.json$/.test(file.name)) continue;
64    const key = file.name.slice(0, -5);
65    const count = Number(key.slice(key.lastIndexOf("-") + 1));
66    if (Number.isSafeInteger(count)) routeCount = Math.max(routeCount, count);
67    try {
68      const value: unknown = JSON.parse(await calls.fs.read(path(PATHS.delivered, key)));
69      if (!isDeliveryRouteFile(value) || value.route !== "open" || !inWindow(value.at_ms, now)) continue;
70      remember({ key, socket: value.socket, sender: value.sender, destination: destination(value.reply_to_session_id), sessionId: value.session_id, atMs: value.at_ms });
71    } catch { /* An unreadable route cannot identify a reply. */ }
72  }
73
74  return {
75    sending: async (entry, atMs) => {
76      expire(atMs);
77      const route: DeliveryRoute = {
78        key: `${atMs}-${++routeCount}`, socket: entry.socket, sender: entry.sender,
79        destination: destination(entry.reply_to_session_id), sessionId: entry.session_id, atMs,
80      };
81      await calls.fs.write(path(PATHS.delivered, route.key), JSON.stringify(routeFile(route, "open")));
82      remember(route);
83      return { key: route.key };
84    },
85    notDelivered: async token => {
86      let route = pendingWithdrawals.get(token.key);
87      if (route === undefined) {
88        for (const [socket, socketRoutes] of routes) {
89          route = socketRoutes.get(token.key);
90          if (route === undefined) continue;
91          socketRoutes.delete(token.key);
92          if (socketRoutes.size === 0) routes.delete(socket);
93          pendingWithdrawals.set(token.key, route);
94          break;
95        }
96      }
97      if (route === undefined) return;
98      await calls.fs.write(path(PATHS.delivered, token.key), JSON.stringify(routeFile(route, "withdrawn")));
99      pendingWithdrawals.delete(token.key);
100    },
101    receive: async (e: SessionReceiveInput) => {
102      if (e.origin.kind !== "peer") return { kind: "pass" };
103      const reply = wrapper(e.text);
104      if (reply.kind === "not a reply") return { kind: "pass" };
105      const at_ms = calls.now();
106      expire(at_ms);
107      const socketRoutes = routes.get(reply.socket);
108      if (socketRoutes === undefined) return { kind: "pass" };
109      const matching = [...socketRoutes.values()];
110      const senders = [...new Set(matching.map(route => route.sender))];
111      const targets = new Set<string>();
112      for (const route of matching) if (route.destination.kind === "session") targets.add(route.destination.sessionId);
113      const answerTo = senders.length === 1 ? senders.join(", ") : `one of ${senders.join(", ")}`;
114      const key = `${at_ms}-${++replyCount}`;
115      const forwarded: string[] = [];
116      for (const sessionId of targets) {
117        const text = `Message from ${reply.name} (their answer to ${answerTo}, passed on by postmaster) [${at_ms}-${++forwardCount}]:\n\n${reply.body}`;
118        try {
119          const answer = await calls.session.send({ to: { sessionId }, text });
120          if (answer.isDelivered) forwarded.push(sessionId);
121        } catch { /* The answer is still logged and consumed when forwarding fails. */ }
122      }
123      const replying = matching[matching.length - 1];
124      if (replying === undefined) return { kind: "pass" };
125      const log: ReplyLog = { at_ms, socket: reply.socket, name: reply.name, session_id: replying.sessionId, senders, body: reply.body, forwarded };
126      await calls.fs.write(path(PATHS.replies, key), JSON.stringify(log));
127      return { kind: "consumed", reason: "postmaster reply" };
128    },
129  };
130}
131
hooks/protocol.ts 119 lines
1import type { EngineInterface, SessionReceiveInput } from "claude-code";
2
3export const POLL_MS = 500;
4export const BATCH = 20;
5export const VERSION = "0.1.0";
6export const REPLY_WINDOW_MS = 30 * 60 * 1000;
7export const PATHS = { outbox: "outbox", claimed: "claimed", results: "results", heartbeat: "heartbeat.json", errors: "errors", delivered: "delivered", replies: "replies" } as const;
8
9export type OutboxEntry = {
10  id: string;
11  session_id: string;
12  socket: string;
13  text: string;
14  sha256: string;
15  deadline_ms: number;
16  sender: string;
17  reply_to_session_id: string;
18};
19export type OutboxClaim = { id: string; claimed_ms: number; run: string };
20export type OutboxResult = {
21  id: string;
22  outcome: "delivered" | "failed" | "expired" | "withdrawn" | "abandoned";
23  detail: string;
24  sha256: string;
25  claimed_ms: number;
26  finished_ms: number;
27};
28export type Heartbeat = { session_id: string; at_ms: number; version: string; run: string };
29export type PostmasterCalls = {
30  env: Pick<EngineInterface["env"], "get">;
31  fs: Pick<EngineInterface["fs"], "write" | "exists" | "list"> & {
32    read(path: string): Promise<string>;
33  };
34  session: Pick<EngineInterface["session"], "id" | "send">;
35  now(): number;
36  every(ms: number, fn: () => void): { cancel(): void };
37};
38export type Outbox = { poll(): Promise<void>; cancel(): void };
39export type ReplyDestination = { kind: "session"; sessionId: string } | { kind: "unroutable" };
40export type DeliveryRoute = {
41  key: string;
42  socket: string;
43  sender: string;
44  destination: ReplyDestination;
45  sessionId: string;
46  atMs: number;
47};
48export type RouteToken = { key: string };
49export type ReplyDisposition = { kind: "consumed"; reason: "postmaster reply" } | { kind: "pass" };
50export type DeliveryRouteFile = {
51  route: "open" | "withdrawn";
52  socket: string;
53  sender: string;
54  reply_to_session_id: string;
55  session_id: string;
56  at_ms: number;
57};
58export type ReplyLog = {
59  at_ms: number;
60  socket: string;
61  name: string;
62  session_id: string;
63  senders: string[];
64  body: string;
65  forwarded: string[];
66};
67export type Replies = {
68  sending(entry: OutboxEntry, atMs: number): Promise<RouteToken>;
69  notDelivered(token: RouteToken): Promise<void>;
70  receive(e: SessionReceiveInput): Promise<ReplyDisposition>;
71};
72
73function record(value: unknown): value is Record<string, unknown> {
74  return typeof value === "object" && value !== null && !Array.isArray(value);
75}
76function timestamp(value: unknown): value is number {
77  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
78}
79export function isEntryId(value: unknown): value is string {
80  return typeof value === "string" && /^[a-f0-9]{32}$/i.test(value);
81}
82function hash(value: unknown): value is string {
83  return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
84}
85export function isOutboxEntry(value: unknown): value is OutboxEntry {
86  return record(value) && isEntryId(value.id) && typeof value.session_id === "string" && value.session_id !== ""
87    && typeof value.socket === "string" && typeof value.text === "string" && hash(value.sha256) && timestamp(value.deadline_ms)
88    && typeof value.sender === "string" && value.sender !== "" && typeof value.reply_to_session_id === "string";
89}
90export function isOutboxClaim(value: unknown): value is OutboxClaim {
91  return record(value) && isEntryId(value.id) && timestamp(value.claimed_ms) && typeof value.run === "string" && value.run !== "";
92}
93export function isOutboxResult(value: unknown): value is OutboxResult {
94  return record(value) && isEntryId(value.id) && typeof value.outcome === "string"
95    && ["delivered", "failed", "expired", "withdrawn", "abandoned"].includes(value.outcome)
96    && typeof value.detail === "string" && hash(value.sha256) && timestamp(value.claimed_ms) && timestamp(value.finished_ms);
97}
98export function isHeartbeat(value: unknown): value is Heartbeat {
99  return record(value) && typeof value.session_id === "string" && value.session_id !== ""
100    && timestamp(value.at_ms) && typeof value.version === "string" && value.version !== ""
101    && typeof value.run === "string" && value.run !== "";
102}
103export function isDeliveryRouteFile(value: unknown): value is DeliveryRouteFile {
104  return record(value) && (value.route === "open" || value.route === "withdrawn")
105    && typeof value.socket === "string" && value.socket.startsWith("uds:/")
106    && typeof value.sender === "string" && value.sender !== ""
107    && typeof value.reply_to_session_id === "string"
108    && typeof value.session_id === "string" && value.session_id !== "" && timestamp(value.at_ms);
109}
110export function isReplyLog(value: unknown): value is ReplyLog {
111  return record(value) && timestamp(value.at_ms)
112    && typeof value.socket === "string" && value.socket.startsWith("uds:/")
113    && typeof value.name === "string" && value.name !== ""
114    && typeof value.session_id === "string" && value.session_id !== ""
115    && Array.isArray(value.senders) && value.senders.length > 0
116    && value.senders.every((sender: unknown) => typeof sender === "string" && sender !== "") && typeof value.body === "string"
117    && Array.isArray(value.forwarded) && value.forwarded.every((id: unknown) => typeof id === "string" && id !== "");
118}
119