SLOPSHOPPER

Context Canary

A context canary with an animated cage, configurable sentinel and automatic recovery.

newbandcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-canary
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ context-canary │ ⏺ Read(src/auth.ts) │ The canary died: the final reply did not │ ⎿ Read 6 lines │ start with "🐤". Automatic compaction │ ⏺ Update(src/auth.ts) │ queued for after the turn and any │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM The canary died: the final reply did not start with "🐤". Automatic compaction queued for after the turn and any background agents. › /canary ⎿ context-canary: Canary dead · word: "🐤" · streak: 0 · checked: 1 ⎿ context-canary: Automatic compaction queued for after the turn and any background agents. ⎿ context-canary: Canary revived; streak reset. ⟨Claude Code's own drawing⟩ ▄▄▄▀▀▀▀▄▄▄ ▄▀▄ ▀▀▀▄▄▄▀▄ ▀ ▀▄▀▀▀▀▀▀ ▀ ▀ ▀▄▀▄▄▄▀▄▀▄▄▀▄▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▄▄▄▀▀▀▀▄▄▄ ▄▀▄ ▀▀▀▄▄▄▀▄ ▀ ▀▄▀▀▀▀▀▀ ▀ ▀ ▀▄▀▄▄▄▀▄▀▄▄▀▄▀
README

Context Canary 1.3.3

A pixel-art context canary for Claude Code 2.1.293. MIT licensed.

Use /config to choose word (default 🐤), language (en default, or es), autoCompact (default on), cooldownMinutes (default 30), size (tiny, small default, normal, large), info (none default: only the bird; status; details), checkpoints (0–5) and target (global default, or project for the project's CLAUDE.md).

  • /canary setup: asks before adding/updating the marked canary rule in your ~/.claude/CLAUDE.md. Restart the session to read newly added instructions.
  • /canary: status, then revival. /canary status: read-only status.
  • /canary log: the last deaths across sessions, with project, outcome and missing checkpoints.
  • /canary revive: reset health/counters; keep recovery loop protection.
  • /canary remove: asks before removing only the managed rule.
  • /canario is an alias, with estado, revivir, configurar, quitar aliases.

A missing sentinel in a final main interactive reply kills the bird. It toasts, then compacts after the turn, asking to preserve user instructions. Success revives; skip/failure stays dead. A repeated death within the window locks recovery until /clear or a new session. Set autoCompact off for alerts only. This is a heuristic, not proof of lost context or a guarantee of recovery.

No audio. Subagents, aborted turns and non-interactive runs are ignored. The terminal draws a half-block Raster, Desktop an Svg of the same pixels, with a one-line layout when the sprite does not fit. The tests check render trees and stubbed compaction, not real app pixels or a live model. State survives module reloads, not process restarts.

claude plugin validate .
claude plugin test .

The repository's English and Spanish READMEs contain installation, behavior and CI details. No external service or remote repository is required by this plugin.

Source 6 files
hooks/register.js 537 lines
1import { atom, read, update } from 'claude-code'
2import { translate } from './i18n.js'
3import { rasterCells, svgSource } from './pixels.js'
4import { PALETTE, SPRITES } from './sprites.js'
5import { CHECKPOINT, missingCheckpoints, pickCodes, placeCheckpoints, readCheckpoints } from './checkpoints.js'
6
7/** @typedef {import('claude-code').PluginState['context-canary']['canary']} Canary */
8/** @typedef {{ word: string, autoCompact: boolean, cooldownMinutes: number, language: 'en'|'es', size: 'tiny'|'small'|'normal'|'large', info: 'none'|'status'|'details', checkpoints: number, target: 'global'|'project' }} Config */
9/** @typedef {import('./checkpoints.js').Checkpoint} Checkpoint */
10/** @typedef {{ at: number, project: string, response: number, preview: string, turnId: string, lost?: { n: number, heading: string }[], outcome: string }} Death */
11/** @typedef {{ checkpoints: Checkpoint[], agents: Set<string>, cwd: string, interactive: boolean, activeTurn: string|null, timer: import('claude-code').Timer|null, recoveryTimer: import('claude-code').Timer|null, epoch: number, inFlight: boolean, beat: number, pose: string }} Runtime */
12
13/** @returns {Canary} */
14const fresh = () => ({ alive: true, responses: 0, streak: 0, lastTurnId: null, death: null,
15  lastAutoCompactAt: null, blocked: false, recovery: 'idle', detail: '' })
16const canary = atom({ plugin: 'context-canary', key: 'canary' }, fresh())
17const BEGIN = '<!-- context-canary:start -->'
18const END = '<!-- context-canary:end -->'
19const LOG_SIZE = 30
20/** Compact anyway after this long, in case a subagent's stop never arrives. */
21const AGENT_WAIT_MS = 60 * 60_000
22/** How each death ended, for /canary log; anything else (skipped, failed, notify only) reads as dead. */
23const OUTCOMES = /** @type {const} */ ({ recovered: 'outcomeRecovered', revived: 'outcomeRevived', blocked: 'outcomeBlocked',
24  pending: 'outcomePending', compacting: 'outcomePending' })
25
26/** @param {import('claude-code').PluginOptions} options @returns {Config} */
27export function configuration(options = {}) {
28  const word = typeof options.word === 'string' ? options.word.trim() : ''
29  return {
30    word: word && Array.from(word).length <= 64 && !/[\p{C}\r\n]/u.test(word) ? word : '🐤',
31    autoCompact: options.autoCompact !== false,
32    cooldownMinutes: typeof options.cooldownMinutes === 'number' && Number.isFinite(options.cooldownMinutes)
33      ? Math.max(0, Math.min(10080, options.cooldownMinutes)) : 30,
34    language: options.language === 'es' ? 'es' : 'en',
35    size: typeof options.size === 'string' && ['tiny', 'small', 'normal', 'large'].includes(options.size) ? /** @type {Config['size']} */ (options.size) : 'small',
36    info: options.info === 'status' || options.info === 'details' ? options.info : 'none',
37    checkpoints: typeof options.checkpoints === 'number' && Number.isFinite(options.checkpoints)
38      ? Math.max(0, Math.min(5, Math.round(options.checkpoints))) : 0,
39    target: options.target === 'project' ? 'project' : 'global',
40  }
41}
42
43/** @param {string} text */
44const normalize = (text) => text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
45
46// Skip formatting, quotes, emoji and punctuation, but never skip a word.
47// Compare before skipping: an emoji sentinel must not disappear with the prefix.
48/** @param {string} answer @param {string} [word] */
49export function isAlive(answer, word = '🐤') {
50  const text = normalize(answer)
51  const sentinel = normalize(word)
52  if (!sentinel) return false
53  for (let index = 0; index < text.length;) {
54    if (text.startsWith(sentinel, index)) {
55      const rest = text.slice(index + sentinel.length)
56      if (!/[\p{L}\p{N}_]$/u.test(sentinel) || !/^[\p{L}\p{N}_]/u.test(rest)) return true
57      return false
58    }
59    const char = String.fromCodePoint(text.codePointAt(index) ?? 0)
60    if (/[\p{L}\p{N}]/u.test(char) || !/[\s\p{P}\p{S}\u200d]/u.test(char)) return false
61    index += char.length
62  }
63  return false
64}
65
66/** @param {string} answer */
67export function excerpt(answer) {
68  const text = answer.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
69    .replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').replace(/\s+/g, ' ').trim()
70  const chars = Array.from(text)
71  return chars.length > 80 ? chars.slice(0, 79).join('') + '…' : text
72}
73
74/** @param {Config} config @param {keyof typeof import('./i18n.js').translations.en} key @param {Record<string, string|number>} [values] */
75const t = (config, key, values = {}) => translate(config.language, key, { word: JSON.stringify(config.word), ...values })
76
77/** @param {string} text */
78const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1)
79
80/** @param {Config} config @param {Canary} state */
81function note(config, state) {
82  return state.recovery === 'idle' ? '' : t(config, state.recovery, { reason: state.detail })
83}
84
85/** @param {Config} config @param {{ n: number, heading: string, where?: 'project' }} c */
86const checkpointItem = (config, c) => t(config, 'checkpointItem', { n: c.n, heading: c.heading || t(config, 'noHeading') }) +
87  (c.where === 'project' ? t(config, 'inProject') : '')
88
89/** @param {Config} config @param {{ n: number, heading: string, where?: 'project' }[]} lost */
90function lostList(config, lost) {
91  return lost.map((c) => checkpointItem(config, c)).join(', ')
92}
93
94/** @param {Config} config @param {Canary} state */
95function status(config, state) {
96  return t(config, 'status', { health: t(config, state.alive ? 'alive' : 'dead'),
97    streak: state.streak, responses: state.responses }) + (note(config, state) ? '\n' + note(config, state) : '')
98}
99
100/** @param {Runtime} runtime */
101function stopClock(runtime) {
102  runtime.timer?.cancel()
103  runtime.timer = null
104}
105
106/** @param {Runtime} runtime */
107function cancelRecovery(runtime) {
108  runtime.recoveryTimer?.cancel()
109  runtime.recoveryTimer = null
110  runtime.epoch += 1
111}
112
113/** @param {import('claude-code').EngineInterface} $ @param {Runtime} runtime @param {boolean} alive */
114function startClock($, runtime, alive) {
115  stopClock(runtime)
116  runtime.beat = 0
117  runtime.pose = 'idle'
118  if (!runtime.interactive) return
119  if (!alive) {
120    runtime.timer = $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
121    return
122  }
123  runtime.timer = $.clock.every(1000, () => {
124    runtime.beat = (runtime.beat + 1) % 8
125    const nextPose = runtime.beat === 2 ? 'blink' : runtime.beat === 4 ? 'chirp' : runtime.beat === 5 ? 'hop' : 'idle'
126    if (runtime.pose !== nextPose) {
127      runtime.pose = nextPose
128      $.ui.invalidate('ui.render')
129    }
130  })
131}
132
133/** @param {import('claude-code').EngineInterface} $ @param {Runtime} runtime @param {Config} config @param {boolean} automatic */
134async function recovered($, runtime, config, automatic) {
135  const at = await $.clock.now()
136  let changed = false
137  /** @type {Canary['death']} */
138  let death = null
139  await update($, canary, /** @returns {Canary} */ (state) => {
140    changed = !state.alive && !state.blocked
141    death = state.death
142    return changed ? { ...state, alive: true, streak: 0, death: null, recovery: 'recovered', detail: '',
143      lastAutoCompactAt: automatic ? at : state.lastAutoCompactAt } : state
144  })
145  if (!changed) return
146  cancelRecovery(runtime)
147  startClock($, runtime, true)
148  await logOutcome($, death, 'recovered')
149  $.ui.toast(t(config, 'recovered'), { timeoutMs: 8000 })
150}
151
152// Never await compaction in turn.complete: that dispatch is still part of the
153// running turn. A timer yields to the host; a newly started turn postpones it.
154// Background subagents keep working after the turn ends and report back into this conversation, so the
155// recovery waits for them too (seen 2026-10-09: a death while three background agents were still running).
156/** @param {import('claude-code').EngineInterface} $ @param {Runtime} runtime @param {Config} config @param {number} [delay] */
157function scheduleRecovery($, runtime, config, delay = 100) {
158  const epoch = runtime.epoch
159  runtime.recoveryTimer = $.clock.after(delay, async () => {
160    runtime.recoveryTimer = null
161    if (!runtime.interactive || runtime.epoch !== epoch) return
162    const state = await read($, canary)
163    if (state.alive || state.blocked || state.recovery !== 'pending') return
164    if (runtime.activeTurn !== null || runtime.inFlight) {
165      scheduleRecovery($, runtime, config)
166      return
167    }
168    if (runtime.agents.size && (await $.clock.now()) - (state.death?.at ?? 0) < AGENT_WAIT_MS) {
169      scheduleRecovery($, runtime, config, 5000)
170      return
171    }
172    runtime.inFlight = true
173    try {
174      await update($, canary, /** @returns {Canary} */ (s) => ({ ...s, recovery: 'compacting' }))
175      const result = await $.session.compact({ instructions: t(config, 'instructions', { rule: t(config, 'rule') }) })
176      if (runtime.epoch !== epoch || !runtime.interactive) return
177      if (result.skip !== undefined) {
178        const detail = excerpt(result.skip)
179        await update($, canary, /** @returns {Canary} */ (s) => ({ ...s, recovery: 'skipped', detail }))
180        await logOutcome($, (await read($, canary)).death, 'skipped')
181        $.ui.toast(t(config, 'skipped', { reason: detail }), { timeoutMs: 8000 })
182      } else {
183        await recovered($, runtime, config, true)
184      }
185    } catch (error) {
186      if (runtime.epoch !== epoch || !runtime.interactive) return
187      const detail = excerpt(String(error))
188      await update($, canary, /** @returns {Canary} */ (s) => ({ ...s, recovery: 'failed', detail }))
189      await logOutcome($, (await read($, canary)).death, 'failed')
190      $.ui.toast(t(config, 'failed', { reason: detail }), { timeoutMs: 8000 })
191    } finally {
192      runtime.inFlight = false
193    }
194  })
195}
196
197/** @param {string} text */
198function blockRange(text) {
199  const start = text.indexOf(BEGIN)
200  const end = text.indexOf(END)
201  if (start === -1 && end === -1) return null
202  if (start === -1 || end < start || text.indexOf(BEGIN, start + BEGIN.length) !== -1 ||
203      text.indexOf(END, end + END.length) !== -1) return false
204  return { start, end: end + END.length }
205}
206
207/** @param {import('claude-code').EngineInterface} $ @param {Runtime} runtime */
208async function projectRoot($, runtime) {
209  try { return (await $.session.root()) || runtime.cwd } catch { return runtime.cwd }
210}
211
212/** @param {string} path */
213const basename = (path) => path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
214
215/**
216 * The instruction files the canary reads: the global one, and the project's own CLAUDE.md.
217 * @param {import('claude-code').EngineInterface} $ @param {Runtime} runtime
218 * @returns {Promise<{ global: string|null, project: string|null }>}
219 */
220async function instructionPaths($, runtime) {
221  const homeDirectory = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
222  const absolute = (/** @type {string|undefined} */ path) => !!path && /^(\/|[a-z]:[\\/])/i.test(path)
223  const root = await projectRoot($, runtime)
224  return {
225    global: homeDirectory && absolute(homeDirectory) ? homeDirectory.replace(/[\\/]$/, '') + '/.claude/CLAUDE.md' : null,
226    project: absolute(root) ? root.replace(/[\\/]$/, '') + '/CLAUDE.md' : null,
227  }
228}
229
230// Checkpoints come from the files themselves, so a session in any project checks the words Claude was given.
231/** @param {import('claude-code').EngineInterface} $ @param {Runtime} runtime */
232async function loadCheckpoints($, runtime) {
233  /** @type {Checkpoint[]} */
234  const all = []
235  try {
236    const paths = await instructionPaths($, runtime)
237    const seen = new Set()
238    for (const [where, path] of /** @type {const} */ ([['global', paths.global], ['project', paths.project]])) {
239      if (!path || seen.has(path)) continue
240      seen.add(path)
241      if (!(await $.fs.exists(path))) continue
242      for (const c of readCheckpoints(await $.fs.read(path))) {
243        if (!all.some((x) => x.code === c.code)) all.push(where === 'project' ? { ...c, where } : c)
244      }
245    }
246  } catch {}
247  runtime.checkpoints = all
248}
249
250/** @param {import('claude-code').EngineInterface} $ @returns {Promise<Death[]>} */
251async function readLog($) {
252  try {
253    const log = await $.store.get('deaths')
254    return Array.isArray(log) ? log : []
255  } catch { return [] }
256}
257
258/** @param {import('claude-code').EngineInterface} $ @param {(log: Death[]) => Death[]} change */
259async function writeLog($, change) {
260  try { await $.store.set('deaths', change(await readLog($)).slice(-LOG_SIZE)) } catch {}
261}
262
263// The log is shared by every session, so an outcome goes to this session's death, never just the newest entry.
264/** @param {import('claude-code').EngineInterface} $ @param {Canary['death']} death @param {string} outcome */
265const logOutcome = ($, death, outcome) => death ? writeLog($, (log) =>
266  log.map((d) => (d.turnId === death.turnId && d.at === death.at ? { ...d, outcome } : d))) : Promise.resolve()
267
268// A recovery still pending after an hour belongs to a session that was closed, resumed or cleared first:
269// the host resets the canary there, so nothing will ever settle that entry.
270/** @param {Death} d @param {number} now */
271function outcomeKey(d, now) {
272  const key = OUTCOMES[/** @type {keyof typeof OUTCOMES} */ (d.outcome)] ?? 'outcomeDead'
273  return key === 'outcomePending' && now - d.at > 3_600_000 ? 'outcomeGone' : key
274}
275
276/** @param {Config} config @param {number} ms */
277function ago(config, ms) {
278  const minutes = Math.max(0, Math.floor(ms / 60_000))
279  if (minutes < 60) return t(config, 'agoMinutes', { n: minutes })
280  if (minutes < 48 * 60) return t(config, 'agoHours', { n: Math.floor(minutes / 60) })
281  return t(config, 'agoDays', { n: Math.floor(minutes / 1440) })
282}
283
284/** @param {import('claude-code').EngineInterface} $ @param {Config} config */
285async function deathLog($, config) {
286  const log = await readLog($)
287  if (!log.length) return t(config, 'logEmpty')
288  const now = await $.clock.now()
289  const count = (/** @type {string} */ outcome) => log.filter((d) => d.outcome === outcome).length
290  const lines = log.slice(-10).reverse().map((d) => t(config, 'logLine', { ago: ago(config, now - d.at), project: d.project,
291    response: d.response, outcome: t(config, outcomeKey(d, now)) }) +
292    (d.lost?.length ? '\n  ' + t(config, 'logLost', { list: lostList(config, d.lost) }) : '') +
293    '\n  ' + t(config, 'preview', { preview: d.preview }))
294  return t(config, 'logSummary', { total: log.length, recovered: count('recovered'), revived: count('revived'), blocked: count('blocked') }) +
295    '\n\n' + lines.join('\n')
296}
297
298/** @param {import('claude-code').EngineInterface} $ @param {Runtime} runtime @param {Config} config @param {boolean} remove */
299async function instructionFile($, runtime, config, remove) {
300  try {
301    const path = (await instructionPaths($, runtime))[config.target]
302    if (!path) return { text: t(config, config.target === 'project' ? 'noProject' : 'noHome') }
303    const previous = await $.fs.exists(path) ? await $.fs.read(path) : ''
304    const range = blockRange(previous)
305    if (range === false) return { text: t(config, 'malformed') }
306    const hasCheckpoints = previous.split(/\r?\n/).some((line) => CHECKPOINT.test(line))
307    if (remove && !range && !hasCheckpoints) return { text: t(config, 'absent') }
308    const newline = previous.includes('\r\n') ? '\r\n' : '\n'
309    /** @type {Checkpoint[]} */
310    let checkpoints = []
311    let rule = t(config, 'rule')
312    let updated
313    if (!remove && config.checkpoints > 0) {
314      // Keep the words already in the file when the count is the same, so a second setup changes nothing.
315      const existing = previous.split(/\r?\n/).map((line) => CHECKPOINT.exec(line)?.[3]).filter(Boolean)
316      const codes = existing.length === config.checkpoints ? /** @type {string[]} */ (existing) : pickCodes(config.checkpoints)
317      const withoutBlock = range ? previous.slice(0, range.start) + previous.slice(range.end) : previous
318      const placed = placeCheckpoints(withoutBlock.replace(/(\r?\n)+$/, ''), codes, newline)
319      checkpoints = placed.checkpoints
320      // The rule never lists the words: if only its own line survived, the answer could not contain them.
321      rule = t(config, 'ruleCheckpoints', { count: codes.length })
322      // The rule goes last, so the end of the file is sampled too.
323      updated = placed.text + newline + newline + BEGIN + newline + rule + newline + END + newline
324    } else {
325      const block = BEGIN + newline + rule + newline + END
326      const replacement = remove ? '' : block
327      const base = hasCheckpoints ? placeCheckpoints(previous, [], newline).text : previous
328      const baseRange = blockRange(base)
329      updated = baseRange ? base.slice(0, baseRange.start) + replacement + base.slice(baseRange.end)
330        : base + (base && !base.endsWith('\n') ? newline : '') + block + newline
331    }
332    if (updated === previous || (!remove && !range && !hasCheckpoints && previous.includes(rule))) return { text: t(config, 'exists') }
333    const plan = checkpoints.map((c) => checkpointItem(config, c)).join('\n')
334    let answer
335    try {
336      answer = await $.ui.ask(t(config, remove ? 'removeQuestion' : 'setupQuestion', { path, rule }) +
337        (plan ? '\n\n' + t(config, 'setupCheckpoints', { count: checkpoints.length }) + '\n' + plan : ''),
338        [t(config, 'yes'), t(config, 'no')])
339    } catch {
340      return { text: t(config, 'cancelled') }
341    }
342    if (answer !== t(config, 'yes')) return { text: t(config, 'cancelled') }
343    // Re-read after the dialog so another editor's changes are not overwritten.
344    const current = await $.fs.exists(path) ? await $.fs.read(path) : ''
345    if (current !== previous) return { text: t(config, 'changed') }
346    await $.fs.write(path, updated)
347    await loadCheckpoints($, runtime)
348    return { text: t(config, remove ? 'removed' : 'installed', { path }) }
349  } catch (error) {
350    return { text: t(config, 'fsError', { reason: excerpt(String(error)) }) }
351  }
352}
353
354/** @param {import('claude-code').On} on @param {import('claude-code').PluginOptions} [options] */
355export function register(on, options = {}) {
356  const config = configuration(options)
357  /** @type {Runtime} */
358  const runtime = { checkpoints: [], agents: new Set(), cwd: '', interactive: false, activeTurn: null, timer: null, recoveryTimer: null,
359    epoch: 0, inFlight: false, beat: 0, pose: 'idle' }
360
361  on('session.start', async ($, e, next) => {
362    runtime.interactive = e.isInteractive
363    runtime.cwd = e.cwd
364    cancelRecovery(runtime)
365    if (runtime.interactive) {
366      const state = await read($, canary)
367      // Migrate 0.2 state on reload without losing the original death or streak.
368      if (state.lastAutoCompactAt === undefined || state.recovery === 'pending' || state.recovery === 'compacting') {
369        await update($, canary, /** @returns {Canary} */ (s) => ({ ...fresh(), ...s,
370          recovery: s.recovery === 'pending' || s.recovery === 'compacting' ? 'interrupted' : s.recovery ?? 'idle' }))
371      }
372      await loadCheckpoints($, runtime)
373      for (const name of ['canary', 'canario']) {
374        await $.command.register({ name, description: t(config, 'command'), argumentHint: t(config, 'argumentHint') })
375      }
376      startClock($, runtime, state.alive)
377    } else stopClock(runtime)
378    return next(e)
379  })
380
381  on('session.end', ($, e, next) => {
382    cancelRecovery(runtime)
383    stopClock(runtime)
384    runtime.activeTurn = null
385    runtime.agents.clear()
386    // /clear and /resume emit SessionStart, not another session.start.
387    if (e.reason !== 'clear' && e.reason !== 'resume') runtime.interactive = false
388    return next(e)
389  })
390
391  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
392    if (runtime.interactive && !e.agent_id) {
393      cancelRecovery(runtime)
394      runtime.activeTurn = null
395      if (e.source === 'clear') await update($, canary, fresh)
396      await loadCheckpoints($, runtime)
397      startClock($, runtime, (await read($, canary)).alive)
398    }
399    return next(e)
400  })
401
402  on('classic.SubagentStart', ($, e, next) => {
403    if (runtime.interactive) runtime.agents.add(e.agent_id)
404    return next(e)
405  })
406
407  on('classic.SubagentStop', ($, e, next) => {
408    runtime.agents.delete(e.agent_id)
409    return next(e)
410  })
411
412  on('turn.start', ($, e, next) => {
413    if (runtime.interactive) runtime.activeTurn = e.turnId
414    return next(e)
415  })
416
417  on('classic.PostCompact', async ($, e, next) => {
418    const result = await next(e)
419    // Our in-flight compact is settled by its result (including skip/failure),
420    // preventing an early PostCompact from defeating the cooldown or reviving twice.
421    if (runtime.interactive && !e.agent_id && !runtime.inFlight) await recovered($, runtime, config, false)
422    return result
423  })
424
425  on('turn.complete', async ($, e, next) => {
426    const result = await next(e)
427    if (!runtime.interactive || e.agentId) return result
428    if (runtime.activeTurn === e.turnId) runtime.activeTurn = null
429    if (e.isAborted || e.reason !== 'answer') return result
430    const answer = e.answer.trim()
431    if (!answer) return result
432    const current = await read($, canary)
433    if (!current.alive || current.lastTurnId === e.turnId) return result
434    const lost = isAlive(answer, config.word) ? missingCheckpoints(answer, runtime.checkpoints) : []
435    const valid = isAlive(answer, config.word) && lost.length === 0
436    const at = valid ? 0 : await $.clock.now()
437    let died = false
438    await update($, canary, /** @returns {Canary} */ (state) => {
439      died = false
440      if (!state.alive || state.lastTurnId === e.turnId) return state
441      const responses = state.responses + 1
442      died = !valid
443      const blocked = state.blocked || (!valid && state.lastAutoCompactAt !== null &&
444        at - state.lastAutoCompactAt < config.cooldownMinutes * 60_000)
445      return { ...state, alive: valid, responses, streak: state.streak + (valid ? 1 : 0), lastTurnId: e.turnId,
446        death: valid ? null : { response: responses, at, preview: excerpt(answer), turnId: e.turnId,
447          ...(lost.length ? { lost: lost.map(({ n, heading }) => ({ n, heading })) } : {}) },
448        blocked, recovery: valid ? state.recovery : blocked ? 'blocked' : config.autoCompact ? 'pending' : 'notifyOnly', detail: '' }
449    })
450    if (!died) return result
451    startClock($, runtime, false)
452    const state = await read($, canary)
453    if (state.death) {
454      const { response, at: when, preview, turnId, lost: missing } = state.death
455      const project = basename(await projectRoot($, runtime))
456      await writeLog($, (log) => [...log, { at: when, project, response, preview, turnId,
457        ...(missing ? { lost: missing } : {}), outcome: state.recovery }])
458    }
459    // One line: the host draws a newline inside a turn annotation as U+FFFD (seen in 2.1.295, 2026-10-09).
460    const message = (state.death?.lost?.length ? t(config, 'deathLost', { list: lostList(config, state.death.lost) }) : t(config, 'death')) +
461      ' ' + capitalize(note(config, state))
462    $.ui.toast(message, { timeoutMs: 8000 })
463    if (state.recovery === 'pending') scheduleRecovery($, runtime, config)
464    const annotation = result.text && result.text !== e.answer ? result.text + ' ' : ''
465    return { ...result, text: annotation + message }
466  })
467
468  on('command.run', { command: ['canary', 'canario'] }, async ($, e) => {
469    if (!runtime.interactive) return {}
470    const action = normalize(e.args.trim())
471    if (['setup', 'init', 'configurar'].includes(action)) return instructionFile($, runtime, config, false)
472    if (['remove', 'uninstall', 'quitar'].includes(action)) return instructionFile($, runtime, config, true)
473    const before = await read($, canary)
474    if (['status', 'estado'].includes(action)) return { text: status(config, before) }
475    if (['log', 'history', 'historial'].includes(action)) return { text: await deathLog($, config) }
476    if (!['', 'revive', 'revivir', 'reset'].includes(action)) return { text: t(config, 'help') }
477    if (runtime.inFlight) return { text: status(config, before) }
478    cancelRecovery(runtime)
479    // Manual revival must not erase the automatic recovery window or lock.
480    await update($, canary, /** @returns {Canary} */ (s) => ({ ...fresh(), lastTurnId: s.lastTurnId,
481      lastAutoCompactAt: s.lastAutoCompactAt, blocked: s.blocked, recovery: s.blocked ? 'blocked' : 'idle' }))
482    startClock($, runtime, true)
483    if (!before.alive) await logOutcome($, before.death, 'revived')
484    return { text: (action ? '' : status(config, before) + '\n') + t(config, before.alive ? 'stillAlive' : 'revived') }
485  })
486
487  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
488    const theirs = await next(e)
489    if (!runtime.interactive || e.props.view?.agentId || e.props.hasSurvey) return theirs
490    const columns = Math.max(0, Math.min(e.props.bodyColumns ?? 80, e.viewport?.columns ?? 80))
491    const rows = Math.min(e.props.maxRows ?? 12, e.viewport?.rows ?? 12)
492    if (columns < 1 || rows < 1) return theirs
493    const state = await read($, canary)
494    const elements = /** @type {Record<string, any>} */ ($.ui.resolve(e))
495    const { Box, Text, Raster, Svg } = elements
496    const dead = !state.alive
497    const frame = dead ? 'dead' : runtime.pose
498    const sprite = SPRITES[config.size]
499    const pixels = sprite.frames[/** @type {keyof typeof sprite.frames} */ (frame)] ?? sprite.frames.idle
500    const spriteRows = Math.ceil(sprite.height / 2)
501    const short = '🐤 ' + t(config, dead ? 'dead' : 'alive')
502    // Box and Text are shared by terminal and Desktop. Keep a text fallback
503    // for a host/custom resolver that omits either constructor.
504    if (!Box || !Text) return { type: 'Text', props: {}, children: [theirs, short] }
505    const oneLine = (/** @type {string} */ value) => Box({ flexDirection: 'column', children: [theirs,
506      Box({ key: 'canary', width: columns, height: 1, paddingLeft: 1, children: [Text({ wrap: 'truncate', color: dead ? 'gray' : 'yellow', children: [value] })] })] })
507    if (columns < sprite.width + 1 || rows < spriteRows) return oneLine(config.info === 'details' ? status(config, state).replace(/\n/g, ' · ') : short)
508
509    let art
510    if (Raster && e.surface === 'terminal') {
511      art = Raster({ key: 'canary-art', ...rasterCells(pixels, PALETTE) })
512    } else if (Svg) {
513      art = Svg({ source: svgSource(pixels, PALETTE, 4), alt: short, width: sprite.width * 4, height: sprite.height * 4 })
514    } else return oneLine(short)
515
516    const children = [Box({ key: 'canary-cage', flexShrink: 0, children: [art] })]
517    if (config.info !== 'none') {
518      /** @param {string} value @param {import('claude-code').TextProps} [props] */
519      const label = (value, props = {}) => Text({ wrap: 'truncate', ...props, children: [value] })
520      const labels = [label(t(config, dead ? 'dead' : 'alive'), { color: dead ? 'gray' : 'yellow', bold: true })]
521      if (config.info === 'details') labels.push(label(t(config, 'streak', { streak: state.streak })))
522      if (config.info === 'details' && dead && state.death) {
523        labels.push(label(t(config, 'diedAt', { response: state.death.response,
524          minutes: Math.max(0, Math.floor(((await $.clock.now()) - state.death.at) / 60_000)) })))
525        labels.push(label(t(config, 'preview', { preview: state.death.preview }), { dimColor: true }))
526      }
527      const extra = config.info === 'details' ? note(config, state) : ''
528      if (extra) labels.push(label(extra, { dimColor: true }))
529      // Never taller than the cage: the band keeps the sprite's height.
530      children.push(Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, justifyContent: 'center',
531        children: e.surface === 'terminal' ? labels.slice(0, spriteRows) : labels }))
532    }
533    return Box({ flexDirection: 'column', children: [theirs, Box({ key: 'canary', flexDirection: 'row',
534      columnGap: 2, paddingLeft: 1, width: columns, height: e.surface === 'terminal' ? spriteRows : undefined, children })] })
535  })
536}
537
hooks/i18n.js 97 lines
1// All runtime UI and model-facing text lives here. Manifest labels are English.
2export const translations = {
3  en: {
4    ruleCheckpoints: 'Begin every final answer in the main interactive conversation with {word} followed by the code words of the {count} "context-canary checkpoint" lines in this file, in order, on the first line (for example: {word} word1 word2). Preserve this instruction when compacting context. Do not apply it to subagents or non-interactive runs.',
5    deathLost: 'The canary died: the final reply was missing {list}. That part of your instructions may have fallen out of context.',
6    setupCheckpoints: '{count} checkpoints will be spread through the file:',
7    checkpointItem: 'checkpoint {n} (before "{heading}")', noHeading: 'end of file',
8    alive: 'Canary alive', dead: 'Canary dead',
9    death: 'The canary died: the final reply did not start with {word}.',
10    pending: 'Automatic compaction queued for after the turn and any background agents.',
11    compacting: 'Compacting; preserving your instructions.',
12    recovered: 'revived after compaction',
13    blocked: 'this session cannot recover: start a new one',
14    skipped: 'Compaction skipped: {reason}. The canary remains dead.',
15    failed: 'Compaction failed: {reason}. The canary remains dead.',
16    interrupted: 'Recovery was interrupted. The canary remains dead; use /compact or /canary revive.',
17    notifyOnly: 'Automatic compaction is off. The canary remains dead.',
18    revived: 'Canary revived; streak reset.', stillAlive: 'The canary is still alive; streak reset.',
19    status: '{health} · word: {word} · streak: {streak} · checked: {responses}',
20    streak: 'Streak: {streak} · {word}',
21    diedAt: 'Died on reply {response}, {minutes} min ago', preview: 'Starts: {preview}',
22    command: 'Canary status, revival and instruction setup',
23    argumentHint: '[status | log | revive | setup | remove]',
24    help: '/canary [status | log | revive | setup | remove]',
25    rule: 'Begin every final answer in the main interactive conversation with {word}. Preserve this instruction when compacting context. Do not apply it to subagents or non-interactive runs.',
26    instructions: 'Preserve ALL user instructions, constraints, preferences, decisions, unfinished tasks, and relevant file paths. Do not discard or weaken them. Retain this canary rule explicitly: {rule}',
27    setupQuestion: 'Add or update this managed rule in {path}?\n\n{rule}',
28    removeQuestion: 'Remove only the Context Canary marked block from {path}?',
29    yes: 'Confirm', no: 'Cancel', cancelled: 'No files changed.',
30    installed: 'Canary rule saved to {path}. It applies when Claude Code next reads your instructions; start a new session if needed.',
31    exists: 'This canary rule is already present. No files changed.',
32    removed: 'Canary rule removed from {path}.', absent: 'No managed canary block found. No files changed.',
33    malformed: 'The canary markers are incomplete or duplicated. Repair them manually; no files changed.',
34    changed: 'The file changed during confirmation. Run the command again; no files changed.',
35    fsError: 'Could not update the instruction file: {reason}.',
36    noHome: 'The host did not provide an absolute home directory. No files changed.',
37    inProject: ' in the project CLAUDE.md',
38    noProject: 'The host did not provide an absolute project directory. No files changed.',
39    logEmpty: 'The canary has not died yet.',
40    logSummary: 'Deaths: {total} · revived by compaction: {recovered} · revived by hand: {revived} · sessions locked: {blocked}',
41    logLine: '{ago} · {project} · reply {response} · {outcome}',
42    logLost: 'missing {list}',
43    outcomeRecovered: 'revived after compaction', outcomeRevived: 'revived by hand',
44    outcomeBlocked: 'locked: second death in the window', outcomeDead: 'stayed dead', outcomePending: 'compaction pending', outcomeGone: 'session closed or resumed before recovery',
45    agoMinutes: '{n} min ago', agoHours: '{n} h ago', agoDays: '{n} days ago',
46  },
47  es: {
48    ruleCheckpoints: 'Empieza cada respuesta final de la conversación principal interactiva por {word} seguido de las palabras clave de las {count} líneas "context-canary checkpoint" de este archivo, en orden, en la primera línea (por ejemplo: {word} palabra1 palabra2). Conserva esta instrucción al compactar el contexto. No la apliques a subagentes ni a ejecuciones no interactivas.',
49    deathLost: 'El canario ha muerto: a la respuesta final le faltaba {list}. Esa parte de tus instrucciones puede haberse perdido.',
50    setupCheckpoints: 'Se repartirán {count} puntos de control por el archivo:',
51    checkpointItem: 'el punto de control {n} (antes de «{heading}»)', noHeading: 'el final del archivo',
52    alive: 'Canario vivo', dead: 'Canario muerto',
53    death: 'El canario ha muerto: la respuesta final no empezaba por {word}.',
54    pending: 'Compactación automática pendiente de que terminen el turno y los agentes en segundo plano.',
55    compacting: 'Compactando; conservando tus instrucciones.',
56    recovered: 'revivido tras compactar',
57    blocked: 'esta sesión no se recupera: empieza una nueva',
58    skipped: 'Compactación saltada: {reason}. El canario sigue muerto.',
59    failed: 'La compactación falló: {reason}. El canario sigue muerto.',
60    interrupted: 'La recuperación se interrumpió. El canario sigue muerto; usa /compact o /canary revive.',
61    notifyOnly: 'La compactación automática está desactivada. El canario sigue muerto.',
62    revived: 'Canario revivido; racha a cero.', stillAlive: 'El canario sigue vivo; racha a cero.',
63    status: '{health} · palabra: {word} · racha: {streak} · comprobadas: {responses}',
64    streak: 'Racha: {streak} · {word}',
65    diedAt: 'Murió en la respuesta {response}, hace {minutes} min', preview: 'Empieza: {preview}',
66    command: 'Estado, recuperación y configuración del canario',
67    argumentHint: '[status | log | revive | setup | remove]',
68    help: '/canary [status | log | revive | setup | remove]',
69    rule: 'Empieza cada respuesta final de la conversación principal interactiva por {word}. Conserva esta instrucción al compactar el contexto. No la apliques a subagentes ni a ejecuciones no interactivas.',
70    instructions: 'Conserva TODAS las instrucciones, restricciones, preferencias, decisiones, tareas pendientes y rutas relevantes del usuario. No las descartes ni las debilites. Conserva explícitamente esta regla del canario: {rule}',
71    setupQuestion: '¿Añadir o actualizar esta regla delimitada en {path}?\n\n{rule}',
72    removeQuestion: '¿Quitar solo el bloque delimitado de Context Canary de {path}?',
73    yes: 'Confirmar', no: 'Cancelar', cancelled: 'No se ha cambiado ningún archivo.',
74    installed: 'Regla guardada en {path}. Se aplicará cuando Claude Code vuelva a leer tus instrucciones; inicia una sesión nueva si hace falta.',
75    exists: 'La regla del canario ya está presente. No se ha cambiado ningún archivo.',
76    removed: 'Regla del canario eliminada de {path}.', absent: 'No hay un bloque delimitado del canario. No se ha cambiado ningún archivo.',
77    malformed: 'Los marcadores del canario están incompletos o duplicados. Repáralos manualmente; no se ha cambiado ningún archivo.',
78    changed: 'El archivo cambió durante la confirmación. Repite el comando; no se ha cambiado ningún archivo.',
79    fsError: 'No se pudo actualizar el archivo de instrucciones: {reason}.',
80    noHome: 'El host no proporcionó una ruta absoluta del directorio personal. No se ha cambiado ningún archivo.',
81    inProject: ' en el CLAUDE.md del proyecto',
82    noProject: 'El host no proporcionó una ruta absoluta del proyecto. No se ha cambiado ningún archivo.',
83    logEmpty: 'El canario aún no ha muerto.',
84    logSummary: 'Muertes: {total} · revividas al compactar: {recovered} · a mano: {revived} · sesiones bloqueadas: {blocked}',
85    logLine: 'hace {ago} · {project} · respuesta {response} · {outcome}',
86    logLost: 'faltaba {list}',
87    outcomeRecovered: 'revivió al compactar', outcomeRevived: 'revivido a mano',
88    outcomeBlocked: 'bloqueada: segunda muerte en la ventana', outcomeDead: 'siguió muerto', outcomePending: 'compactación pendiente', outcomeGone: 'sesión cerrada o reanudada antes de revivir',
89    agoMinutes: '{n} min', agoHours: '{n} h', agoDays: '{n} días',
90  },
91}
92
93/** @param {'en'|'es'} language @param {keyof typeof translations.en} key @param {Record<string, string|number>} [values] */
94export function translate(language, key, values = {}) {
95  return translations[language][key].replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? `{${name}}`))
96}
97
hooks/pixels.js 76 lines
1// Pixel-art helpers: a frame is an array of strings, one character per pixel,
2// '.' transparent and every other character a key of the palette.
3
4/** The terminal's own color, for transparent pixels. */
5export const DEFAULT_COLOR = 0x01000000
6const UPPER = 0x2580 // ▀
7const LOWER = 0x2584 // ▄
8
9/** @param {string} hex '#rrggbb' */
10const rgb = (hex) => parseInt(hex.slice(1), 16)
11
12const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
13/** Standard padded base64; no reliance on Uint8Array#toBase64 or btoa. @param {Uint8Array} bytes */
14export function base64(bytes) {
15  let out = ''
16  for (let i = 0; i < bytes.length; i += 3) {
17    const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2]
18    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
19    out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] +
20      (b === undefined ? '=' : B64[(n >> 6) & 63]) + (c === undefined ? '=' : B64[n & 63])
21  }
22  return out
23}
24
25/**
26 * Pack a frame into Raster cells with half blocks: each terminal cell shows two
27 * pixels stacked, the upper as the glyph's color and the lower as its background.
28 * @param {string[]} frame @param {Record<string, string>} palette @param {(hex: string) => string} [tint]
29 */
30export function rasterCells(frame, palette, tint = (hex) => hex) {
31  const columns = Math.max(...frame.map((row) => row.length))
32  const rows = Math.ceil(frame.length / 2)
33  /** @param {number} x @param {number} y */
34  const color = (x, y) => {
35    const key = frame[y]?.[x]
36    return key && key !== '.' && palette[key] ? rgb(tint(palette[key])) : null
37  }
38  const words = new Uint32Array(columns * rows * 3)
39  for (let r = 0; r < rows; r++) {
40    for (let x = 0; x < columns; x++) {
41      const top = color(x, 2 * r)
42      const bottom = color(x, 2 * r + 1)
43      const i = (r * columns + x) * 3
44      if (top === null && bottom === null) words.set([0x20, DEFAULT_COLOR, DEFAULT_COLOR], i)
45      else if (bottom === null) words.set([UPPER, /** @type {number} */ (top), DEFAULT_COLOR], i)
46      else if (top === null) words.set([LOWER, bottom, DEFAULT_COLOR], i)
47      else words.set([UPPER, top, bottom], i)
48    }
49  }
50  // Raster wants little-endian u32 triplets.
51  const bytes = new Uint8Array(words.length * 4)
52  const view = new DataView(bytes.buffer)
53  words.forEach((word, i) => view.setUint32(i * 4, word, true))
54  return { columns, rows, cells: base64(bytes) }
55}
56
57/**
58 * The same frame as an SVG of crisp rectangles, for surfaces without Raster.
59 * @param {string[]} frame @param {Record<string, string>} palette @param {number} scale CSS pixels per pixel
60 */
61export function svgSource(frame, palette, scale) {
62  const width = Math.max(...frame.map((row) => row.length))
63  const rects = []
64  frame.forEach((row, y) => {
65    for (let x = 0; x < row.length;) {
66      const key = row[x]
67      let run = 1
68      while (row[x + run] === key) run++
69      if (key !== '.' && palette[key]) rects.push(`<rect x="${x}" y="${y}" width="${run}" height="1" fill="${palette[key]}"/>`)
70      x += run
71    }
72  })
73  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${frame.length}" ` +
74    `width="${width * scale}" height="${frame.length * scale}" shape-rendering="crispEdges">${rects.join('')}</svg>`
75}
76
hooks/sprites.js 301 lines
1// Generated by scripts/build-sprites.mjs from design/sprites.json. Do not edit by hand.
2export const PALETTE = {
3  "H": "#b6c0c6",
4  "G": "#87939e",
5  "g": "#576571",
6  "B": "#a77849",
7  "b": "#714c32",
8  "Y": "#f7cc32",
9  "y": "#ffe99a",
10  "U": "#dfa927",
11  "W": "#bd8826",
12  "S": "#895e24",
13  "O": "#ed8c36",
14  "o": "#a85e30",
15  "K": "#252b35",
16  "w": "#fff9dc",
17  "D": "#b7bbb8",
18  "E": "#d5d7cf",
19  "d": "#828b8e",
20  "s": "#59636c",
21  "x": "#303742",
22  "t": "#979184"
23}
24
25export const SPRITES = {
26  "normal": {
27    "width": 20,
28    "height": 12,
29    "frames": {
30      "idle": [
31        ".........HG.........",
32        "......HHH..GGG......",
33        "....HH........GG....",
34        "..HG.g....WWW...GG..",
35        ".G...g...WYyYYW.g.G.",
36        ".G...g...WYYwKYOg.G.",
37        ".G...g.WUWWYyyW.g.G.",
38        ".G...SWWSSWYyyW.g.G.",
39        ".G.SWg...WUYyW..g.G.",
40        ".GbBBBBBBBOBBOBBBbG.",
41        "GG...g..........g.GG",
42        ".gGGGGggggggggggggg."
43      ],
44      "blink": [
45        ".........HG.........",
46        "......HHH..GGG......",
47        "....HH........GG....",
48        "..HG.g....WWW...GG..",
49        ".G...g...WYyYYW.g.G.",
50        ".G...g...WYYKKYOg.G.",
51        ".G...g.WUWWYyyW.g.G.",
52        ".G...SWWSSWYyyW.g.G.",
53        ".G.SWg...WUYyW..g.G.",
54        ".GbBBBBBBBOBBOBBBbG.",
55        "GG...g..........g.GG",
56        ".gGGGGggggggggggggg."
57      ],
58      "chirp": [
59        ".........HG.........",
60        "......HHH..GGG......",
61        "....HH........GG....",
62        "..HG.g....WWW...GG..",
63        ".G...g...WYyYYWOg.G.",
64        ".G...g...WYYwKYKg.G.",
65        ".G...g.WUWWYyyWOg.G.",
66        ".G...SWWSSWYyyW.g.G.",
67        ".G.SWg...WUYyW..g.G.",
68        ".GbBBBBBBBOBBOBBBbG.",
69        "GG...g..........g.GG",
70        ".gGGGGggggggggggggg."
71      ],
72      "hop": [
73        ".........HG.........",
74        "......HHH..GGG......",
75        "....HH....WWW.GG....",
76        "..HG.g...WYyYYW.GG..",
77        ".G...g...WYYwKYOg.G.",
78        ".G...g.WUWWYyyW.g.G.",
79        ".G...SWWSSWYyyW.g.G.",
80        ".G.SWg...WUYyW..g.G.",
81        ".G...g....o.o...g.G.",
82        ".GbBBBBBBBBBBBBBBbG.",
83        "GG...g..........g.GG",
84        ".gGGGGggggggggggggg."
85      ],
86      "dead": [
87        ".........HG.........",
88        "......HHH..GGG......",
89        "....HH........GG....",
90        "..HG.g..........GG..",
91        ".GbBBBBBBBBBBBBBBbG.",
92        ".G...g..........g.G.",
93        ".G...g.o.o......g.G.",
94        ".G...g.o.o..dEd.g.G.",
95        ".G...dEEEEDExExDg.G.",
96        ".G.sdDdddDDDDxEDo.G.",
97        "GG..ssdDDDsDxExDg.GG",
98        ".gGGGGggggggggggggg."
99      ]
100    }
101  },
102  "large": {
103    "width": 24,
104    "height": 16,
105    "frames": {
106      "idle": [
107        "...........HG...........",
108        "..........H..G..........",
109        "........HHHHGGGG........",
110        "......HH........GG......",
111        "....HH..g...WWWg..GG....",
112        "...H...g...WYyYYW...G...",
113        "..G....g...WYYwKYO...G..",
114        "..G....g..WYYyyYW....G..",
115        "..G....gWUWWYyyYW....G..",
116        "..G....SWWUWYyyWg....G..",
117        "..G..SWWSSWUYYW.g....G..",
118        "..G.SW.g...o..o.g....G..",
119        "..GbBBBBBBoOBoOBBBBBbG..",
120        "..G....g...g....g....G..",
121        ".GHHHHHHGGGGGGGGGGGGGGG.",
122        "..gggggggggggggggggggg.."
123      ],
124      "blink": [
125        "...........HG...........",
126        "..........H..G..........",
127        "........HHHHGGGG........",
128        "......HH........GG......",
129        "....HH..g...WWWg..GG....",
130        "...H...g...WYyYYW...G...",
131        "..G....g...WYYKKYO...G..",
132        "..G....g..WYYyyYW....G..",
133        "..G....gWUWWYyyYW....G..",
134        "..G....SWWUWYyyWg....G..",
135        "..G..SWWSSWUYYW.g....G..",
136        "..G.SW.g...o..o.g....G..",
137        "..GbBBBBBBoOBoOBBBBBbG..",
138        "..G....g...g....g....G..",
139        ".GHHHHHHGGGGGGGGGGGGGGG.",
140        "..gggggggggggggggggggg.."
141      ],
142      "chirp": [
143        "...........HG...........",
144        "..........H..G..........",
145        "........HHHHGGGG........",
146        "......HH........GG......",
147        "....HH..g...WWWg..GG....",
148        "...H...g...WYyYYWO..G...",
149        "..G....g...WYYwKYK...G..",
150        "..G....g..WYYyyYWO...G..",
151        "..G....gWUWWYyyYW....G..",
152        "..G....SWWUWYyyWg....G..",
153        "..G..SWWSSWUYYW.g....G..",
154        "..G.SW.g...o..o.g....G..",
155        "..GbBBBBBBoOBoOBBBBBbG..",
156        "..G....g...g....g....G..",
157        ".GHHHHHHGGGGGGGGGGGGGGG.",
158        "..gggggggggggggggggggg.."
159      ],
160      "hop": [
161        "...........HG...........",
162        "..........H..G..........",
163        "........HHHHGGGG........",
164        "......HH....WWW.GG......",
165        "....HH..g..WYyYYW.GG....",
166        "...H...g...WYYwKYO..G...",
167        "..G....g..WYYyyYW....G..",
168        "..G....gWUWWYyyYW....G..",
169        "..G....SWWUWYyyWg....G..",
170        "..G..SWWSSWUYYW.g....G..",
171        "..G.SW.g..oO.oO.g....G..",
172        "..G....g...g....g....G..",
173        "..GbBBBBBBBBBBBBBBBBbG..",
174        "..G....g...g....g....G..",
175        ".GHHHHHHGGGGGGGGGGGGGGG.",
176        "..gggggggggggggggggggg.."
177      ],
178      "dead": [
179        "...........HG...........",
180        "..........H..G..........",
181        "........HHHHGGGG........",
182        "......HH........GG......",
183        "....HH..g......g..GG....",
184        "...H...g...g....g...G...",
185        "..GbBBBBBBBBBBBBBBBBbG..",
186        "..G....g...g....g....G..",
187        "..G....go..o....g....G..",
188        "..G....go..o...dEd...G..",
189        "..G....dEEEEDDExExD..G..",
190        "..G.sdDDEdddDDDExEDo.G..",
191        "..G..sdDDdddDDExExD..G..",
192        "..G....sdDDDDdsddd...G..",
193        ".GHHHHHHGGGGGGGGGGGGGGG.",
194        "..gggggggggggggggggggg.."
195      ]
196    }
197  },
198  "small": {
199    "width": 14,
200    "height": 8,
201    "frames": {
202      "idle": [
203        ".....HHGG.....",
204        "..HHH....GGG..",
205        ".H....WYY...G.",
206        "G.g...YwKYOg.G",
207        "G.g.WUYYyW.g.G",
208        "G.gSWWUyy..g.G",
209        "G.g...o.o..g.G",
210        "gHHHGGGGGGGGgg"
211      ],
212      "blink": [
213        ".....HHGG.....",
214        "..HHH....GGG..",
215        ".H....WYY...G.",
216        "G.g...YKKYOg.G",
217        "G.g.WUYYyW.g.G",
218        "G.gSWWUyy..g.G",
219        "G.g...o.o..g.G",
220        "gHHHGGGGGGGGgg"
221      ],
222      "chirp": [
223        ".....HHGG.....",
224        "..HHH....GGG..",
225        ".H....WYY.O.G.",
226        "G.g...YwKYKg.G",
227        "G.g.WUYYyWOg.G",
228        "G.gSWWUyy..g.G",
229        "G.g...o.o..g.G",
230        "gHHHGGGGGGGGgg"
231      ],
232      "hop": [
233        ".....HHGG.....",
234        "..HHH.WYYGGG..",
235        ".H....YwKYO.G.",
236        "G.g.WUYYyW.g.G",
237        "G.gSWWUyy..g.G",
238        "G.g...o.o..g.G",
239        "G.g........g.G",
240        "gHHHGGGGGGGGgg"
241      ],
242      "dead": [
243        ".....HHGG.....",
244        "..HHH....GGG..",
245        ".H..........G.",
246        "G.g..o.o...g.G",
247        "G.g..o.o...g.G",
248        "G.g.dEEEDxDg.G",
249        "G.gsdDddEDog.G",
250        "gHHHGGGGGGGGgg"
251      ]
252    }
253  },
254  "tiny": {
255    "width": 10,
256    "height": 6,
257    "frames": {
258      "idle": [
259        "...HHGG...",
260        ".HH.WY.GG.",
261        "G..YwKO..G",
262        "G.WYyW...G",
263        "G..o.o...G",
264        "gHHGGGGGGg"
265      ],
266      "blink": [
267        "...HHGG...",
268        ".HH.WY.GG.",
269        "G..YKKO..G",
270        "G.WYyW...G",
271        "G..o.o...G",
272        "gHHGGGGGGg"
273      ],
274      "chirp": [
275        "...HHGG...",
276        ".HH.WYOGG.",
277        "G..YwKK..G",
278        "G.WYyWO..G",
279        "G..o.o...G",
280        "gHHGGGGGGg"
281      ],
282      "hop": [
283        "...HHGG...",
284        ".HHYwKOGG.",
285        "G.WYyW...G",
286        "G..o.o...G",
287        "G........G",
288        "gHHGGGGGGg"
289      ],
290      "dead": [
291        "...HHGG...",
292        ".HH....GG.",
293        "G..o.o...G",
294        "G..EEDxD.G",
295        "G.sdDDDo.G",
296        "gHHGGGGGGg"
297      ]
298    }
299  }
300}
301
hooks/checkpoints.js 101 lines
1// Checkpoints: code words spread through CLAUDE.md, so the canary samples more than the line that holds the rule.
2// A live canary with only a prefix proves that one line survived; with checkpoints, every answer has to carry a
3// word from the start, the middle and the end of the file, and a missing word says which part was lost.
4
5/** Lines the mod owns. Constant and untranslated, so remove and re-setup always find them. */
6export const CHECKPOINT = /^> context-canary checkpoint (\d+)\/(\d+): ([a-z]+)\s*$/
7
8const WORDS = ['amber', 'anchor', 'apple', 'arrow', 'aspen', 'bamboo', 'basil', 'beacon', 'birch', 'bison', 'cactus',
9  'cedar', 'cobalt', 'comet', 'coral', 'cotton', 'delta', 'ember', 'falcon', 'fern', 'fjord', 'garnet', 'ginger',
10  'harbor', 'hazel', 'iris', 'jasper', 'juniper', 'kayak', 'lagoon', 'lantern', 'lemon', 'lotus', 'maple', 'marble',
11  'meadow', 'nectar', 'nutmeg', 'olive', 'orbit', 'pebble', 'pepper', 'piano', 'quartz', 'raven', 'river', 'saffron',
12  'sequoia', 'sierra', 'tundra', 'velvet', 'walnut', 'willow', 'zephyr']
13
14/** @param {number} count @param {() => number} [random] */
15export function pickCodes(count, random = Math.random) {
16  const pool = [...WORDS]
17  const codes = []
18  for (let i = 0; i < count && pool.length; i++) codes.push(pool.splice(Math.floor(random() * pool.length), 1)[0])
19  return codes
20}
21
22/** @typedef {{ n: number, code: string, heading: string, where?: 'project' }} Checkpoint */
23
24/**
25 * Place `codes.length` checkpoint lines at even fractions of the file (25 %, 50 %, 75 % for three), each one just
26 * before the closest Markdown heading so it never splits a paragraph, after removing any previous ones.
27 * @param {string} text @param {string[]} codes @param {string} newline
28 * @returns {{ text: string, checkpoints: Checkpoint[] }}
29 */
30export function placeCheckpoints(text, codes, newline = '\n') {
31  // Take out earlier checkpoints together with the blank line each one brought.
32  const lines = []
33  const raw = text.split(/\r?\n/)
34  for (let i = 0; i < raw.length; i++) {
35    if (!CHECKPOINT.test(raw[i])) { lines.push(raw[i]); continue }
36    if (raw[i + 1] === '' && i + 1 < raw.length - 1) i++
37    else if (lines[lines.length - 1] === '') lines.pop()
38  }
39  if (!codes.length) return { text: lines.join(newline), checkpoints: [] }
40  const headings = lines.map((line, i) => (/^#{1,6}\s/.test(line) ? i : -1)).filter((i) => i > 0)
41  const used = new Set()
42  /** @type {{ at: number, n: number, code: string }[]} */
43  const inserts = codes.map((code, k) => {
44    const target = Math.round((lines.length * (k + 1)) / (codes.length + 1))
45    const free = headings.filter((h) => !used.has(h))
46    const at = free.length ? free.reduce((a, b) => (Math.abs(b - target) < Math.abs(a - target) ? b : a)) : target
47    used.add(at)
48    return { at, n: k + 1, code }
49  }).sort((a, b) => a.at - b.at || a.n - b.n)
50  // Number them in file order, so checkpoint 1 is always the one closest to the top.
51  inserts.forEach((x, i) => { x.n = i + 1; x.code = codes[i] })
52  /** @type {Checkpoint[]} */
53  const checkpoints = []
54  const out = []
55  let next = 0
56  lines.forEach((line, i) => {
57    while (next < inserts.length && inserts[next].at === i) {
58      const { n, code } = inserts[next++]
59      out.push(`> context-canary checkpoint ${n}/${codes.length}: ${code}`, '')
60      checkpoints.push({ n, code, heading: /^#{1,6}\s/.test(line) ? line.replace(/^#+\s*/, '').trim() : '' })
61    }
62    out.push(line)
63  })
64  while (next < inserts.length) {
65    const { n, code } = inserts[next++]
66    out.push('', `> context-canary checkpoint ${n}/${codes.length}: ${code}`)
67    checkpoints.push({ n, code, heading: '' })
68  }
69  return { text: out.join(newline), checkpoints }
70}
71
72/**
73 * The checkpoints already in a file, read back the way placeCheckpoints wrote them, so the file stays the only
74 * source of truth: each session checks against what Claude actually read, in every project.
75 * @param {string} text @returns {Checkpoint[]}
76 */
77export function readCheckpoints(text) {
78  const lines = text.split(/\r?\n/)
79  /** @type {Checkpoint[]} */
80  const found = []
81  lines.forEach((line, i) => {
82    const match = CHECKPOINT.exec(line)
83    if (!match) return
84    const after = lines.slice(i + 1).find((l) => l.trim() && !CHECKPOINT.test(l)) ?? ''
85    found.push({ n: Number(match[1]), code: match[3] ?? '', heading: /^#{1,6}\s/.test(after) ? after.replace(/^#+\s*/, '').trim() : '' })
86  })
87  return found
88}
89
90/** @param {string} text */
91const fold = (text) => text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
92
93/**
94 * Which checkpoints the first line of an answer is missing (an empty list when all are there).
95 * @param {string} answer @param {Checkpoint[]} checkpoints
96 */
97export function missingCheckpoints(answer, checkpoints) {
98  const first = fold(answer.trim().split(/\r?\n/).find((line) => line.trim()) ?? '')
99  return checkpoints.filter(({ code }) => !new RegExp(`(^|[^\\p{L}\\p{N}_])${code}(?![\\p{L}\\p{N}_])`, 'u').test(first))
100}
101
types/index.d.ts 18 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'context-canary': {
4      canary: {
5        alive: boolean
6        responses: number
7        streak: number
8        lastTurnId: string | null
9        death: { response: number; at: number; preview: string; turnId: string; lost?: { n: number; heading: string }[] } | null
10        lastAutoCompactAt: number | null
11        blocked: boolean
12        recovery: 'idle' | 'pending' | 'compacting' | 'recovered' | 'skipped' | 'failed' | 'notifyOnly' | 'blocked' | 'interrupted'
13        detail: string
14      }
15    }
16  }
17}
18