The discipline layer around AI build sessions: Showrunner (advisor), Workhorse (builder), Detective (observe-only diagnosis), The Architect (spec), Review Crew…

A discipline layer for building software with AI sessions.
superheroes doesn't run your builds — your sessions do. It's the set of roles, artifacts, and review structures that let a technical owner delegate real work to AI sessions and ship the result on evidence instead of vibes. It's built for the moderately technical builder — someone who can describe what they want, tell whether the result works, and read code a little. The judgment lives in the structure (brief checks, cross-vendor review, an advisor that vets with fresh eyes), not in the owner's own engineering taste — and every claim traces to a receipt the owner's advisor session can check from the PR alone.
One plugin, one install:
/plugin marketplace add zwrose/superheroes
/plugin install superheroes@superheroes
Compatibility. superheroes is validated on Claude Code ≥ 2.1.219. Its SessionStart bootstrap relies on the harness natively loading project context (project/user CLAUDE.md, memory) on every spawn path — plain chat, headless -p, and slash-command spawn; on an older harness a slash-command-spawned session may silently miss that layer.
Why it exists and where it's going: PHILOSOPHY.md — who superheroes is for, what its owner may trust, and the bets behind it — and ROADMAP.md — the release train delivering those promises.
One command sets up, fixes, or shows & tunes any project's calibration:
/superheroes:configure
Run it first in any project. It senses what the project needs and either sets it up, repairs it, or lets you see the whole project's calibration on one screen and tune a setting — models per role, review engines, test-pilot, storage (in-repo vs. out-of-repo), boundary rules, Risk and trust (the project's threat model and the risk areas that decide how much review each change gets), and cloud builds (off until the owner opts in through a one-time setup sitting) — once, so every session that follows inherits it.
configure also carries the preflight: the checkout a builder session runs, with the owner still present, before it goes autonomous — live-exercising the browser tool, cross-vendor CLI, and gh access rather than trusting a config file, so a stalled approval surfaces now instead of at 2am. See skills/configure/reference/preflight.md.
| Command | Use it to… |
|---|---|
/superheroes:configure | Set up, fix, view, or tune a project's superheroes calibration (run this first). |
Plugin update dialog — cache bookkeeping. Claude Code may list .in_use/ and __pycache__/ entries under the plugin install cache as locally modified files. Those are runtime bookkeeping (session markers and Python bytecode from older installed versions); they are safe to overwrite, and no user content lives in the plugin cache directory. SessionStart best-effort removes stale .in_use markers from the active plugin install; set SUPERHEROES_NO_CACHE_SWEEP to any non-empty value to disable that sweep. SessionStart also runs a read-only scan of other installed version directories under the same cache parent: it never deletes those markers, but may add a one-line bootstrap note asking the advisor to propose a manual cleanup with the owner. SUPERHEROES_NO_CACHE_SWEEP suppresses that sibling scan as well.
Showrunner, Workhorse, and Detective are the three session types you actually launch — one long-lived advisor per project, one active builder per issue, and an observe-only diagnostician when the cause matters more than the fix. The Architect, Review Crew, Test-Pilot, and Guardian serve inside those sessions.
Keeps the project honest at project altitude. One long-lived session per project: it keeps the roadmap and issue board truthful, routes incoming work to one of four intake routes (discovery, detective, build-ready, micro), decomposes big asks into small, independently mergeable issues, drafts each builder's launch prompt as just the command and the issue pointer (everything durable lives in the issue), vets every PR from its artifacts — the diff, the issue/spec, the build brief — against what was asked and what was proposed, diagnoses anomalies from artifacts, and coordinates releases. With several PRs waiting, it drafts their vets in parallel with the vet-drafter agent, on its own model, and still writes every verdict itself. It keeps merge approval with the owner; the merge policy is stated once, in duty 6 of plugins/superheroes/skills/showrunner/SKILL.md. A wave preflight runs the conformance probe once per dispatchable engine before any builder launches. Once a project switches cloud builds on, the advisor launches each build in a cloud session or on the owner's machine, under the same review, vet and merge rules either way.
| Command | Use it to… |
|---|---|
/superheroes:showrunner | Run the advisor session for this project — route work, vet PRs, coordinate releases. |
/superheroes:showrunner-handoff | Hand the advisor seat over deliberately before this session goes dark. |
/superheroes:showrunner-resume | Pick the advisor seat back up from durable state in a new or restarted session. |
/superheroes:checkpoint | Works in any session, charter or not: freshen live state, save the checkpoint notes, and emit a ready-to-paste /compact command. Where mods are present the superheroes mod steers every compaction automatically from those notes; on hosts without mods the PreCompact fallback steers it and the manual paste is the floor. With the mod in an interactive session, or a session in the desktop app's Code tab, it also checkpoints and compacts by itself once the context passes a threshold: a heads-up band above the prompt offers Now or Snooze, and the threshold, the allowance before the harness's own auto-compaction, and auto / click-only / off are the plugin's settings (/config). After the mod's own compaction or a manual /compact, the session resumes on its own. |
/superheroes:adopt-version | Take up a newly installed superheroes version in this live seat — diff, re-read what changed, run the upgrade checklist, and brief the owner — with no handoff. Works in any charter seat. |
/superheroes:discuss-open-decisions | Walk the owner through open decisions that are theirs — standing proposals, open parks, anything pending — filtered to what is genuinely theirs, in three batches: what blocks work first, then decisions not tied to one PR, then one sheet per PR, where a saved Merge verdict plus "done" in the chat is the owner's merge word. |
Takes one routed issue to a ready PR. A disposable session, one active builder per issue: it writes a short build brief (shape, contracts & state, reuse plan, hard seams, rejected alternatives, consequential flags), gets it checked pre-code by a fresh reviewer at comparable tier and from another vendor, then builds test-first with tiered subagents in small diffs, verifies UI work in a real browser via test-pilot, runs multi-model review with every finding dispositioned in the PR body, and hands back a ready PR. That is the full lane; a small, low-risk change the advisor calls light gets the builder's own typing and one independent reviewer, and a change touching one of the project's risk areas, or whose risk is unclear (every change, until the project has done its Risk and trust sitting), always gets the full lane. It runs on the owner's machine or, for a project with cloud builds on, headless in a cloud session. It never merges.
| Command | Use it to… |
|---|---|
/superheroes:workhorse | Build a routed issue and take it to a ready-for-review PR. |
/superheroes:checkpoint | Works in any session, charter or not: freshen live state, save the checkpoint notes, and emit a ready-to-paste /compact command. Where mods are present the superheroes mod steers every compaction automatically from those notes; on hosts without mods the PreCompact fallback steers it and the manual paste is the floor. With the mod in an interactive session, or a session in the desktop app's Code tab, it also checkpoints and compacts by itself once the context passes a threshold: a heads-up band above the prompt offers Now or Snooze, and the threshold, the allowance before the harness's own auto-compaction, and auto / click-only / off are the plugin's settings (/config). After the mod's own compaction or a manual /compact, the session resumes on its own. |
Finds the demonstrated cause when the cause matters more than the fix. A dedicated observe-only session that fires when the cause is unknown, the blast radius is cross-cutting, or a first fix already failed — an ordinary bug stays build-ready. It demonstrates a cause by reproduction or A/B comparison on a disposable copy, never by inference from error text alone. It delivers a diagnosis receipt on the incident issue for the advisor to vet. It never edits the surface under diagnosis and never produces a fix.
| Command | Use it to… |
|---|---|
/superheroes:detective | Run an observe-only diagnosis when the cause is the valuable thing — demonstrate cause, deliver a diagnosis receipt, never fix. |
Turns fuzzy intent into an owner-approved spec. the-architect owns the what, in plain language — never the how, which stays the builder's, spelled out in the build brief. It runs Discovery (eliciting requirements with you, no jargon), which ends one of three ways: an owner-approved spec; a findings record you ratify, when the investigation finds nothing worth specifying; or a park note, when it stops before reaching an answer — never silence.
| Command | Use it to… |
|---|---|
/superheroes:architect-discovery | Turn an idea into an owner-approved requirements spec. |
The multi-model, cross-vendor review layer. It checks the build brief before any code is written and reviews code with an auto-fix loop (review-code). A spec is reviewed by the three spec checks Discovery runs — gap review, the source check both ways, and grounding (see the-architect, above). Panels are composed to be vendor-complementary — models that didn't write the code (or the brief, or the spec) are the ones reviewing it, and no reviewer from a maker's family runs without the owner's word for that change. The full-lane panel follows the lane call: a code reviewer, the test role (which writes break-it tests on the project's risk areas) and a grounding seat (which checks the PR body on a read-only review) on every change, plus security and premortem reviewers when a risk area is touched or risk is unclear. Every review, on every lane, writes one review record on the PR, which the advisor's vet reads; every finding states its consequence, and a leftover with a real consequence comes to the owner.
| Command | Use it to… |
|---|---|
/superheroes:review-code | Review an open PR or local branch and auto-fix what it finds — commits locally, never pushes. |
Behavioral proof that a change actually works — not just that it compiles. It seeds realistic test data, posts a checkbox test plan to the PR, then drives the plan in a real browser and posts a results comment. Observe-only: a bug it finds is a finding, never a fix — fixes always route back to the session that called it in.
| Command | Use it to… |
|---|---|
/superheroes:test-pilot-plan | Seed test data for a PR/branch and post a checkbox test plan to the PR. |
/superheroes:test-pilot-execute | Drive the plan in a real browser, record what it observes at each step, and post a results comment before your spot-check. |
Guards the existential risk for a non-technical owner: unmaintainable AI-spaghetti. A periodic read-only sweep of repo health where deterministic tools detect and one model pass validates each candidate against your project's own conventions, then writes it as a plain consequence with a receipt. It reports drift over a baseline — only what changed since the last sweep, never re-raising settled trades — reaching you through the advisor as consequences to act on, not matrices to interpret. It never edits code, never commits or pushes, never files issues, and never runs or owns enforcement: it recommends; you and the advisor decide. The health lenses (duplication, complexity, coupling, dependency and doc freshness, dead code) roll out across the guardian arc; this is the sweep it runs them in.
| Command | Use it to… |
|---|---|
/superheroes:guardian | Run a read-only repo-health sweep → a drift report of plain-language consequences with receipts. Never edits, commits, or files. |
rubric/covenant.md).superheroes does not execute your build as a fixed sequence of stages, and it is not an orchestration engine — there is no intermediary layer routing sessions through steps on their behalf. There are no gates between an approved issue and a ready PR beyond the ones above. The platform runs the agents; superheroes supplies the judgment structure around them.
The marketplace runs on both Claude Code and Codex. The plugin is the same; only the install command differs.
Claude Code (existing flow):
/plugin marketplace add zwrose/superheroes
/plugin install superheroes@superheroes
Codex:
codex plugin marketplace add zwrose/superheroes
codex plugin add superheroes@superheroes
Skills speak in host-neutral actions and resolve them per host via a thin tool-map (hosts/claude-tools.md / hosts/codex-tools.md inside the plugin). Claude Code is the supported host; Codex loads the skills and runs the discipline layer with no host guarantees.
See the roadmap — now a live GitHub Project — for what's planned and in flight, and CONVENTIONS.md for the cross-plugin contracts.
Issues and pull requests are welcome. Fork the repo, open a PR, and we'll help get it merged. See CONTRIBUTING.md.
MIT © Zach Rose
hooks/mod/register.ts 836 lines1// The superheroes mod (#1814): steers every compaction with the session's charter
2// directive plus its checkpoint notes, saves the checkpoint block a reply ends with
3// (layer 1), and runs the automatic checkpoint: at the trigger it runs
4// /superheroes:checkpoint, and once the notes file changed it compacts (layer 2;
5// the decisions are ./decide), only in an attended session: an interactive one, one the
6// desktop app started, or one with the desktop app on the surface roster; where it declines
7// near or past the trigger it records why (`<id>.skip`). After its own compaction or a manual /compact it
8// submits the resume prompt (./resume), so the session carries on (#1891). Every hook here fails open — on any error the event
9// goes on unchanged (`next(e)` once), no delivered-marker is written, and the
10// PreCompact command hook steers in its place.
11//
12// Two engine facts, measured on harness 2.1.296 (#1814, comment 6134267207).
13// First: the mod's own `session.compact` hook runs for a compaction the mod starts with
14// `$.session.compact()` (`e.trigger === 'plugin'`). That hook is the only place
15// instructions are composed and the marker is marked, so the mod's own compaction
16// passes no instructions and resets no fill itself.
17// Second: `session.measure` fires after the main `turn.complete` (and once at session
18// start), so the idle gate needs no timer to measure again after a turn.
19
20import type { EngineInterface, Register, RenderElement } from 'claude-code'
21
22import { instructionsArgv, markArgv, parseVocabulary, pathArgv, saveArgv, skipArgv, unmarkArgv } from './argv'
23import type { CliVocabulary } from './argv'
24import { extractCheckpointBlock } from './checkpoint-block'
25import {
26 DESKTOP_SURFACE,
27 decide,
28 effectiveTrigger,
29 isAttended,
30 isDesktopStarted,
31 notesChanged,
32 rearmAt,
33 settingsFrom,
34 skipKey,
35 snooze,
36} from './decide'
37import type { Decision, Settings } from './decide'
38import { RESUME_PROMPT, resumesAfter } from './resume'
39
40const MANIFEST = '.claude-plugin/plugin.json'
41
42const CLI_TIMEOUT_MS = 15000
43
44const CHECKPOINT_COMMAND = 'superheroes:checkpoint'
45
46// The compaction waits until the turn has ended.
47const COMPACT_DELAY_MS = 1000
48
49// A checkpoint that never saw its turn complete stops blocking the next one after this.
50const STALE_CHECKPOINT_MS = 30 * 60 * 1000
51
52const transcriptPath = { plugin: 'superheroes', key: 'transcriptPath' } as const
53const isInteractive = { plugin: 'superheroes', key: 'isInteractive' } as const
54const desktopStarted = { plugin: 'superheroes', key: 'isDesktopStarted' } as const
55const isTurnActive = { plugin: 'superheroes', key: 'isTurnActive' } as const
56const checkpoint = { plugin: 'superheroes', key: 'checkpoint' } as const
57const snoozeUntilTokens = { plugin: 'superheroes', key: 'snoozeUntilTokens' } as const
58const band = { plugin: 'superheroes', key: 'band' } as const
59const rearmAfterCompact = { plugin: 'superheroes', key: 'rearmAfterCompact' } as const
60const lastSkip = { plugin: 'superheroes', key: 'lastSkip' } as const
61
62// True while a checkpoint is being started: two measurements must not both start one.
63let isStarting = false
64
65// Counts the session's own compactions that succeeded, bumped by the `session.compact` hook.
66// A plain variable: a rejected state write cannot hide a compaction from it.
67let compactionsDone = 0
68
69// True from a resume prompt's submission until it has entered (or failed): a second
70// compaction meanwhile submits no second one.
71let isResumePending = false
72
73const note = ($: EngineInterface, text: string): void => {
74 try {
75 $.ui.log(`superheroes mod: ${text}`, { to: 'debug' })
76 } catch {
77 // a log line is never worth failing a hook over
78 }
79}
80
81// Runs one argv built by ./argv (the one home of the CLI's command lines); resolves
82// its JSON object only for exit 0 with `ok: true`, otherwise rejects.
83async function directive(
84 $: EngineInterface,
85 argv: readonly string[],
86 stdin?: string,
87): Promise<Record<string, unknown>> {
88 const verb = argv[3] ?? '?'
89 const { exitCode, stdout } = await $.process.run(
90 argv,
91 stdin === undefined ? { timeoutMs: CLI_TIMEOUT_MS } : { timeoutMs: CLI_TIMEOUT_MS, stdin },
92 )
93
94 if (exitCode !== 0) {
95 throw new Error(`${verb} exited ${exitCode}: ${stdout.slice(0, 200)}`)
96 }
97
98 const res: unknown = JSON.parse(stdout)
99
100 if (typeof res !== 'object' || res === null || (res as { ok?: unknown }).ok !== true) {
101 throw new Error(`${verb} answered not ok: ${stdout.slice(0, 200)}`)
102 }
103
104 return res as Record<string, unknown>
105}
106
107// The CLI's vocabulary, read from the file the CLI's own parser is built from. Read per
108// hook call; a missing or malformed file throws, which each caller fails open on.
109const vocabulary = async ($: EngineInterface): Promise<CliVocabulary> =>
110 parseVocabulary(await $.fs.read(`${$.plugin.root}/lib/compaction_cli.json`))
111
112const text = (value: unknown): string | null =>
113 typeof value === 'string' && value !== '' ? value : null
114
115// The settings: the options coerced against the manifest's `userConfig`, read per call
116// like the vocabulary. Null (fail safe: no band, no automatic run) when the manifest
117// cannot answer.
118async function loadSettings($: EngineInterface, options: unknown): Promise<Settings | null> {
119 try {
120 const manifest: { userConfig?: unknown } = JSON.parse(await $.fs.read(`${$.plugin.root}/${MANIFEST}`))
121
122 return settingsFrom(options, manifest.userConfig)
123 } catch (error) {
124 note($, `settings: ${String(error)}`)
125
126 return null
127 }
128}
129
130// The session's checkpoint notes as they stand: null when there is no file, undefined when
131// the path could not be resolved or the file exists but could not be read.
132async function readNotes($: EngineInterface): Promise<string | null | undefined> {
133 let path: string | null
134
135 try {
136 const cli = await vocabulary($)
137
138 path = text((await directive($, pathArgv(cli, $.plugin.root, await $.session.id()))).path)
139 } catch {
140 return undefined
141 }
142 if (path === null) {
143 return null
144 }
145 try {
146 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
147 } catch {
148 return undefined
149 }
150}
151
152// The checkpoint now running, or null (none, or one so old its turn is long lost).
153async function runningCheckpoint($: EngineInterface) {
154 const { value } = await $.state.get(checkpoint)
155
156 if (!value) {
157 return null
158 }
159 if ((await $.clock.now()) - value.startedAt < STALE_CHECKPOINT_MS) {
160 return value
161 }
162 // a lost checkpoint turn must not leave the session busy for good
163 if (value.turnId !== undefined && (await $.state.get(isTurnActive)).value === value.turnId) {
164 await $.state.set(isTurnActive, null)
165 }
166
167 return null
168}
169
170// A checkpoint that could not start or finish: clear it, tell the owner, block nothing.
171async function abandonCheckpoint($: EngineInterface, error: unknown): Promise<void> {
172 note($, `checkpoint: ${String(error)}`)
173 try {
174 await $.state.set(checkpoint, null)
175 $.ui.toast('Auto-checkpoint failed')
176 } catch (cleanup) {
177 note($, `checkpoint cleanup: ${String(cleanup)}`)
178 }
179}
180
181// Runs /superheroes:checkpoint (automatic, or the band's Now). Never starts a second one,
182// and Now waits out a running turn.
183async function startCheckpoint($: EngineInterface, isNow = false): Promise<void> {
184 if (isStarting) {
185 return
186 }
187 isStarting = true
188
189 try {
190 if ((await runningCheckpoint($)) !== null) {
191 return
192 }
193 const notesBefore = await readNotes($)
194 // the fill it started at: the fallback re-arm when the ending fill cannot be read
195 let fill: number | undefined
196
197 try {
198 fill = (await $.session.usage()).context.tokens
199 } catch {
200 // an unread fill only means no fallback snooze when this checkpoint is skipped
201 }
202
203 // a turn may have begun while the notes and fill were read: write nothing then
204 if (typeof (await $.state.get(isTurnActive)).value === 'string') {
205 if (isNow) {
206 $.ui.toast('Auto-checkpoint: wait for the current turn to finish')
207 }
208
209 return
210 }
211
212 await $.state.set(checkpoint, { startedAt: await $.clock.now(), fill, ...(notesBefore === undefined ? {} : { notesBefore }) })
213 await $.state.set(band, false)
214 // not awaited: the call settles with the checkpoint turn, which must not hold a hook
215 $.command.run({ command: CHECKPOINT_COMMAND, args: 'auto' }).catch(error =>
216 abandonCheckpoint($, error),
217 )
218 } catch (error) {
219 await abandonCheckpoint($, error)
220 } finally {
221 isStarting = false
222 }
223}
224
225// Where an automatic re-arm waits: nowhere (null) while the fill is below the trigger, which
226// alone governs then; otherwise `rearmAt`.
227const rearmTo = (
228 tokens: number,
229 trigger: number,
230 breakdown: { isAutoCompactEnabled?: boolean; autoCompactThreshold?: number } | undefined,
231): number | null =>
232 tokens < trigger ? null : rearmAt(tokens, breakdown?.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined)
233
234// The surfaces the session draws on, read afresh at each decision (a client attaches at any
235// time). Null (fail closed: unattended) when the read fails or answers anything but a list.
236async function readSurfaces($: EngineInterface): Promise<readonly unknown[] | null> {
237 try {
238 const surfaces: unknown = await $.session.surfaces()
239
240 return Array.isArray(surfaces) ? surfaces : null
241 } catch (error) {
242 note($, `surfaces: ${String(error)}`)
243
244 return null
245 }
246}
247
248// Why the mod declined, for the owner to read beside the notes (`<id>.skip`, through the CLI): written
249// once per change of the decline's reason and of the side of the trigger the fill is on, from the
250// heads-up line up. `lastSkip` moves only once a write resolved, so a failed write is retried at
251// the next measure; a run or a fill below the heads-up line clears it. Never throws: the decision
252// and the band are already made.
253async function recordSkip(
254 $: EngineInterface,
255 decision: Decision,
256 tokens: number,
257 trigger: number,
258 attendance: { isInteractive: boolean; isDesktopStarted: boolean; surfaces: readonly unknown[] | null },
259): Promise<void> {
260 try {
261 const key = skipKey(decision, tokens, trigger)
262 const { value: last } = await $.state.get(lastSkip)
263
264 if (key === null) {
265 if (typeof last === 'string') {
266 await $.state.set(lastSkip, null)
267 }
268
269 return
270 }
271 if (key === last) {
272 return
273 }
274
275 const cli = await vocabulary($)
276 const record = {
277 at: new Date(await $.clock.now()).toISOString(),
278 reason: decision.reason,
279 tokens,
280 trigger,
281 ...attendance,
282 }
283
284 await directive($, skipArgv(cli, $.plugin.root, await $.session.id()), JSON.stringify(record))
285 await $.state.set(lastSkip, key)
286 } catch (error) {
287 note($, `skip record: ${String(error)}`)
288 }
289}
290
291// Reads the fill, decides, and draws the band from state; starts a checkpoint on `fire`.
292// A failed usage read changes nothing.
293async function evaluate($: EngineInterface, options: unknown): Promise<void> {
294 const settings = await loadSettings($, options)
295
296 if (settings === null) {
297 await $.state.set(band, false)
298
299 return
300 }
301
302 const { context } = await $.session.usage({ breakdown: 'summary' })
303
304 if (context.tokens === undefined) {
305 return
306 }
307
308 const trigger = effectiveTrigger(
309 settings,
310 context.breakdown?.autoCompactThreshold,
311 context.breakdown?.isAutoCompactEnabled ?? false,
312 )
313 // the first measurement after a successful compaction: context must grow from here
314 // before an automatic run repeats, within the harness's own compaction window
315 if ((await $.state.get(rearmAfterCompact)).value === true) {
316 await $.state.set(snoozeUntilTokens, rearmTo(context.tokens, trigger, context.breakdown))
317 await $.state.set(rearmAfterCompact, false)
318 }
319 // read before the idle gate: a stale checkpoint's expiry frees a lost turn's busy mark
320 const isRunning = (await runningCheckpoint($)) !== null
321
322 const snoozed = (await $.state.get(snoozeUntilTokens)).value
323 const attendance = {
324 isInteractive: (await $.state.get(isInteractive)).value === true,
325 isDesktopStarted: (await $.state.get(desktopStarted)).value === true,
326 surfaces: await readSurfaces($),
327 }
328 const decision = decide({
329 tokens: context.tokens,
330 trigger,
331 mode: settings.mode,
332 isIdle: typeof (await $.state.get(isTurnActive)).value !== 'string',
333 isAttended: isAttended(attendance.isInteractive, attendance.isDesktopStarted, attendance.surfaces),
334 isRunning,
335 snoozeUntilTokens: typeof snoozed === 'number' ? snoozed : null,
336 })
337
338 await $.state.set(band, decision.showBand)
339 if (decision.fire) {
340 await startCheckpoint($)
341 }
342 await recordSkip($, decision, context.tokens, trigger, attendance)
343}
344
345// The compaction input with the session's directive and notes as its instructions (the
346// owner's own text rides along inside them). Fails open: on any error the input comes
347// back as it was. The `session.compact` hook is the one caller: every main-loop
348// compaction, the mod's own included, is steered there and nowhere else.
349async function steer<T extends { instructions?: string }>($: EngineInterface, e: T): Promise<T> {
350 let steered = e
351
352 try {
353 const sessionId = await $.session.id()
354 const { value: known } = await $.state.get(transcriptPath)
355 const transcript = text(known)
356 const owner = e.instructions?.trim() ? e.instructions : null
357
358 const cli = await vocabulary($)
359 const res = await directive($, instructionsArgv(cli, $.plugin.root, sessionId, transcript, owner))
360 const instructions = text(res.instructions)
361 const markerPath = text(res.markerPath)
362
363 if (instructions === null || markerPath === null) {
364 throw new Error('instructions answer lacks instructions or markerPath')
365 }
366
367 // Keep the instructions whatever the marker's acknowledgement does: a mark
368 // that published the marker and then died before answering would leave
369 // PreCompact deferring to a delivery that never reached the summarizer.
370 steered = { ...e, instructions }
371
372 // The CLI publishes the marker atomically (temp file + rename): a failed
373 // write leaves no marker, so the PreCompact fallback steers as well.
374 await directive($, markArgv(cli, $.plugin.root, sessionId))
375 } catch (error) {
376 note($, `session.compact: ${String(error)}`)
377 }
378
379 return steered
380}
381
382// The one definition of the mod-presence signal: the mod exports this variable to the
383// session's Bash children with the session's own id as its value. The checkpoint skill reads
384// it back: its own id means the mod runs in THIS session and saves the reply's block; unset or
385// another id means no mod here. Called at session start and after a `/clear` (which starts a
386// new session id without a `session.start`).
387// `$.env.set` needs a literal name, so this call is the one home; the skill cites it.
388const announcePresence = async ($: EngineInterface): Promise<void> => {
389 await $.env.set('SUPERHEROES_COMPACT_MOD', await $.session.id())
390}
391
392// Where a compaction attempt began: the session and the clock before `steer` marks. Null
393// (fail open: no unmark) when either cannot be read.
394async function beginAttempt($: EngineInterface): Promise<{ sessionId: string; since: number } | null> {
395 try {
396 return { sessionId: await $.session.id(), since: await $.clock.now() }
397 } catch (error) {
398 note($, `session.compact attempt: ${String(error)}`)
399
400 return null
401 }
402}
403
404// An attempt that did not complete (the compaction threw or was skipped) leaves the marker
405// `steer` published, which would make the PreCompact fallback stay silent for a delivery that
406// never reached the summarizer. The CLI removes that marker only if it was stamped within the
407// attempt. Scheduled, never awaited: an aborted dispatch aborts the `$.process.run` made in it,
408// but a clock callback's run completes (measured on harness 2.1.296).
409async function unmarkLater($: EngineInterface, attempt: { sessionId: string; since: number } | null): Promise<void> {
410 if (attempt === null) {
411 return
412 }
413 try {
414 const until = await $.clock.now()
415
416 $.clock.after(0, async () => {
417 try {
418 const cli = await vocabulary($)
419
420 await directive($, unmarkArgv(cli, $.plugin.root, attempt.sessionId, attempt.since, until))
421 } catch (error) {
422 note($, `session.compact unmark: ${String(error)}`)
423 }
424 })
425 } catch (error) {
426 note($, `session.compact unmark: ${String(error)}`)
427 }
428}
429
430// After a completed compaction of the mod's own or a manual /compact, tells the session to carry
431// on (#1891): one prompt per compaction, its own turn once the session is idle. Not awaited: the
432// submission settles when the prompt enters, which must not hold the compaction. Fails open.
433function submitResume($: EngineInterface): void {
434 if (isResumePending) {
435 return
436 }
437 isResumePending = true
438 try {
439 $.prompt
440 .submit({ text: RESUME_PROMPT })
441 .catch(error => note($, `resume prompt: ${String(error)}`))
442 .finally(() => {
443 isResumePending = false
444 })
445 } catch (error) {
446 isResumePending = false
447 note($, `resume prompt: ${String(error)}`)
448 }
449}
450
451// The fill is about to drop: a Snooze and the band belong to the old fill.
452async function forgetFill($: EngineInterface): Promise<void> {
453 try {
454 await $.state.set(snoozeUntilTokens, null)
455 await $.state.set(band, false)
456 } catch (error) {
457 note($, `session.compact state: ${String(error)}`)
458 }
459}
460
461// Compacts after the turn that ended the checkpoint; `$.session.compact` rejects while a
462// turn runs, so it waits (a busy turn counts as no attempt, until the checkpoint is stale),
463// and a rejection is tried once more before the owner is told.
464// No instructions here: the `session.compact` hook steers this compaction too. The
465// checkpoint stays set (`compacting`) until this resolves, so a measurement taken at the
466// still-high fill starts no second one; the toast waits for the compaction itself.
467function compactSoon($: EngineInterface, options: unknown, startedAt: number, isRetry = false): void {
468 $.clock.after(COMPACT_DELAY_MS, async () => {
469 let didFail = false
470 let skipReason = ''
471 let isLast = isRetry
472
473 try {
474 const pending = (await $.state.get(checkpoint)).value
475
476 // another compaction already satisfied (or a new checkpoint replaced) this one
477 if (!pending?.compacting || pending.startedAt !== startedAt) {
478 return
479 }
480 if (typeof (await $.state.get(isTurnActive)).value === 'string') {
481 if ((await runningCheckpoint($)) !== null) {
482 compactSoon($, options, startedAt, isRetry)
483
484 return
485 }
486 isLast = true
487 throw new Error('a turn is still running')
488 }
489
490 let res: { skip?: string } | undefined
491
492 if ((await $.state.get(isInteractive)).value === true) {
493 res = await $.session.compact()
494 } else {
495 // a headless (SDK) session rejects `$.session.compact()`: run the /compact command
496 // instead. It resolves without an outcome, but the `session.compact` hook counts a
497 // successful compaction, independent of its checkpoint cleanup (which can fail).
498 const before = compactionsDone
499
500 await $.command.run({ command: 'compact', args: '' })
501 if (compactionsDone === before) {
502 throw new Error('the /compact command did not compact')
503 }
504 }
505
506 // a vetoed compaction resolves with a skip: the context did not shrink
507 if (typeof res?.skip === 'string') {
508 skipReason = ` (${res.skip})`
509 throw new Error(res.skip)
510 }
511 } catch (error) {
512 if (!isLast) {
513 compactSoon($, options, startedAt, true)
514
515 return
516 }
517 note($, `compact: ${String(error)}`)
518 $.ui.toast(`Auto-checkpoint saved the notes but could not compact${skipReason}`)
519 didFail = true
520 }
521 try {
522 // the compaction resolved or gave up: bookkeeping failures here never schedule another one
523 const { value } = await $.state.get(checkpoint)
524
525 await $.state.set(checkpoint, null)
526 if (didFail) {
527 // the next automatic run waits for the fill to grow, as a skipped checkpoint does
528 await rearmFromEnd($, options, value?.fill)
529 } else {
530 $.ui.toast('Checkpointed and compacted')
531 }
532 } catch (error) {
533 note($, `compact state: ${String(error)}`)
534 }
535 })
536}
537
538// Ends the checkpoint the finished main-loop turn belonged to: compacts once the notes
539// file changed, otherwise says why not. A failed save or an unreadable baseline never compacts.
540async function finishCheckpoint($: EngineInterface, options: unknown, saveFailed: boolean, isAborted: boolean, turnId: string): Promise<void> {
541 const { value } = await $.state.get(checkpoint)
542
543 // only the checkpoint command's own turn ends it: a user turn queued ahead of it does not;
544 // a record past STALE_CHECKPOINT_MS is no checkpoint (`runningCheckpoint`'s rule)
545 if (!value || value.compacting || value.turnId !== turnId || (await runningCheckpoint($)) === null) {
546 return
547 }
548
549 // an interrupted checkpoint turn is cancelled, whatever its reply saved
550 if (isAborted) {
551 await endWithoutCompacting($, options, value.fill, 'Auto-checkpoint cancelled: the turn was interrupted')
552
553 return
554 }
555
556 const notesAfter = saveFailed ? null : await readNotes($)
557 const why = saveFailed
558 ? 'the notes could not be saved'
559 : value.notesBefore === undefined
560 ? 'the notes could not be read before the checkpoint'
561 : notesChanged(value.notesBefore, notesAfter ?? null)
562 ? null
563 : 'the notes file did not change'
564
565 if (why === null) {
566 await $.state.set(checkpoint, { ...value, compacting: true })
567 compactSoon($, options, value.startedAt)
568 } else {
569 await endWithoutCompacting($, options, value.fill, `Auto-checkpoint skipped: ${why}; next try after more context`)
570 }
571}
572
573// Snoozes past the fill as it stands now, the checkpoint's own turn included (or this would
574// start again at once), unless it is below the trigger; an unreadable fill falls back to a
575// step past the start fill.
576async function rearmFromEnd($: EngineInterface, options: unknown, startFill: number | undefined): Promise<void> {
577 let until: number | null | undefined = typeof startFill === 'number' ? snooze(startFill) : undefined
578
579 try {
580 const { context } = await $.session.usage({ breakdown: 'summary' })
581
582 if (context.tokens !== undefined) {
583 const settings = await loadSettings($, options)
584 const { isAutoCompactEnabled, autoCompactThreshold } = context.breakdown ?? {}
585 const trigger = settings === null ? 0 : effectiveTrigger(settings, autoCompactThreshold, isAutoCompactEnabled ?? false)
586
587 until = rearmTo(context.tokens, trigger, context.breakdown)
588 }
589 } catch {
590 // keep the fallback
591 }
592 if (until !== undefined) {
593 await $.state.set(snoozeUntilTokens, until)
594 }
595}
596
597// Ends a checkpoint that will not compact: cleared, re-armed, and the owner told.
598async function endWithoutCompacting($: EngineInterface, options: unknown, startFill: number | undefined, message: string): Promise<void> {
599 await $.state.set(checkpoint, null)
600 await rearmFromEnd($, options, startFill)
601 $.ui.toast(message)
602}
603
604export const register: Register = (on, options) => {
605 on('session.start', async ($, e, next) => {
606 try {
607 await $.state.set(isInteractive, e.isInteractive)
608 } catch (error) {
609 note($, `session.start state: ${String(error)}`)
610 }
611
612 // read once: the entrypoint and the inherited child-session marker do not change in a session
613 try {
614 const entrypoint = await $.env.get('CLAUDE_CODE_ENTRYPOINT')
615 const childSession = await $.env.get('CLAUDE_CODE_CHILD_SESSION')
616
617 await $.state.set(desktopStarted, isDesktopStarted(entrypoint, childSession))
618 } catch (error) {
619 note($, `session.start desktop signal: ${String(error)}`)
620 try {
621 // unreadable reads not desktop-started (fail closed)
622 await $.state.set(desktopStarted, false)
623 } catch (cleanup) {
624 note($, `session.start desktop signal state: ${String(cleanup)}`)
625 }
626 }
627
628 try {
629 await announcePresence($)
630 } catch (error) {
631 note($, `session.start: ${String(error)}`)
632 }
633
634 return next(e)
635 }).catch(($, e, next) => next(e))
636
637 on('classic.SessionStart', async ($, e, next) => {
638 try {
639 await $.state.set(transcriptPath, text(e.transcript_path))
640 } catch (error) {
641 note($, `classic.SessionStart: ${String(error)}`)
642 }
643
644 // `/clear` fires no `session.start`: the presence signal must name the new session
645 if (e.source === 'clear') {
646 try {
647 await announcePresence($)
648 } catch (error) {
649 note($, `classic.SessionStart presence: ${String(error)}`)
650 }
651 }
652
653 return next(e)
654 }).catch(($, e, next) => next(e))
655
656 on('session.compact', async ($, e, next) => {
657 // A subagent's own compaction is not the session's: leave it untouched.
658 if (e.agentId) {
659 return next(e)
660 }
661
662 const attempt = await beginAttempt($)
663 const steered = await steer($, e)
664 let result: Awaited<ReturnType<typeof next>>
665
666 try {
667 result = await next(steered)
668 } catch (error) {
669 await unmarkLater($, attempt)
670
671 throw error
672 }
673
674 // published once the compaction is done: a measurement during it sees the old fill;
675 // a skipped one keeps the Snooze and band
676 if ('skip' in result) {
677 await unmarkLater($, attempt)
678 } else {
679 compactionsDone += 1
680 await forgetFill($)
681 try {
682 await $.state.set(rearmAfterCompact, true)
683 // a pending automatic compaction is satisfied by this one
684 if ((await $.state.get(checkpoint)).value?.compacting) {
685 await $.state.set(checkpoint, null)
686 }
687 } catch (error) {
688 note($, `session.compact state: ${String(error)}`)
689 }
690 if (resumesAfter(e.trigger)) {
691 submitResume($)
692 }
693 }
694
695 return result
696 }).catch(($, e, next) => next(e))
697
698 on('session.measure', async ($, e, next) => {
699 try {
700 await evaluate($, options)
701 } catch (error) {
702 note($, `session.measure: ${String(error)}`)
703 }
704
705 return next(e)
706 }).catch(($, e, next) => next(e))
707
708 // the desktop app joining the roster is the moment an attended session begins: no measure
709 // need follow it
710 on('session.attach', { surface: DESKTOP_SURFACE }, async ($, e, next) => {
711 const result = await next(e)
712
713 try {
714 await evaluate($, options)
715 } catch (error) {
716 note($, `session.attach: ${String(error)}`)
717 }
718
719 return result
720 }).catch(($, e, next) => next(e))
721
722 on('turn.start', async ($, e, next) => {
723 // A subagent's turn is not the session's: it never marks the session busy, and never
724 // binds the checkpoint (only the main turn.complete finishes one).
725 if (e.agentId) {
726 return next(e)
727 }
728
729 try {
730 await $.state.set(isTurnActive, e.turnId)
731
732 const running = await runningCheckpoint($)
733
734 // measured on harness 2.1.296: a command's turn.start text carries its <command-name> tag
735 if (
736 running !== null &&
737 running.turnId === undefined &&
738 e.text.includes(`<command-name>/${CHECKPOINT_COMMAND}</command-name>`)
739 ) {
740 await $.state.set(checkpoint, { ...running, turnId: e.turnId })
741 }
742 } catch (error) {
743 note($, `turn.start: ${String(error)}`)
744 }
745
746 return next(e)
747 }).catch(($, e, next) => next(e))
748
749 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
750 let drawn: RenderElement | null = null
751
752 try {
753 if (!e.props.hasSurvey && (await $.state.get(band)).value === true) {
754 const { Box, Button, Text } = $.ui.resolve(e)
755 const headsUp =
756 (await loadSettings($, options))?.mode === 'click-only'
757 ? 'Context is near the checkpoint threshold'
758 : 'Auto-checkpoint soon'
759
760 drawn = Box({
761 children: [
762 Text({ dimColor: true, children: `${headsUp} ` }),
763 Button({ key: 'now', label: 'Now', onPress: () => startCheckpoint($, true) }),
764 Button({
765 key: 'snooze',
766 label: 'Snooze',
767 onPress: async () => {
768 try {
769 const tokens = (await $.session.usage()).context.tokens ?? 0
770
771 await $.state.set(snoozeUntilTokens, snooze(tokens))
772 await $.state.set(band, false)
773 } catch (error) {
774 note($, `snooze: ${String(error)}`)
775 }
776 },
777 }),
778 ],
779 })
780 }
781 } catch (error) {
782 note($, `ui.render: ${String(error)}`)
783 }
784
785 return drawn ?? next(e)
786 }).catch(($, e, next) => next(e))
787
788 on('turn.complete', async ($, e, next) => {
789 const result = await next(e)
790
791 // A subagent's answer is not the session's: its checkpoint block must never
792 // replace the parent's notes (`$.session.id()` is the parent's there too).
793 let warning: string | null = null
794
795 if (!e.agentId) {
796 try {
797 await $.state.set(isTurnActive, null)
798 } catch (error) {
799 note($, `turn.complete state: ${String(error)}`)
800 }
801
802 try {
803 const block = extractCheckpointBlock(e.answer)
804
805 if (block !== null) {
806 try {
807 // The CLI replaces the notes atomically (temp file + rename): a failed
808 // save leaves the last good notes in place.
809 const cli = await vocabulary($)
810
811 await directive($, saveArgv(cli, $.plugin.root, await $.session.id()), block)
812 $.ui.toast('Checkpoint notes saved')
813 } catch (error) {
814 // The skill was told not to write the file (the presence signal is set),
815 // so a failed save must reach the owner: by toast, and by a line shown beneath the
816 // answer (headless output emits no toast).
817 note($, `turn.complete: ${String(error)}`)
818 warning = 'Checkpoint notes NOT saved: the previous notes are still in place'
819 $.ui.toast(warning)
820 }
821 }
822 } catch (error) {
823 note($, `turn.complete: ${String(error)}`)
824 }
825
826 try {
827 await finishCheckpoint($, options, warning !== null, e.isAborted, e.turnId)
828 } catch (error) {
829 await abandonCheckpoint($, error)
830 }
831 }
832
833 return warning === null ? result : { ...result, text: `${result.text}\n\n${warning}` }
834 }).catch(($, e, next) => next(e))
835}
836hooks/mod/argv.ts 158 lines1// The one home of the argv the mod runs against lib/compaction_directive.py. Pure and
2// import-free so the real-CLI test can compile this file alone and execute exactly
3// what the mod would run (lib/tests/test_compaction_mod_argv_1814.py). It spells no verb
4// and no flag: the vocabulary is lib/compaction_cli.json, the definition the CLI's own
5// parser is built from, handed in by the caller.
6
7// One verb of the CLI: its name and the flag each role is spelled with.
8export type CliVerb<Role extends string> = {
9 readonly verb: string
10 readonly flags: Readonly<Record<Role, string>>
11}
12
13export type CliVocabulary = {
14 readonly instructions: CliVerb<'sessionId' | 'transcriptPath' | 'userInstructions'>
15 readonly mark: CliVerb<'sessionId'>
16 readonly path: CliVerb<'sessionId'>
17 readonly save: CliVerb<'sessionId'>
18 readonly skip: CliVerb<'sessionId'>
19 readonly unmark: CliVerb<'sessionId' | 'since' | 'until'>
20}
21
22const isRecord = (value: unknown): value is Record<string, unknown> =>
23 typeof value === 'object' && value !== null && !Array.isArray(value)
24
25const pick = <Role extends string>(
26 verbs: Record<string, unknown>,
27 verb: string,
28 roles: readonly Role[],
29): CliVerb<Role> => {
30 const entry = verbs[verb]
31
32 if (!isRecord(entry)) {
33 throw new Error(`compaction_cli.json lacks the ${verb} verb`)
34 }
35
36 const flags = {} as Record<Role, string>
37
38 for (const role of roles) {
39 const flag = entry[role]
40
41 if (typeof flag !== 'string' || !flag.startsWith('--')) {
42 throw new Error(`compaction_cli.json: ${verb} lacks a flag for ${role}`)
43 }
44 flags[role] = flag
45 }
46
47 return { verb, flags }
48}
49
50// Reads the text of lib/compaction_cli.json; throws on anything but the expected shape.
51export const parseVocabulary = (text: string): CliVocabulary => {
52 const parsed: unknown = JSON.parse(text)
53 const verbs = isRecord(parsed) ? parsed.verbs : undefined
54
55 if (!isRecord(verbs)) {
56 throw new Error('compaction_cli.json lacks a verbs object')
57 }
58
59 return {
60 instructions: pick(verbs, 'instructions', ['sessionId', 'transcriptPath', 'userInstructions']),
61 mark: pick(verbs, 'mark', ['sessionId']),
62 path: pick(verbs, 'path', ['sessionId']),
63 save: pick(verbs, 'save', ['sessionId']),
64 skip: pick(verbs, 'skip', ['sessionId']),
65 unmark: pick(verbs, 'unmark', ['sessionId', 'since', 'until']),
66 }
67}
68
69const base = (pluginRoot: string, verb: string): string[] => [
70 'python3',
71 '-B',
72 `${pluginRoot}/lib/compaction_directive.py`,
73 verb,
74]
75
76export const instructionsArgv = (
77 vocabulary: CliVocabulary,
78 pluginRoot: string,
79 sessionId: string,
80 transcript: string | null,
81 owner: string | null,
82): string[] => {
83 const { verb, flags } = vocabulary.instructions
84 const argv = [...base(pluginRoot, verb), flags.sessionId, sessionId]
85
86 if (transcript !== null) {
87 argv.push(flags.transcriptPath, transcript)
88 }
89 if (owner !== null) {
90 argv.push(flags.userInstructions, owner)
91 }
92
93 return argv
94}
95
96export const markArgv = (
97 vocabulary: CliVocabulary,
98 pluginRoot: string,
99 sessionId: string,
100): string[] => [
101 ...base(pluginRoot, vocabulary.mark.verb),
102 vocabulary.mark.flags.sessionId,
103 sessionId,
104]
105
106// Removes the delivered marker of the attempt that ran from `since` to `until` (epoch ms).
107export const unmarkArgv = (
108 vocabulary: CliVocabulary,
109 pluginRoot: string,
110 sessionId: string,
111 since: number,
112 until: number,
113): string[] => {
114 const { verb, flags } = vocabulary.unmark
115
116 return [
117 ...base(pluginRoot, verb),
118 flags.sessionId,
119 sessionId,
120 flags.since,
121 String(since),
122 flags.until,
123 String(until),
124 ]
125}
126
127export const pathArgv = (
128 vocabulary: CliVocabulary,
129 pluginRoot: string,
130 sessionId: string,
131): string[] => [
132 ...base(pluginRoot, vocabulary.path.verb),
133 vocabulary.path.flags.sessionId,
134 sessionId,
135]
136
137// The checkpoint block body travels on stdin.
138export const saveArgv = (
139 vocabulary: CliVocabulary,
140 pluginRoot: string,
141 sessionId: string,
142): string[] => [
143 ...base(pluginRoot, vocabulary.save.verb),
144 vocabulary.save.flags.sessionId,
145 sessionId,
146]
147
148// The skip record travels on stdin.
149export const skipArgv = (
150 vocabulary: CliVocabulary,
151 pluginRoot: string,
152 sessionId: string,
153): string[] => [
154 ...base(pluginRoot, vocabulary.skip.verb),
155 vocabulary.skip.flags.sessionId,
156 sessionId,
157]
158hooks/mod/checkpoint-block.ts 66 lines1// The one parser of the checkpoint block a session ends its reply with
2// (`skills/checkpoint/SKILL.md` step 4). Pure: no `claude-code` import.
3
4/** The one definition of the checkpoint fence's info string; tests import it, and the skill cites this file. */
5export const CHECKPOINT_INFO = 'superheroes-checkpoint'
6
7// CommonMark: a fence is three or more backticks (info string without a backtick)
8// or three or more tildes; it closes only on the same character, at least as long.
9const OPEN = /^ {0,3}(?:(`{3,})([^`]*)|(~{3,})(.*))$/
10const CLOSE_TICKS = /^ {0,3}(`{3,})[ \t]*$/
11const CLOSE_TILDES = /^ {0,3}(~{3,})[ \t]*$/
12
13/**
14 * The body of the last fenced block whose info string is exactly
15 * `superheroes-checkpoint`, without its fences and without the one trailing
16 * newline; null when there is none, the last one is never closed, its body is
17 * empty, or any non-blank line follows its closing fence (the block must end the reply). The checkpoint fence is three or more backticks and closes on a
18 * line of at least as many; any other fence (backtick or tilde) is walked past
19 * whole, so a checkpoint fence quoted inside it is not a block.
20 */
21export function extractCheckpointBlock(text: string): string | null {
22 const lines = text.split(/\r?\n/)
23 let last: { body: string[]; isClosed: boolean; end: number } | null = null
24
25 for (let i = 0; i < lines.length; i += 1) {
26 const open = OPEN.exec(lines[i] ?? '')
27 if (open === null) {
28 continue
29 }
30
31 const isTilde = open[3] !== undefined
32 const width = (open[1] ?? open[3] ?? '').length
33 const info = (isTilde ? open[4] : open[2]) ?? ''
34 const closer = isTilde ? CLOSE_TILDES : CLOSE_TICKS
35 const body: string[] = []
36 let isClosed = false
37
38 for (i += 1; i < lines.length; i += 1) {
39 const line = lines[i] ?? ''
40 const close = closer.exec(line)
41 if (close !== null && (close[1] ?? '').length >= width) {
42 isClosed = true
43 break
44 }
45 body.push(line)
46 }
47
48 if (!isTilde && info.trim() === CHECKPOINT_INFO) {
49 last = { body, isClosed, end: i }
50 }
51 }
52
53 if (last === null || !last.isClosed) {
54 return null
55 }
56
57 // The skill's contract: the block is the LAST thing in the reply.
58 if (lines.slice(last.end + 1).some((line) => line.trim() !== '')) {
59 return null
60 }
61
62 const body = last.body.join('\n')
63
64 return body.trim() === '' ? null : body
65}
66hooks/mod/decide.ts 171 lines1// The decision logic of the automatic checkpoint (#1814 layer 2): pure, no engine
2// imports, and the one home of every threshold rule. register.ts reads the engine
3// and acts on what this answers.
4
5// The manifest's `userConfig.autoCheckpointMode.options` is the roster of modes; the code
6// names only the two whose behavior it implements, and any other valid mode is band-only.
7// These spellings are owners' saved setting values, fixed outside the repository: the
8// CONVENTIONS §11.3 exception.
9export const AUTO_MODE = 'auto'
10export const OFF_MODE = 'off'
11
12export type Settings = { thresholdTokens: number; allowanceTokens: number; mode: string }
13
14// How far below the trigger the heads-up band appears, and how far past the
15// current fill a Snooze pushes the next automatic run.
16export const HEADS_UP_TOKENS = 50000
17export const SNOOZE_STEP_TOKENS = 100000
18
19// The lowest trigger there is, whatever the settings or the window say: a tiny
20// window must not fire a checkpoint on every turn.
21export const MIN_TRIGGER_TOKENS = 50000
22
23type Spec = { default?: unknown; min?: unknown; max?: unknown; options?: unknown }
24
25const asRecord = (value: unknown): Record<string, unknown> =>
26 typeof value === 'object' && value !== null ? { ...value } : {}
27
28const specOf = (schema: unknown, key: string): Spec => asRecord(asRecord(schema)[key])
29
30// The setting's number: the raw value when it lies in the spec's [min, max], else the
31// spec's default; null when the spec itself is no usable number setting.
32const numberSetting = (raw: unknown, { default: dflt, min, max }: Spec): number | null => {
33 const fits = (v: unknown): v is number =>
34 typeof v === 'number' && typeof min === 'number' && typeof max === 'number' && v >= min && v <= max
35
36 return fits(raw) ? raw : fits(dflt) ? dflt : null
37}
38
39// Coerces the plugin's options to settings. The manifest's `userConfig` (the parsed
40// object, passed as `schema`) is the one home of defaults, bounds and modes: anything
41// missing, malformed or out of range takes its default; the allowance is kept as is
42// (headroom before the harness compacts, not a share of the threshold). Null when the schema cannot answer: the caller then runs nothing.
43export const settingsFrom = (options: unknown, schema: unknown): Settings | null => {
44 const raw = asRecord(options)
45 const thresholdTokens = numberSetting(raw.autoCheckpointTokens, specOf(schema, 'autoCheckpointTokens'))
46 const allowance = numberSetting(raw.autoCheckpointAllowance, specOf(schema, 'autoCheckpointAllowance'))
47 const { default: dflt, options: modes } = specOf(schema, 'autoCheckpointMode')
48 const known = Array.isArray(modes) ? (modes as unknown[]) : []
49 const mode = known.includes(raw.autoCheckpointMode) ? raw.autoCheckpointMode : dflt
50
51 if (thresholdTokens === null || allowance === null || !known.includes(mode)) {
52 return null
53 }
54
55 return { thresholdTokens, allowanceTokens: allowance, mode: mode as string }
56}
57
58// The fill at which the checkpoint runs. With the harness's own auto-compaction on
59// and its threshold known, the checkpoint must land an allowance before it:
60// min(threshold, autoCompactThreshold - allowance). Floored at MIN_TRIGGER_TOKENS.
61export const effectiveTrigger = (
62 settings: Settings,
63 autoCompactThreshold: number | undefined,
64 isAutoCompactEnabled: boolean,
65): number => {
66 const window =
67 isAutoCompactEnabled && autoCompactThreshold !== undefined && Number.isFinite(autoCompactThreshold)
68 ? autoCompactThreshold - settings.allowanceTokens
69 : Infinity
70
71 return Math.max(MIN_TRIGGER_TOKENS, Math.min(settings.thresholdTokens, window))
72}
73
74// The surface of the desktop app, whose Code tab hosts the session as an SDK host (so
75// `session.start` reports it not interactive): its presence on the roster is attendance.
76export const DESKTOP_SURFACE = 'desktop'
77
78// The harness's spellings of the entrypoint a session the desktop app starts carries (the second
79// is the desktop app under a third-party provider), fixed outside the repository: the
80// CONVENTIONS §11.3 exception, pinned by a literal test.
81export const DESKTOP_ENTRYPOINTS: readonly string[] = ['claude-desktop', 'claude-desktop-3p']
82
83// bite-proof axis: a launcher-spawned `-p` builder (it inherits the entrypoint, and carries the child marker) and any other host stay unattended
84export const isDesktopStarted = (entrypoint: unknown, childSession: unknown): boolean =>
85 typeof entrypoint === 'string' &&
86 DESKTOP_ENTRYPOINTS.includes(entrypoint) &&
87 (childSession === undefined || childSession === '')
88
89// bite-proof axis: a non-interactive session counts as attended when the desktop app started it, or with 'desktop' on the roster
90export const isAttended = (
91 isInteractive: boolean,
92 isDesktopStarted: boolean,
93 surfaces: readonly unknown[] | null,
94): boolean =>
95 isInteractive === true ||
96 isDesktopStarted === true ||
97 (Array.isArray(surfaces) && surfaces.includes(DESKTOP_SURFACE))
98
99export type DecideInput = {
100 tokens: number
101 trigger: number
102 mode: string
103 isIdle: boolean
104 isAttended: boolean
105 isRunning: boolean
106 snoozeUntilTokens: number | null
107}
108
109export type Decision = { showBand: boolean; fire: boolean; reason: string }
110
111// `reason` names the gate that decided: off, headless (no person attached: no band and no
112// run, in every mode), running, below-heads-up, click-only, busy, below-trigger, snoozed,
113// or fire.
114export const decide = (input: DecideInput): Decision => {
115 const { tokens, trigger, mode, isIdle, isRunning, snoozeUntilTokens } = input
116
117 if (mode === OFF_MODE) {
118 return { showBand: false, fire: false, reason: 'off' }
119 }
120 if (!input.isAttended) {
121 return { showBand: false, fire: false, reason: 'headless' }
122 }
123 if (isRunning) {
124 return { showBand: false, fire: false, reason: 'running' }
125 }
126 if (tokens < trigger - HEADS_UP_TOKENS) {
127 return { showBand: false, fire: false, reason: 'below-heads-up' }
128 }
129
130 const band = (fire: boolean, reason: string): Decision => ({ showBand: true, fire, reason })
131
132 if (mode !== AUTO_MODE) {
133 return tokens < (snoozeUntilTokens ?? 0)
134 ? { showBand: false, fire: false, reason: 'snoozed' }
135 : band(false, 'click-only')
136 }
137 if (!isIdle) {
138 return band(false, 'busy')
139 }
140 if (tokens < trigger) {
141 return band(false, 'below-trigger')
142 }
143 if (tokens < (snoozeUntilTokens ?? 0)) {
144 return band(false, 'snoozed')
145 }
146
147 return band(true, 'fire')
148}
149
150// The key of a skip record: the decline's reason and which side of the trigger the fill is on.
151// Null when the mod ran, or the fill is below the heads-up line (nothing to record).
152export const skipKey = (decision: Decision, tokens: number, trigger: number): string | null =>
153 decision.fire || tokens < trigger - HEADS_UP_TOKENS
154 ? null
155 : `${decision.reason}:${tokens >= trigger ? 'past' : 'near'}`
156
157export const snooze = (tokens: number): number => tokens + SNOOZE_STEP_TOKENS
158
159// Where an automatic re-arm waits for: a step past the fill, but never beyond the middle of
160// the headroom left before the harness compacts (`autoCompactThreshold`: known only while its
161// auto-compact is on), so a checkpoint still runs first. The owner's Snooze keeps `snooze`.
162export const rearmAt = (fill: number, autoCompactThreshold: number | undefined): number =>
163 autoCompactThreshold !== undefined && autoCompactThreshold > fill
164 ? Math.min(snooze(fill), fill + Math.max(1, Math.floor((autoCompactThreshold - fill) / 2)))
165 : snooze(fill)
166
167// The changed-file gate: a checkpoint counts only when the notes file now holds
168// text that differs from what it held before.
169export const notesChanged = (before: string | null, after: string | null): boolean =>
170 typeof after === 'string' && after !== '' && after !== before
171hooks/mod/resume.ts 17 lines1// The prompt the mod submits once a compaction it started, or a manual /compact, has completed
2// in the main conversation, so the compacted session carries on (#1891). Pure: its one `claude-code` import is a type.
3
4import type { SessionCompactTrigger } from 'claude-code'
5
6/** The one definition of the resume prompt's text, as the owner agreed it (#1891); tests import it. */
7export const RESUME_PROMPT =
8 'Resume: this session was just compacted. Re-read your resume point (your checkpoint notes, and your charter if this session has one), then continue where you left off.'
9
10/**
11 * Whether a completed main-conversation compaction gets the resume prompt: the mod's own
12 * (`plugin`) and a manual /compact do; the harness's mid-turn `auto` compaction already
13 * carries on, and a `precompute` one is no compaction of the conversation yet.
14 */
15export const resumesAfter = (trigger: SessionCompactTrigger): boolean =>
16 trigger === 'plugin' || trigger === 'manual'
17types/index.d.ts 36 lines1declare module 'claude-code' {
2 interface PluginState {
3 superheroes: {
4 transcriptPath: string | null
5 // from session.start: false for `claude -p` and the SDK; attendance also counts a session the
6 // desktop app started (`isDesktopStarted`) and the desktop app on the surface roster
7 // (`isAttended` in ../hooks/mod/decide), and only an attended session runs anything automatically
8 isInteractive: boolean
9 // from session.start: the desktop app started this session (its entrypoint, and no inherited
10 // child-session marker); false when either variable could not be read
11 isDesktopStarted: boolean
12 // the running turn's id, from turn.start to the main loop's turn.complete (null: idle)
13 isTurnActive: string | null
14 // set while an automatic or Now checkpoint runs (and through the compaction it ends in,
15 // `compacting`); the notes as they stood before it: null = no file, absent = unreadable;
16 // `fill`: the context fill it started at, the fallback for its re-arm;
17 // `turnId`: the turn running its command, once seen
18 checkpoint: {
19 startedAt: number
20 fill?: number
21 turnId?: string
22 notesBefore?: string | null
23 compacting?: boolean
24 } | null
25 // the fill below which a Snooze holds the automatic run back
26 snoozeUntilTokens: number | null
27 // set when a compaction succeeded: the next measurement snoozes to its fill, once
28 rearmAfterCompact: boolean
29 // the last decision's showBand: the band draws from this
30 band: boolean
31 // the `skipKey` of the last skip record written; null after a run or below the heads-up line
32 lastSkip: string | null
33 }
34 }
35}
36