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

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.
$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.| event | runs | returns |
|---|---|---|
classic.SessionStart | sandman recall --hook, docket --hook, sandman report --hook, concurrently, 10 s each | the chain's result plus each one's context, in that order, as additionalContext |
classic.UserPromptSubmit | docket --hook, 10 s | docket's pull as additionalContext |
classic.Stop | docket --hook, 10 s | the chain's result untouched |
classic.Notification (permission_prompt, idle_prompt, agent_needs_input) | docket --hook, 10 s | the chain's result untouched |
classic.SessionEnd | sandman take --hook, 1.5 s — the SessionEnd budget | the 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).
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.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)⟳ <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.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.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)
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.
hooks/perpetual.mjs 359 lines1// 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