SLOPSHOPPER

Context Canary

Development test entry for the Context Canary marketplace; installs use plugins/context-canary.

newbandcommandtoasttimer
v1.3.3MITupdated 2026-10-09Nachx639/context-canary
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 🐤

A pixel-art canary that dies when Claude Code forgets your instructions — then auto-compacts and brings it back to life.

Español · 中文 · MIT · a Claude Code mod · Tested with Claude Code 2.1.293

claude plugin marketplace add Nachx639/context-canary
claude plugin install context-canary@context-canary

Then run /canary setup in a new session. ⭐ If it ever saves one of your sessions, a star helps other people find it.

Why

Long coding conversations can stop following an instruction you gave earlier, and you rarely notice until something goes wrong. It is the canary in the coal mine: Context Canary makes one instruction visible. Each final answer must begin with a small sentinel (🐤 by default). While it does, a bird lives in its cage just above the prompt. When an answer misses it, the bird dies, the session compacts automatically keeping your instructions, and the bird comes back. A missing sentinel is a warning signal, not proof that context was lost, and compaction does not guarantee the next answer will comply.

Checkpoints: sample the whole file

A live canary only proves that the line holding the rule survived, not the rest of your CLAUDE.md. Set checkpoints to 1–5 and run /canary setup: it puts the rule at the end of the file and spreads short code words before headings at the 25 %, 50 % and 75 % marks, like > context-canary checkpoint 2/3: maple. Every answer must then start with 🐤 comet maple river. The rule never lists the words, so a reply can only carry them if those parts of the file are still in context. When one is missing, the canary dies and names it: missing checkpoint 2 (before "Testing"). /canary remove takes the rule and every checkpoint out.

The words are read back from the files each time a session starts, so the canary checks exactly what Claude was given, in every project.

Your project's CLAUDE.md too

Set target to project and /canary setup writes the rule (and checkpoints) to the CLAUDE.md at the root of the current project instead of ~/.claude/CLAUDE.md. Checkpoints in both files are always checked; a lost one from the project file says so.

Death log

/canary log lists the last deaths, newest first: when, in which project, on which reply, how it ended (revived after compaction, revived by hand, locked, stayed dead), the checkpoints that were missing and how the reply started. The log keeps the last 30 deaths in the plugin's own store, across sessions, so you can tell a one-off slip from a project whose instructions keep falling out.

Install

claude plugin marketplace add Nachx639/context-canary
claude plugin install context-canary@context-canary

To install from a local clone instead:

claude plugin marketplace add /absolute/path/to/context-canary-repo
claude plugin install context-canary@context-canary

In a new interactive session, run /canary setup. It shows the exact rule and asks for consent before updating ~/.claude/CLAUDE.md (or the project's CLAUDE.md with target: project). No file is changed on plugin load. If the rule was added during an existing session, start a new one so Claude Code reads it. Avoid enabling two copies of the canary simultaneously.

Configuration

The manifest's userConfig fields appear in Claude Code's /config panel. The host passes the resolved values to register(on, options); this mod never edits settings. Restart the session after changing options if it has not reloaded.

OptionDefaultMeaning
word🐤Sentinel at the start of each final reply. One line, up to 64 Unicode code points. Invalid/empty input falls back to 🐤.
autoCompacttrueCompact after death. false means notifications only.
cooldownMinutes30A second death less than this many minutes after successful automatic compaction locks recovery. Range 0–10080; 0 disables the window.
languageenen or es. 2.1.293 exposes no UI locale accessor, so language is explicit.
sizesmallPixel-art canary: tiny (10×3 cells), small (14×4), normal (20×6) or large (24×8).
infononeText beside the canary: none (only the bird), status (“Canary alive/dead”) or details (status, streak, death and recovery notes).
checkpoints01–5 spreads code words through CLAUDE.md with /canary setup; every answer must carry them, so the canary samples the whole file and names the part that was lost.
targetglobalWhich file /canary setup and /canary remove edit: global (~/.claude/CLAUDE.md) or project (CLAUDE.md at the project root).

After changing word or the rule's language, run /canary setup again to update the managed rule. Matching ignores case and accents, allows bold, quotes, Markdown punctuation and preceding emojis, and rejects longer lookalike words. ✨ CANÁRIO: done matches canario; hello canario and canarios do not.

The CLI can set a string option too:

claude plugin configure context-canary@context-canary --values-stdin <<'JSON'
{"word":"canario","language":"es"}
JSON

Commands

CommandEffect
/canaryShow the previous status, then revive and reset the counters.
/canary statusShow health, sentinel, counters and recovery note without changing state.
/canary logLast deaths across sessions: when, project, reply, outcome and missing checkpoints.
/canary reviveRevive/reset counters. Retains the cooldown timestamp and any recovery lock.
/canary setupConfirm adding/updating the marked rule in the user's CLAUDE.md.
/canary removeConfirm removing only that marked block.

/canario is an alias. Subcommands also accept estado, revivir, configurar and quitar, historial; init, reset, history and uninstall are aliases for setup, revive and remove. remove/uninstall here remove the instruction block, not the plugin.

The managed block uses <!-- context-canary:start --> and <!-- context-canary:end -->. Setup is idempotent, preserves surrounding text and line endings, and also recognizes the exact current rule outside a block. It asks before replacing an older rule. Incomplete/duplicate markers, a read error, cancellation or a file changed during the dialog prevent the write. Remove does not delete unmarked instructions you wrote yourself.

Recovery and display

Only a nonempty final turn.complete.answer, with reason: 'answer', from an interactive main turn is checked. Subagents, aborted turns, intermediate tool steps, local command output and claude -p runs do not trigger it.

On death, the mod retains the first failing reply's number, timestamp and short excerpt, shows a toast and queues a timer. After the turn returns, it calls $.session.compact with instructions to preserve all user instructions, constraints, preferences, decisions and unfinished work, including the sentinel rule. A newly started turn postpones the call, and so do background subagents still running (up to an hour), since they report back into the same conversation. Compaction may use a model request and incur normal Claude Code usage.

Successful automatic compaction revives the bird with “revived after compaction”. A skipped or rejected call leaves it dead and explains why, with no retries. A real main-session classic.PostCompact can also revive a dead bird after manual/host compaction; the plugin's own result controls its automatic attempt, so the two paths cannot revive twice.

A second death within the configured window leaves “this session cannot recover: start a new one”. This lock lasts until /clear or a new session. Manual revival is available but does not remove the lock or cooldown. If compaction is in flight, revival reports its status and waits for the result. /clear, session shutdown and manual revival cancel queued work; an old result cannot revive a different context.

The band shows only the canary in its cage, drawn as pixel art: in the terminal a Raster of half blocks (two pixels per cell), in Desktop a crisp Svg of the same pixels. It blinks, chirps and hops with at most one redraw per second; the dead bird lies belly-up, grey and still. There is no audio. Details stay in the shadows: toasts on death and recovery, and /canary status for streak and history. Set info to status or details to put them beside the cage. When the sprite does not fit (from tiny 10 columns × 3 rows to large 24 × 8), the display falls back to one line. Surveys and subagent views retain the host's display.

Health/history and cooldown live in host $.state, surviving module reloads, but not process restarts. Claude Code resets that state on /clear, /resume and /branch. A reload during recovery leaves the bird dead with an interrupted note rather than replaying a possibly completed operation. No background file, network or process calls are made by the mod. At session start it reads the two instruction files for their checkpoints; only explicit setup/remove writes one. The death log lives in the plugin's own store ($.store). The host's compaction is the operation that can call a model.

Develop and test

claude plugin validate .
claude plugin validate ./.claude-plugin/plugin.json
claude plugin test .
claude plugin validate ./plugins/context-canary
claude plugin test ./plugins/context-canary

claude plugin test is a mod runner, not a marketplace runner. The root .claude-plugin/plugin.json and hooks/hooks.json are a development test entry that loads the same nested module. They let the root command execute the shipped tests without a second implementation. The marketplace installs only ./plugins/context-canary/. Keep root userConfig defaults in sync with the nested manifest when editing them.

Tests use claude-code/testing, a fake clock, in-memory state, filesystem stubs and an AskUserQuestion stub for $.ui.ask. They cover recovery, exact cooldown boundaries, skip/rejection, deferred work and stale results, matching, both languages, consent/idempotence, ignored turns, state migration/reload, narrow layouts and the terminal/Desktop render trees. No setup is run on the developer's actual instruction file. A compaction rejection is simulated with a failing event stub; the kit skips it and the underlying test host rejects the call.

The workflow uses Anthropic's official native installer, pinned to 2.1.293, and runs validation/tests at both levels. No model API key is needed for stubbed tests. Neither real model compaction nor actual terminal/Desktop pixels are exercised by the test suite. A local test tree is not a screenshot test.

Local generated API types are excluded from Git. Where those 2.1.293 types and TypeScript already exist, additionally run:

tsc -p plugins/context-canary/.claude-plugin/types/tsconfig.json --allowJs --checkJs

References: manifest/userConfig, mods API, test kit, marketplaces. For this implementation, the locally generated 2.1.293 API types take precedence over a newer or older website.

Layout

.claude-plugin/marketplace.json     Public marketplace
.claude-plugin/plugin.json          Root development test entry
hooks/hooks.json                    Loads the shipped module for root tests
plugins/context-canary/
  .claude-plugin/plugin.json        Plugin manifest, 1.3.3 and userConfig
  hooks/{hooks.json,register.js,i18n.js,pixels.js,checkpoints.js}
  hooks/sprites.js                 Generated from design/sprites.json

  types/index.d.ts                 Host state contract
  tests/canary.test.ts
.github/workflows/test.yml
design/                             Pixel-art source, preview.html, render.py
scripts/build-sprites.mjs           design/sprites.json → hooks/sprites.js
scripts/demo/                       Demo video and soundtrack generators
media/                              Demo GIF, video and social preview
README.md · README.es.md · README.zh.md · CHANGELOG.md · LICENSE
Source 6 files
plugins/context-canary/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
plugins/context-canary/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
plugins/context-canary/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
plugins/context-canary/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
plugins/context-canary/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
plugins/context-canary/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