SLOPSHOPPER

perpetual

The harness's session hooks (sandman recall/report/take, docket), house-rule guards, the five-hour window clock and the sandman shift band.

newbandguardcommandpromptprocess
v0.2.0no licenseupdated 2026-10-14jgeschwendt/claude/plugins/perpetual
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · perpetual
› fix the failing auth test and add an audit log call ● perpetual: (empty) ● perpetual: (empty) ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by perpetual: perpetual: never force-push — push a new branch or a new commit instead. ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /docket ● perpetual: (empty) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

perpetual

The harness's Claude Code mod: it runs the session hooks that settings.json used to wire, guards tool calls against the house rules, keeps the five-hour window clock, and puts the sandman shift above the prompt.

Behaviour

  • Session hooks — each binary runs from $HOME/.local/bin through $.process.run, with the settings hook's stdin JSON (the classic.<Event> event's e) on stdin, and its output read as a command hook's: a JSON reply's hookSpecificOutput.additionalContext, else plain stdout. A missing binary, a timeout or a non-zero exit is a dim $.ui.log line and adds nothing; no exit code blocks.
eventrunsreturns
classic.SessionStartsandman recall --hook, docket --hook, sandman report --hook, concurrently, 10 s eachthe chain's result plus each one's context, in that order, as additionalContext
classic.UserPromptSubmitdocket --hook, 10 sdocket's pull as additionalContext
classic.Stopdocket --hook, 10 sthe chain's result untouched
classic.Notification (permission_prompt, idle_prompt, agent_needs_input)docket --hook, 10 sthe chain's result untouched
classic.SessionEndsandman take --hook, 1.5 s — the SessionEnd budgetthe chain's result untouched

A classic.* hook's result carries additionalContext on SessionStart, UserPromptSubmit and Stop, not on Notification or SessionEnd (verified 2026-10-14 · Claude Code 2.1.296 ClassicResultFields). Under managed settings an installed mod's classic.* hooks do not run (verified 2026-10-14 · claude plugin validate).

  • Guards — a Bash pkill -f, git checkout … --, git reset --hard, git push --force/-f/--force-with-lease, git stash (not list/show) or git clean at a command position, and a Write/Edit to a *.yml path, are denied with the house rule as the reason, and each denial is trailed: sandman trail append --kind guard. The match is textual: a forbidden command quoted after ;, &, | or ( inside an argument is denied too. An agent.spawn with no model (forks aside) is pinned to opus.
  • Window clock — at session.start and on every session.measure, the five_hour entry of $.session.usage().rateLimits is written to $CLAUDE_WINDOW_FILE, else $XDG_STATE_HOME/claude/window.json, else ~/.local/state/claude/window.json, as {"at","five_hour":{"resets_at","used_percentage"},"session_id"}, resets_at in unix seconds — the statusline's format, read by sandman shift clock. $.fs.write is not atomic, so the file goes to window.json.<session>.tmp and /bin/mv -f renames it. No write without both figures. (verified 2026-10-14 · code.claude.com/docs/en/plugins/mods/reference, Mods API methods)
  • Band above the prompt — one dim line, cut to the band's width: ⟳ <shift> · <theme> · <repo> · <time to handoff> · last: <kind> <rationale>. Read every 15 s from ~/.sandman/shifts/current.json (shift, theme, repo, handoff_by) and the last line of today's (UTC) trail file ~/.sandman/trail/<yyyy>/<mm>/<dd>.jsonl. Either may be missing; with neither, the band is left to the engine. Interactive sessions only.
  • Correction capture — a prompt the person typed at the composer (never a claude -p, plugin or notification prompt) that matches ^no[,.! ], don't/dont, never, stop, i said, wrong or not what i (case-insensitive) runs sandman trail append --kind correction --rationale <first 200 chars> --ref session:<id>, plus --shift $SANDMAN_SHIFT when set. A prompt of three or more tokens whose token set has Jaccard ≥ 0.8 with an earlier prompt of the session is appended instead with the rationale repeated instruction: …. Skipped, and kept out of the repeat comparison: a prompt over 400 characters, one starting # (a shift or breath brief, a pasted document), and the opening prompt of a claude --bg session — dispatch's brief. That prompt reaches the mod as a composer prompt of an interactive terminal session; the one signal telling it apart is the daemon's CLAUDE_CODE_SESSION_KIND=bg (verified 2026-10-14 · Claude Code 2.1.299, probe mod under --bg and interactive). The prompt always continues unchanged.
  • /shift runs sandman shift status, /docket docket list, /trail [n] sandman trail log cut to its last n lines (10 by default). Output is a $.ui.log line Claude never reads; all three run mid-turn.

Installing it

The harness repo is a marketplace (.claude-plugin/marketplace.json); once per machine:

claude plugin marketplace add ~/.grove/code/jgeschwendt/claude/main
claude plugin install perpetual@jgeschwendt

The install enables it under enabledPlugins in the user settings.json — this repo's, through the ~/.claude link — so every session loads it, claude --bg shifts included. A local marketplace is read in place: an edit on the trunk is live from the next session. (verified 2026-10-14 · code.claude.com/docs/en/plugin-marketplaces)

Checking it

claude plugin validate plugins/perpetual --strict
claude plugin test plugins/perpetual

The tests stub $.process.run beneath the mod, so no binary runs; HOME is stubbed to /home/test and each test asserts the argv under /home/test/.local/bin.

Source 1 files
hooks/perpetual.mjs 359 lines
1// perpetual: the harness's session hooks, guards and window clock, plus the shift band.
2
3/** prompts that read as the person correcting the session */
4const CORRECTION = [
5  /^no[,.! ]/i,
6  /\bdon'?t\b/i,
7  /\bi said\b/i,
8  /\bnever\b/i,
9  /\bnot what i\b/i,
10  /\bstop\b/i,
11  /\bwrong\b/i,
12];
13
14/** a command word at the start of a shell command: line start, after `;`, `&`, `|`, `(`, `{` or `$(` */
15const AT = String.raw`(?:^|[;&|({]|\$\()\s*`;
16/** `git` and its global options, then the subcommand */
17const GIT = String.raw`${AT}git(?:\s+-[Cc]\s+\S+|\s+--[\w-]+(?:=\S+)?)*\s+`;
18/** Bash commands the house rules forbid, each with the rule that forbids it */
19const GUARDS = /** @type {[string, string][]} */ ([
20  [
21    String.raw`${AT}pkill\b[^;&|]*\s-[a-z]*f`,
22    "pkill -f matches every process's full argv — kill by resolved PID: `lsof -ti :<port>` or `pgrep -x <name>`, inspect, then `kill <pid>`",
23  ],
24  [
25    String.raw`${GIT}checkout\b[^;&|]*\s--(?:\s|$)`,
26    "never mutate to inspect — `git checkout --` discards every uncommitted change to the path, co-resident work included; undo your own edit by re-editing",
27  ],
28  [
29    String.raw`${GIT}reset\b[^;&|]*\s--hard\b`,
30    "never mutate to inspect — `git reset --hard` discards the working tree; commit, or fast-forward with `git update-ref`",
31  ],
32  [
33    String.raw`${GIT}push\b[^;&|]*\s(?:--force(?:-with-lease)?(?:=\S*)?|-f)(?=\s|$)`,
34    "never force-push — push a new branch or a new commit instead",
35  ],
36  [
37    String.raw`${GIT}stash\b(?!\s+(?:list|show)\b)`,
38    "never `git stash` — the stash stack is shared across worktrees and sessions; set work aside with a WIP commit",
39  ],
40  [
41    String.raw`${GIT}clean\b`,
42    "never mutate to inspect — `git clean` deletes untracked files, co-resident work included",
43  ],
44]).map(([pattern, reason]) => /** @type {const} */ ([new RegExp(pattern, "m"), reason]));
45/** the notification types docket opens a thread for */
46const NOTIFY = ["agent_needs_input", "idle_prompt", "permission_prompt"];
47
48const HOOK_MS = 10000;
49/** past this many characters a prompt is a brief or a pasted document, not a correction */
50const PROMPT_MAX = 400;
51const REFRESH_MS = 15000;
52const REPEAT_JACCARD = 0.8;
53const REPEAT_MIN_TOKENS = 3;
54const TAKE_MS = 1500;
55const TRAIL_DEFAULT_LINES = 10;
56
57/** @type {{ home: string | undefined, isOpened: boolean, last: any, prompts: Set<string>[], shift: any, timer: { cancel(): void } | undefined }} */
58const state = { home: undefined, isOpened: false, last: null, prompts: [], shift: null, timer: undefined };
59
60/**
61 * Appends one trail event; a failure is logged, never raised.
62 * @param {any} $
63 * @param {string} kind
64 * @param {string} rationale
65 */
66async function append($, kind, rationale) {
67  const argv = ["trail", "append", "--kind", kind, "--rationale", rationale];
68  argv.push("--ref", `session:${await $.session.id()}`);
69  const shift = await $.env.get("SANDMAN_SHIFT");
70  if (shift) argv.push("--shift", shift);
71  await run($, "sandman", argv, { timeoutMs: 5000 });
72}
73
74/**
75 * Minutes until `handoff_by`, as `1h05m left`, or `handoff due` once past.
76 * @param {string | undefined} handoffBy
77 * @param {number} now
78 */
79function clockOf(handoffBy, now) {
80  if (!handoffBy) return undefined;
81  const due = Date.parse(handoffBy);
82  if (Number.isNaN(due)) return handoffBy;
83  const minutes = Math.floor((due - now) / 60000);
84  if (minutes <= 0) return "handoff due";
85  const h = Math.floor(minutes / 60);
86  return h > 0 ? `${h}h${String(minutes % 60).padStart(2, "0")}m left` : `${minutes}m left`;
87}
88
89/**
90 * Runs a settings-hook binary on the event's stdin JSON, as a command hook would, and resolves
91 * to the context it adds: `hookSpecificOutput.additionalContext` of a JSON reply, or plain stdout.
92 * @param {any} $
93 * @param {string} bin
94 * @param {string[]} args
95 * @param {object} e
96 * @param {number} timeoutMs
97 * @returns {Promise<string[]>}
98 */
99async function hook($, bin, args, e, timeoutMs = HOOK_MS) {
100  const out = (await run($, bin, args, { stdin: JSON.stringify(e), timeoutMs }))?.trim();
101  if (!out) return [];
102  if (!/^\{[^]*\}$/.test(out)) return [out];
103  const context = parse(out)?.hookSpecificOutput?.additionalContext;
104  return typeof context === "string" && context ? [context] : [];
105}
106
107/** @param {any} $ */
108async function homeOf($) {
109  state.home ??= (await $.env.get("HOME")) ?? "";
110  return state.home;
111}
112
113/**
114 * Jaccard similarity of two token sets.
115 * @param {Set<string>} a
116 * @param {Set<string>} b
117 */
118function jaccard(a, b) {
119  let shared = 0;
120  for (const token of a) if (b.has(token)) shared += 1;
121  return shared / (a.size + b.size - shared);
122}
123
124/** @param {string | undefined} text */
125function parse(text) {
126  if (!text) return null;
127  try {
128    const value = JSON.parse(text);
129    return value && typeof value === "object" ? value : null;
130  } catch {
131    return null;
132  }
133}
134
135/**
136 * Reads a file's text, or `undefined` when it is missing or unreadable.
137 * @param {any} $
138 * @param {string} path
139 */
140async function readText($, path) {
141  try {
142    return await $.fs.read(path);
143  } catch {
144    return undefined;
145  }
146}
147
148/**
149 * Re-reads the shift file and today's trail tail, then asks for a redraw.
150 * @param {any} $
151 */
152async function refresh($) {
153  const home = await homeOf($);
154  const day = new Date(await $.clock.now()).toISOString().slice(0, 10).split("-");
155  const shiftText = await readText($, `${home}/.sandman/shifts/current.json`);
156  const trailText = await readText($, `${home}/.sandman/trail/${day[0]}/${day[1]}/${day[2]}.jsonl`);
157  state.shift = parse(shiftText);
158  state.last = parse(trailText?.trimEnd().split("\n").at(-1));
159  $.ui.invalidate("ui.render");
160}
161
162/**
163 * Runs `~/.local/bin/<bin>`; resolves to its stdout, or `undefined` after a dim log line when it
164 * cannot start, times out or exits non-zero. Never raises: no binary may break a session.
165 * @param {any} $
166 * @param {string} bin
167 * @param {string[]} args
168 * @param {object} [init]
169 */
170async function run($, bin, args, init) {
171  const name = [bin, ...args.slice(0, 2)].join(" ");
172  try {
173    const { exitCode, stderr, stdout } = await $.process.run(
174      [`${await homeOf($)}/.local/bin/${bin}`, ...args],
175      init,
176    );
177    if (exitCode === 0) return stdout;
178    $.ui.log(`perpetual: ${name} exited ${exitCode}: ${stderr.trim().split("\n")[0]}`);
179  } catch (error) {
180    $.ui.log(`perpetual: ${name} failed: ${error}`);
181  }
182  return undefined;
183}
184
185/**
186 * Runs a binary for a command and logs what it printed.
187 * @param {any} $
188 * @param {string} bin
189 * @param {string[]} args
190 * @param {(stdout: string) => string} shape
191 */
192async function show($, bin, args, shape = (stdout) => stdout.trim()) {
193  const stdout = await run($, bin, args);
194  if (stdout !== undefined) $.ui.log(shape(stdout) || "(empty)");
195  return {};
196}
197
198/** @param {string} text */
199function tokensOf(text) {
200  return new Set(
201    text
202      .toLowerCase()
203      .replace(/[^\p{L}\p{N}\s']/gu, " ")
204      .split(/\s+/)
205      .filter(Boolean),
206  );
207}
208
209/**
210 * Persists the five-hour window to `$CLAUDE_WINDOW_FILE`, else `$XDG_STATE_HOME/claude/window.json`,
211 * else `~/.local/state/claude/window.json`, for `sandman shift clock`: written to a per-session `.tmp`
212 * then renamed with `mv`, since `$.fs.write` is not atomic. Skipped without both figures.
213 * @param {any} $
214 */
215async function window($) {
216  const five = (await $.session.usage()).rateLimits.find((/** @type {{ kind: string }} */ limit) => limit.kind === "five_hour");
217  const resetsAt = Date.parse(five?.resetsAt ?? "");
218  if (Number.isNaN(resetsAt) || typeof five.percentUsed !== "number") return;
219  const root = (await $.env.get("XDG_STATE_HOME")) || `${await homeOf($)}/.local/state`;
220  const path = (await $.env.get("CLAUDE_WINDOW_FILE")) || `${root}/claude/window.json`;
221  const id = await $.session.id();
222  const at = new Date(await $.clock.now()).toISOString().replace(/\.\d+Z$/, "Z");
223  const five_hour = { resets_at: Math.floor(resetsAt / 1000), used_percentage: five.percentUsed };
224  try {
225    await $.fs.write(`${path}.${id}.tmp`, JSON.stringify({ at, five_hour, session_id: id }));
226    await $.process.run(["/bin/mv", "-f", `${path}.${id}.tmp`, path]);
227  } catch (error) {
228    $.ui.log(`perpetual: window.json write failed: ${error}`);
229  }
230}
231
232/**
233 * Runs side-effect hooks beside the chain beneath and returns its result untouched.
234 * @param {any} next
235 * @param {any} e
236 * @param {Promise<unknown>[]} runs
237 */
238async function beside(next, e, runs) {
239  return (await Promise.all([next(e), ...runs]))[0];
240}
241
242/**
243 * Runs context hooks beside the chain beneath and adds what they print, in order.
244 * @param {any} next
245 * @param {any} e
246 * @param {Promise<string[]>[]} runs
247 */
248async function context(next, e, runs) {
249  const [result, ...contexts] = await Promise.all([next(e), ...runs]);
250  const additionalContext = [...(result.additionalContext ?? []), ...contexts.flat()];
251  return additionalContext.length ? { ...result, additionalContext } : result;
252}
253
254/** @type {import('claude-code').Register} */
255export const register = (on) => {
256  on("agent.spawn", ($, e, next) => next(e.model || e.fork ? e : { ...e, model: "opus" }));
257
258  on("classic.Notification", { notification_type: NOTIFY }, ($, e, next) =>
259    beside(next, e, [hook($, "docket", ["--hook"], e)]),
260  );
261
262  on("classic.SessionEnd", ($, e, next) =>
263    beside(next, e, [hook($, "sandman", ["take", "--hook"], e, TAKE_MS)]),
264  );
265
266  on("classic.SessionStart", ($, e, next) =>
267    context(next, e, [
268      hook($, "sandman", ["recall", "--hook"], e),
269      hook($, "docket", ["--hook"], e),
270      hook($, "sandman", ["report", "--hook"], e),
271    ]),
272  );
273
274  on("classic.Stop", ($, e, next) => beside(next, e, [hook($, "docket", ["--hook"], e)]));
275
276  on("classic.UserPromptSubmit", ($, e, next) => context(next, e, [hook($, "docket", ["--hook"], e)]));
277
278  on("command.run", { command: "docket" }, ($) => show($, "docket", ["list"]));
279
280  on("command.run", { command: "shift" }, ($) => show($, "sandman", ["shift", "status"]));
281
282  on("command.run", { command: "trail" }, ($, e) => {
283    const n = Number.parseInt(e.args.trim(), 10);
284    const count = Number.isInteger(n) && n > 0 ? n : TRAIL_DEFAULT_LINES;
285    return show($, "sandman", ["trail", "log"], (stdout) =>
286      stdout.trimEnd().split("\n").slice(-count).join("\n"),
287    );
288  });
289
290  on("prompt.submit", async ($, e, next) => {
291    // the owner's live words only: a `claude -p` shift brief's "never …" is no correction, nor is a long or `# `
292    // prompt (a brief, a pasted document), nor a `claude --bg` session's opening prompt — dispatch's brief, which
293    // arrives as a composer prompt of an interactive terminal session; only the daemon's env tells it apart
294    if (e.origin.kind !== "composer") return next(e);
295    const isOpening = !state.isOpened;
296    state.isOpened = true;
297    if (e.text.length > PROMPT_MAX || e.text.startsWith("# ")) return next(e);
298    if (isOpening && (await $.env.get("CLAUDE_CODE_SESSION_KIND")) === "bg") return next(e);
299    const tokens = tokensOf(e.text);
300    const isRepeat =
301      tokens.size >= REPEAT_MIN_TOKENS &&
302      state.prompts.some((seen) => jaccard(seen, tokens) >= REPEAT_JACCARD);
303    state.prompts.push(tokens);
304    const head = e.text.slice(0, 200);
305    if (isRepeat) await append($, "correction", `repeated instruction: ${head}`);
306    else if (CORRECTION.some((pattern) => pattern.test(e.text))) await append($, "correction", head);
307    return next(e);
308  }).catch(($, e, next) => next(e)); // observe-only: a failed capture never holds the prompt
309
310  on("session.measure", async ($, e, next) => (await window($), next(e)));
311
312  on("session.start", async ($, e, next) => {
313    for (const [name, description, argumentHint] of /** @type {[string, string, string?][]} */ ([
314      ["docket", "Lists the docket's threads (log only)."],
315      ["shift", "Shows the current sandman shift (log only)."],
316      ["trail", "Shows the last n trail events, 10 by default (log only).", "[n]"],
317    ]))
318      await $.command.register({ argumentHint, description, immediate: true, name });
319    await window($);
320    if (e.isInteractive && e.surface !== null) {
321      await refresh($);
322      state.timer?.cancel();
323      state.timer = $.clock.every(REFRESH_MS, () => refresh($));
324    }
325    return next(e);
326  });
327
328  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
329    const reason = GUARDS.find(([pattern]) => pattern.test(e.command))?.[1];
330    if (!reason) return next(e);
331    await append($, "guard", `${reason}: ${e.command.slice(0, 200)}`);
332    return { deny: `perpetual: ${reason}.` };
333  });
334
335  on("tool.call", { tool: ["Edit", "Write"] }, async ($, e, next) => {
336    if (!/\.yml$/i.test(e.file_path)) return next(e);
337    const reason = "YAML files end in `.yaml`, never `.yml` — rename on touch";
338    await append($, "guard", `${reason}: ${e.file_path}`);
339    return { deny: `perpetual: ${reason}.` };
340  });
341
342  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
343    const { last, shift } = state;
344    if (e.props.hasSurvey || (!shift && !last)) return next(e);
345    const parts = [
346      shift?.shift,
347      shift?.theme,
348      shift?.repo,
349      clockOf(shift?.handoff_by, await $.clock.now()),
350      last ? `last: ${[last.kind, last.rationale].filter(Boolean).join(" ")}` : undefined,
351    ].filter(Boolean);
352    const width = Math.max(1, e.props.bodyColumns);
353    let line = `⟳ ${parts.join(" · ")}`.replace(/\s+/g, " ");
354    if (line.length > width) line = `${line.slice(0, width - 1)}…`;
355    const { Text } = $.ui.resolve(e);
356    return h(Text, { dimColor: true, wrap: "truncate-end" }, line);
357  });
358};
359