SLOPSHOPPER

superheroes

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

bandtoastprocesstimer
★ 1v0.46.1MITupdated 2026-10-26zwrose/superheroes/plugins/superheroes
A shopper browsing a rack in a slop shop
README

superheroes

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.


Getting set up

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.

CommandUse it to…
/superheroes:configureSet 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.


Three heroes run your sessions; four serve inside them

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.

Showrunner — the advisor session

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.

CommandUse it to…
/superheroes:showrunnerRun the advisor session for this project — route work, vet PRs, coordinate releases.
/superheroes:showrunner-handoffHand the advisor seat over deliberately before this session goes dark.
/superheroes:showrunner-resumePick the advisor seat back up from durable state in a new or restarted session.
/superheroes:checkpointWorks 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-versionTake 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-decisionsWalk 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.

Workhorse — the builder session

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.

CommandUse it to…
/superheroes:workhorseBuild a routed issue and take it to a ready-for-review PR.
/superheroes:checkpointWorks 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.

Detective — the diagnosis session

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.

CommandUse it to…
/superheroes:detectiveRun an observe-only diagnosis when the cause is the valuable thing — demonstrate cause, deliver a diagnosis receipt, never fix.

The Architect

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.

CommandUse it to…
/superheroes:architect-discoveryTurn an idea into an owner-approved requirements spec.

Review Crew

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.

CommandUse it to…
/superheroes:review-codeReview an open PR or local branch and auto-fix what it finds — commits locally, never pushes.

Test-Pilot

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.

CommandUse it to…
/superheroes:test-pilot-planSeed test data for a PR/branch and post a checkbox test plan to the PR.
/superheroes:test-pilot-executeDrive the plan in a real browser, record what it observes at each step, and post a results comment before your spot-check.

Guardian — the maintainability guardian

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.

CommandUse it to…
/superheroes:guardianRun a read-only repo-health sweep → a drift report of plain-language consequences with receipts. Never edits, commits, or files.

What holds it together

  • Specs carry intent. An owner-approved spec is the what — the contract a PR is held accountable to. The how stays the builder's: spelled out explicitly in the build brief, checked once before code, and vetted against at the PR. No plan documents, no doc-review treadmills.
  • Review is structurally independent. Cross-vendor panels mean models that didn't write the code review it; the advisor vets every PR with fresh context; merge approval stays with the owner. Maker and checker are never the same mind.
  • configure calibrates once, and every session inherits it.
  • The covenant rides every session. A SessionStart hook injects a distilled operating discipline — never delegate merge approval; claim only what you verified; disclose every degradation; park rather than presume — into every session (see rubric/covenant.md).
  • A worktree guard refuses silent destruction of uncommitted work — on Claude Code. A second hook intercepts git commands that would irrecoverably discard uncommitted changes and points at recoverable alternatives — commit first, stash, or revert a probe edit with an inverse edit. Codex has no such hook wired yet.

What this is not

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.


Multi-host harness

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.


Where this is going

See the roadmap — now a live GitHub Project — for what's planned and in flight, and CONVENTIONS.md for the cross-plugin contracts.

Contributing

Issues and pull requests are welcome. Fork the repo, open a PR, and we'll help get it merged. See CONTRIBUTING.md.

License

MIT © Zach Rose

Source 6 files
hooks/mod/register.ts 836 lines
1// 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}
836
hooks/mod/argv.ts 158 lines
1// 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]
158
hooks/mod/checkpoint-block.ts 66 lines
1// 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}
66
hooks/mod/decide.ts 171 lines
1// 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
171
hooks/mod/resume.ts 17 lines
1// 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'
17
types/index.d.ts 36 lines
1declare 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