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

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.
hooks/register.js 537 lines1import { 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}
537hooks/i18n.js 97 lines1// 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}
97hooks/pixels.js 76 lines1// 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}
76hooks/sprites.js 301 lines1// 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}
301hooks/checkpoints.js 101 lines1// 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}
101types/index.d.ts 18 lines1declare 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