SLOPSHOPPER

context-meter

A context band above the prompt that warns on quality in tokens (200k fading, 350k dumb zone) and hands off there: a state doc, /clear, the doc read back…

newbandguardcommandtoastprompt
v0.4.0no licenseupdated 2026-10-08cleverfakealias/agents/mods/context-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ context-meter │ ⏺ Read(src/auth.ts) │ Context holds 97k (49% of 200k). Past 70k, │ ⎿ Read 6 lines │ answer quality tends to slip. The dumb │ ⏺ Update(src/auth.ts) │ zone starts at 100k; Handoff is yours to │ ⎿ 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 › /handoff ⎿ context-meter: Handoff: Claude writes the state doc, then the context is cleared and the doc read back. ⟨Claude Code's own drawing⟩ d: ▸ Context ████████████░░░░░░░░░░░░ 49% quality fading 97k/200k · handoff queued: it starts when this turn ends · Effort low ○──○──○──○──○ max ⇥ Handoff ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ d: ▸ Context ████████████░░░░░░░░░░░░ 49% quality fading 97k/200k · handoff queued: it starts when t Effort low ○──○──○──○──○ max ⇥ Handoff
README

agents — a clean starting point for agentic development

A small, language-neutral scaffold you copy into a repo so coding agents start with sensible guardrails. It is Claude Code–native, with AGENTS.md as the cross-tool contract that Cursor, Codex, Copilot, and others read too.

It is deliberately small. It leans on Claude Code's built-in controls where they exist, and adds one hook for the part that has to be project-specific: running your formatter and your tests.

What's in the box

scaffold/                 ← copy this into your repo
├── AGENTS.md             project facts, commands, conventions, security (fill in)
├── CLAUDE.md             imports AGENTS.md, plus a few Claude-specific notes
├── .gitignore            lines to add to yours
└── .claude/
    ├── settings.json     permission rules and hook wiring
    ├── hooks/
    │   ├── checks.mjs    runs your formatter after edits, your tests before Claude finishes
    │   └── checks.json   the commands it runs (empty until you fill it in)
    └── skills/zenn/      /zenn: optional spec-first workflow for larger work
providers.md              notes for Cursor / Copilot / Codex / Gemini / Devin
mods/                     user-level Claude Code mods (see mods/README.md)
tests/                    node --test "tests/*.test.mjs": the hook, the scaffold rules, the mods

Setup

1. Copy the scaffold into your repo

cp -r /path/to/agents/scaffold/. /path/to/your-repo/

The trailing /. copies the contents, including the dot-directories. If the repo already has a .gitignore, AGENTS.md, or CLAUDE.md, merge those by hand instead of overwriting them. Node on PATH is the only requirement.

2. Fill in AGENTS.md

Replace each <!-- placeholder --> with the project's name, stack, and real commands, and delete the sections you don't need.

3. Tell the checks hook what to run

Edit .claude/hooks/checks.json. Both lists are empty by default, which turns the hook off.

{
  "format": {
    "py": "ruff format {file}",
    "ts,tsx,js": "npx --no-install prettier --write {file}"
  },
  "verify": ["pytest -q"]
}
  • format maps file extensions to a command that runs after Claude edits a file of that type. {file} is the edited file's path, already quoted. If the command fails, its output goes back to Claude to fix.
  • verify commands run when Claude finishes a turn in which it edited files. If one fails, Claude keeps working until it passes.
  • Commands run from the repo root. A command whose tool isn't installed is skipped. CLAUDE_SKIP_CHECKS=1 claude turns the hook off for a session.

4. Turn on the OS sandbox (optional)

On macOS, Linux, or WSL2, run /sandbox in Claude Code. It confines shell commands to the project directory and to network hosts you approve. The secret paths denied in settings.json apply inside the sandbox too.

How the guardrails work

| Layer | What it covers | | :- | :- | | deny rules | Secrets are never read or written: .env*, .dev.vars*, key files, ~/.ssh, ~/.gnupg, cloud credentials (AWS, GCP, Azure, Kubernetes), tool and registry credentials (GitHub CLI, Docker, npm, PyPI, RubyGems, ~/.git-credentials, ~/.netrc). .env.example stays usable. Lockfiles (package-lock.json, pnpm-lock.yaml, *.lock, *.lockb, go.sum) are not read either: they are large, and they only change through the package manager. | | File search | settings.json sets CLAUDE_CODE_GLOB_NO_IGNORE=false, so Claude's file search respects .gitignore. By default it also lists ignored files such as node_modules and build output. | | ask rules | A person approves git push, git reset --hard, git clean, gh pr merge, CI workflow edits, and any command retried outside the sandbox. These prompt in every permission mode, including auto. | | Built into Claude Code | Writes to .claude/, .git/, .mcp.json, and shell startup files are never auto-approved. rm -rf on the project, home, or root is always stopped. settings.json also disables bypass-permissions mode. | | Checks hook | Your formatter and tests run without anyone remembering to. | | AGENTS.md | Conventions and intent. It shapes what agents try; it enforces nothing. |

There are no allow rules: nothing is pre-approved. Claude Code already runs read-only commands without asking, and saves your own "don't ask again" choices to .claude/settings.local.json.

No permission mode is pinned either. Use Manual, auto, or plan as you prefer; the deny and ask rules hold in all of them.

Limits worth knowing

  • Shell rules match the command as written. Bash(git push *) catches git push origin main but not git -C . push. The rules stop the usual form, not a determined workaround. For anything that must never happen, protect the branch on the remote.
  • Read rules don't see inside scripts. They cover Claude's file tools and common shell readers such as cat. A script that opens a file itself is only stopped by the OS sandbox.
  • The sandbox doesn't run on native Windows. Use WSL2 or a dev container there if you need real isolation.
  • ***.pem and *.key are denied wholesale.** Delete those two lines from settings.json if your repo keeps non-secret files with those extensions.
  • Lockfiles can't be read. To check a resolved version, ask the package manager (npm ls <pkg>, pnpm why <pkg>), or delete the lockfile lines from settings.json.

User-level mods

mods/ holds Claude Code mods that load into every session on the machine, whatever repo it runs in, the Desktop Code tab included. Each one fixes friction that kept repeating across real sessions:

| Mod | In short | | :- | :- | | shell-sense | Denies shell commands that are certain to fail on Windows, and says what works instead. | | package-gate | Asks you before any package install, with a registry link per package. | | secret-shield | Keeps secret files and token values out of shell reads and the transcript. | | repo-lock | Denies dependency changes with the wrong package manager or from the wrong folder. | | context-meter | A band above the prompt: context fill against quality marks (30% fading, 40% "dumb zone"), rate limits, and model and effort switchers. | | session-context | Tells Claude the repo, branch and uncommitted work with each prompt. |

Setup, the full behaviour of each, and how to work on them: mods/README.md.

Working on this repo

node --test "tests/*.test.mjs"

The tests exercise checks.mjs, enforce the scaffold's own rules (file size limits, valid hook paths, rule syntax), and check that the mods' shared files match. Each mod also has its own tests: claude plugin test mods/<name>. Using another agent? See providers.md.

Earlier versions (per-language standards skills, command-guard hooks, the multi-provider scaffolds) live in git history.

Source 3 files
hooks/register.tsx 617 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Category, Detail, Handoff, Meter, Setup } from '../types'
5import {
6  EFFORTS,
7  HANDOFF_COMMAND,
8  HANDOFF_DIR,
9  aliasFor,
10  aliasOf,
11  averageGrowth,
12  baseline,
13  cardSvg,
14  compact,
15  compactInstructions,
16  contextLevel,
17  crossed,
18  families,
19  fileName,
20  gauge,
21  handoffPath,
22  handoffPrompt,
23  lastDelta,
24  limitName,
25  forcedCompactAt,
26  marks,
27  parseAlias,
28  readPrompt,
29  resetIn,
30  signed,
31  sparkline,
32  stageOf,
33  turnsLeft,
34  warning,
35  windowNote,
36  zoneName,
37} from './rules'
38
39const meter = atom({ plugin: 'context-meter', key: 'meter' } as const, null as Meter | null)
40const detail = atom({ plugin: 'context-meter', key: 'detail' } as const, null as Detail | null)
41const setup = atom({ plugin: 'context-meter', key: 'setup' } as const, null as Setup | null)
42const isOpen = atom({ plugin: 'context-meter', key: 'isOpen' } as const, false)
43// The highest warning stage already shown; kept in state so a reload doesn't repeat a toast.
44const warned = atom({ plugin: 'context-meter', key: 'warned' } as const, 0)
45const handoff = atom({ plugin: 'context-meter', key: 'handoff' } as const, null as Handoff | null)
46
47const PHASE_TEXT: Record<Handoff['phase'], string> = {
48  queued: 'handoff queued: it starts when this turn ends',
49  writing: 'handoff 1/3: Claude writes the doc',
50  clearing: 'handoff 2/3: clearing the context',
51  reading: 'handoff 3/3: Claude reads the doc back',
52}
53
54// A handoff never runs on its own: the person presses Handoff or types
55// `/handoff`. And it touches nothing outside its own chat: the state it needs
56// across the `/clear` lives in this module, which the process keeps while the
57// session id changes. `$.store` is one file shared by every open chat of every
58// project, so nothing of the handoff goes there.
59type Pending = { path: string; at: number }
60// The read-back a `/clear` chain still owes, until it is sent or picked up.
61let pending: Pending | undefined
62// When this chat last cleared: a dumb-zone crossing soon after says the
63// baseline itself is too large, which no handoff can fix.
64let lastClearAt = 0
65const SOON_AFTER_MS = 10 * 60_000
66
67// Per turn: how often the engine's autocompact was held, and how the last turn
68// ended. A second hold in one turn means the request itself is too long, and
69// only a compaction gets the session out; so does a turn that ended in error.
70let holdsThisTurn = 0
71let lastTurnFailed = false
72// The hold is announced once per context, not on every turn it repeats.
73let holdToasted = false
74
75// The free figures: fill, limits and cost. Cheap enough for every tool call.
76// `tokensHint` stands in when the engine has no count yet (right after a compaction).
77async function refresh($: EngineInterface, isTurnEnd: boolean, tokensHint?: number) {
78  const usage = await $.session.usage()
79  // Always the model's full window: the percent is a share of it, whatever
80  // window the session happens to compact at.
81  const { window } = usage.context
82  if (!(window > 0)) return
83  const tokens = usage.context.tokens ?? tokensHint
84  const percent = tokens === undefined ? undefined : Math.round((tokens / window) * 100)
85  await update($, meter, prev => {
86    const history = [...(prev?.history ?? [])]
87    if (isTurnEnd && tokens !== undefined) {
88      history.push(tokens)
89      if (history.length > 12) history.shift()
90    }
91    return {
92      tokens: tokens ?? 0,
93      window,
94      percent: percent ?? 0,
95      history,
96      limits: usage.rateLimits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt })),
97      usd: usage.cost?.usd,
98    }
99  })
100  if (tokens === undefined || percent === undefined) return
101  const m = marks(window, (await read($, detail))?.autoCompactAt)
102  // Decided inside the update, so two refreshes in flight (parallel tool calls)
103  // cannot both see the old value and both toast.
104  let level: number | undefined
105  await update($, warned, prev => {
106    const alert = crossed(stageOf(tokens, m), prev)
107    level = alert.level
108    return alert.warned
109  })
110  if (level === undefined) return
111  $.ui.toast(warning(level, { tokens, window, percent }, m), { timeoutMs: 12000 })
112  if (level >= 2 && lastClearAt > 0 && (await $.clock.now()) - lastClearAt < SOON_AFTER_MS) {
113    $.ui.toast(`Already past ${compact(m.dumb)} right after a handoff: the baseline is too large. Shrink the doc, memory files or MCP tools before the next one.`)
114  }
115}
116
117// The /context breakdown, estimated locally (no token-count requests).
118async function refreshDetail($: EngineInterface) {
119  const b = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
120  if (b === undefined) return
121  const api = b.apiUsage
122  const input = api ? api.input_tokens + api.cache_read_input_tokens + api.cache_creation_input_tokens : 0
123  const categories: Category[] = b.categories
124    .filter((c): c is typeof c & { kind: Category['kind'] } => c.kind !== 'deferred')
125    .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }))
126  await update($, detail, () => ({
127    window: b.rawMaxTokens,
128    windowSource: b.autocompactSource,
129    categories,
130    autoCompactAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
131    cacheHit: api && input > 0 ? Math.round((api.cache_read_input_tokens / input) * 100) : undefined,
132    memoryFiles: [...b.memoryFiles].sort((a, z) => z.tokens - a.tokens).map(f => ({ path: f.path, tokens: f.tokens })),
133    mcpTokens: b.mcpTools.filter(t => t.isLoaded).reduce((n, t) => n + t.tokens, 0),
134  }))
135}
136
137// The model buttons: choices from the `/config` model row, the current pick
138// from the live session. The row holds the saved default, which a session-only
139// `/model` leaves alone, so it is only the fallback.
140async function refreshSetup($: EngineInterface) {
141  const row = (await $.config.list()).find(r => r.key === 'model')
142  if (!row) return
143  const model = await $.session.model().catch(() => undefined)
144  const alias = (model && aliasOf(model)) ?? (typeof row.value === 'string' ? row.value : undefined)
145  await update($, setup, prev => ({ ...prev, alias, options: [...(row.options ?? [])], model: model ?? prev?.model }))
146}
147
148// Runs `/model`, `/effort` or `/compact` as if typed: queued until the session is
149// idle, with its usual transcript lines. A refusal shows as a toast, never silence.
150async function runCommand($: EngineInterface, command: 'model' | 'effort' | 'compact', args = ''): Promise<boolean> {
151  try {
152    await $.command.run({ command, args })
153  } catch (err) {
154    $.ui.toast(`/${command} did not run: ${message(err)}`)
155    return false
156  }
157  await quietly(refreshSetup($))
158  return true
159}
160
161const quietly = (p: Promise<unknown>) => p.catch(() => undefined)
162const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
163
164// The handoff, step 1: ask for the doc, as a turn of its own once the session is idle.
165async function beginHandoff($: EngineInterface) {
166  const m = await read($, meter)
167  const root = await $.session.root()
168  const now = await $.clock.now()
169  const path = handoffPath(root, await $.session.id(), now)
170  // The folder ignores itself: handoff docs are working notes, never commits.
171  await quietly($.fs.write(`${path.slice(0, path.lastIndexOf('/'))}/.gitignore`, '*\n'))
172  await update($, handoff, () => ({ phase: 'writing' as const, path, since: now, waited: 0 }))
173  const r = { tokens: m?.tokens ?? 0, window: m?.window ?? 0, percent: m?.percent ?? 0 }
174  void $.prompt.submit({ text: handoffPrompt(path, r) }).catch(async err => {
175    $.ui.toast(`The handoff did not start: ${message(err)}`)
176    await update($, handoff, () => null)
177  })
178}
179
180// Step 2, at the end of the doc's turn: a fresh context, then step 3. At the end
181// of the read-back turn: done.
182async function advanceHandoff($: EngineInterface, isAborted: boolean) {
183  const job = await read($, handoff)
184  if (job?.phase === 'reading') return void (await update($, handoff, () => null))
185  if (job?.phase !== 'writing' || !job.path) return
186  const path = job.path
187  if (isAborted) {
188    $.ui.toast(`The handoff stopped with the turn: the context was kept. Press Handoff to try again.`)
189    return void (await update($, handoff, () => null))
190  }
191  const st = await $.fs.stat(path).catch(() => undefined)
192  // Two seconds of slack between the engine's clock and the file system's.
193  if (st?.kind === 'file' && st.mtimeMs >= (job.since ?? 0) - 2000) {
194    await update($, handoff, () => ({ ...job, phase: 'clearing' as const }))
195    // Not awaited: the commands wait for the idle session, which this hook holds.
196    void quietly(resetContext($, path))
197    return
198  }
199  // A prompt queued earlier may run first: wait one more turn, then give up.
200  if ((job.waited ?? 0) < 1) return void (await update($, handoff, () => ({ ...job, waited: (job.waited ?? 0) + 1 })))
201  $.ui.toast(`No handoff doc at ${path}, so the context was kept. Press Handoff to try again.`)
202  await update($, handoff, () => null)
203}
204
205// Steps 2 and 3: `/clear` (or `/compact` with the doc named, where `/clear` is
206// refused), then the read-back prompt. `/clear` starts a new session with empty
207// state and no session.start, so the path rides this chain and the meter refills
208// here. The module keeps the job too: should this chain die with the old session,
209// the first prompt of the new one picks the read-back up (see prompt.submit).
210async function resetContext($: EngineInterface, path: string) {
211  const at = await $.clock.now()
212  pending = { path, at }
213  let how: 'cleared' | 'compacted' = 'cleared'
214  try {
215    await $.command.run({ command: 'clear', args: '' })
216    lastClearAt = at
217    holdToasted = false
218  } catch {
219    how = 'compacted'
220    if (!(await runCommand($, 'compact', compactInstructions(path)))) {
221      pending = undefined
222      return void (await update($, handoff, () => null))
223    }
224  }
225  await update($, handoff, () => ({ phase: 'reading' as const, path }))
226  await quietly(refreshDetail($))
227  await quietly(refresh($, false))
228  await quietly(refreshSetup($))
229  // The new session knows no slash commands of this plugin yet.
230  await quietly($.command.register(HANDOFF_COMMAND))
231  try {
232    await $.prompt.submit({ text: readPrompt(path, how) })
233    pending = undefined
234  } catch (err) {
235    $.ui.toast(`The read-back did not start: ${message(err)}. The doc is at ${path}.`)
236    await update($, handoff, () => null)
237  }
238}
239
240// A read-back this chat still owes from a session that ended before it ran, if recent.
241async function pendingReadBack($: EngineInterface): Promise<Pending | undefined> {
242  if (pending === undefined) return undefined
243  if ((await $.clock.now()) - pending.at > 30 * 60_000) {
244    pending = undefined
245    return undefined
246  }
247  return pending
248}
249
250export const register: Register = on => {
251  on('session.start', async ($, e, next) => {
252    const result = await next(e)
253    // A new process (a `/clear` raises no session.start): nothing is owed yet.
254    pending = undefined
255    lastClearAt = 0
256    holdToasted = false
257    holdsThisTurn = 0
258    lastTurnFailed = false
259    await quietly($.command.register(HANDOFF_COMMAND))
260    // The breakdown first: it says which window the meter measures against.
261    await quietly(refreshDetail($))
262    await quietly(refresh($, false))
263    await quietly(refreshSetup($))
264    return result
265  })
266
267  // `/handoff`: the Handoff button as a typed command, at any fill. A prompt
268  // cannot be submitted from inside a command's own hook, so a timer does it next.
269  on('command.run', { command: HANDOFF_COMMAND.name }, async ($) => {
270    const job = await read($, handoff)
271    if (job !== null && job.phase !== 'queued') return { text: `A handoff is already running (${PHASE_TEXT[job.phase]}).` }
272    await update($, handoff, () => ({ phase: 'queued' as const }))
273    $.clock.after(0, () => quietly(beginHandoff($)))
274    return { text: 'Handoff: Claude writes the state doc, then the context is cleared and the doc read back.' }
275  })
276
277  // Each main-loop request carries the effort in use. Its model id may drop the
278  // `[1m]` mark, so the model comes from `$.session.model()` instead.
279  on('turn.step', async function* ($, e, next) {
280    if (e.agentId === undefined) {
281      const { effort } = e
282      void quietly(update($, setup, prev => (prev?.effort === effort ? prev : { options: [], ...prev, effort })))
283    }
284    return yield* next(e)
285  })
286
287  // Live during a turn: each finished main-loop tool call follows a fresh API
288  // response. Subagents have their own context.
289  on('tool.call', async ($, e, next) => {
290    const result = await next(e)
291    // Not awaited, so the meter never holds up a tool result.
292    if (e.agentId === undefined) void quietly(refresh($, false))
293    return result
294  })
295
296  // The person's own prompt while a handoff runs: the doc is already written, so
297  // Claude adds what this exchange changes. In a session that `/clear` started
298  // without finishing the read-back, the first prompt carries the read-back.
299  on('prompt.submit', async ($, e, next) => {
300    // Typed at the terminal, or sent by the Desktop app (`sdk`); never a plugin's own.
301    if (e.origin.kind !== 'composer' && e.origin.kind !== 'sdk') return next(e)
302    const job = await read($, handoff)
303    if (job?.path && (job.phase === 'writing' || job.phase === 'clearing')) {
304      return next({
305        ...e,
306        context: [
307          ...(e.context ?? []),
308          `A handoff doc for this session was just written to ${job.path}, and the context is about to be cleared. After you answer, append a short note on this exchange to that doc so nothing is lost.`,
309        ],
310      })
311    }
312    const left = job === null ? await pendingReadBack($) : undefined
313    if (left === undefined) return next(e)
314    pending = undefined
315    await update($, handoff, () => ({ phase: 'reading' as const, path: left.path }))
316    return next({ ...e, context: [...(e.context ?? []), readPrompt(left.path, 'cleared')] })
317  })
318
319  on('session.compact', async ($, e, next) => {
320    if (e.agentId !== undefined) return next(e)
321    const job = await read($, handoff)
322    const m = await read($, meter)
323    // The engine's own compaction never runs on its own while there is room:
324    // it is held, and the person hands off when ready. Near the hard limit it
325    // runs; so does the second attempt in one turn, and the one after a failed
326    // turn, because then the request itself is too long and no doc can help.
327    if (
328      e.trigger === 'auto' &&
329      job?.phase !== 'clearing' &&
330      m &&
331      m.tokens < forcedCompactAt(m.window) &&
332      holdsThisTurn === 0 &&
333      !lastTurnFailed
334    ) {
335      holdsThisTurn++
336      if (!holdToasted) {
337        holdToasted = true
338        $.ui.toast(
339          `Autocompact held at ${compact(m.tokens)}: press Handoff (or type /handoff) at a good stopping point. It runs on its own only past ${compact(forcedCompactAt(m.window))}.`,
340          { timeoutMs: 12000 },
341        )
342      }
343      return { skip: 'context-meter holds autocompact: the person hands off with a state doc when ready' }
344    }
345    const result = await next(e)
346    // A compaction ends no turn: record its drop and redraw at once. The engine
347    // has no count until the next response, so the compaction's own stands in.
348    const after = result.skip === undefined ? result.tokensAfter : undefined
349    void quietly(refreshDetail($).then(() => refresh($, true, after)))
350    return result
351  })
352
353  on('turn.complete', async ($, e, next) => {
354    const result = await next(e)
355    // Subagents have their own context; only the main conversation counts here.
356    if (e.agentId === undefined) {
357      holdsThisTurn = 0
358      lastTurnFailed = e.reason === 'error'
359      await quietly(advanceHandoff($, e.isAborted || e.reason !== 'answer'))
360      await quietly(refreshDetail($))
361      await quietly(refresh($, true))
362      if ((await read($, handoff))?.phase === 'queued') await quietly(beginHandoff($))
363      void quietly(refreshSetup($))
364    }
365    return result
366  })
367
368  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
369    const m = await read($, meter)
370    if (e.props.hasSurvey || m === null) return next(e)
371
372    const els = $.ui.resolve(e)
373    const { Box, Text, Button } = els
374    // The terminal draws no images; every other surface takes the SVG card.
375    const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
376    const open = await read($, isOpen)
377    const d = await read($, detail)
378    const s = await read($, setup)
379    const now = await $.clock.now()
380    const job = await read($, handoff)
381    const wide = e.props.bodyColumns >= 90
382    const autoCompactAt = d?.autoCompactAt
383    const mk = marks(m.window, autoCompactAt)
384    const color = contextLevel(m.tokens, mk)
385    const delta = lastDelta(m.history)
386    const growth = averageGrowth(m.history)
387    // Before the dumb zone, count down to it (the handoff). Inside it, to the
388    // forced compact the band holds off until then, or to full with autocompact off.
389    const isDumb = m.tokens >= mk.dumb
390    const forcedAt = forcedCompactAt(m.window)
391    const left = turnsLeft(m.tokens, isDumb ? (autoCompactAt ? forcedAt : m.window) : mk.dumb, growth)
392    const leftText =
393      left === undefined ? undefined : `≈${left} turns to ${isDumb ? (autoCompactAt ? 'a forced compact' : 'full') : 'the handoff'}`
394    const status = job ? PHASE_TEXT[job.phase] : undefined
395    const note = windowNote(d?.window, m.window)
396    const current = s?.alias ? parseAlias(s.alias) : undefined
397    // The engine's own band (and any plugin's beneath) stays; this one goes under it.
398    const below = await next(e)
399    const effort = typeof s?.effort === 'string' ? s.effort : undefined
400
401    // The band's hotkeys arm from the terminal's focus chord; a desktop clicks.
402    const toggle = (
403      <Button
404        key="toggle"
405        plain
406        {...(Svg ? {} : { hotkey: 'd' })}
407        label={Svg ? `${open ? '▾' : '▸'} Details` : `${open ? '▾' : '▸'} Context`}
408        onPress={async () => {
409          const opening = !(await read($, isOpen))
410          await update($, isOpen, () => opening)
411          if (opening) await quietly(refreshDetail($))
412        }}
413      />
414    )
415
416    // The big view where the surface draws images; a text row in the terminal.
417    // The card holds the figures only: a sentence that may run long (the window
418    // note) is a text row under it, where it wraps.
419    const headline =
420      Svg ? (
421        <Box flexDirection="column">
422          <Svg
423            key="card"
424            alt={`Context ${m.percent}% full, ${compact(m.tokens)} of ${compact(m.window)} tokens`}
425            source={cardSvg({
426              percent: m.percent,
427              tokens: m.tokens,
428              window: m.window,
429              growth: delta !== undefined ? `${signed(delta)} last turn` : undefined,
430              left: leftText,
431              status,
432              marks: mk,
433              limits: m.limits.map(l => ({ name: limitName(l.kind), percent: l.percent, reset: resetIn(l.resetsAt, now) })),
434            })}
435          />
436          {note ? (
437            <Box paddingLeft={1}>
438              <Text dimColor>{note}</Text>
439            </Box>
440          ) : null}
441        </Box>
442      ) : (
443        <Box flexDirection="row" gap={1} flexWrap="wrap">
444          {toggle}
445          <Text>
446            <Text color={color}>{gauge(m.tokens, m.window, wide ? 24 : 12).filled}</Text>
447            <Text dimColor>{gauge(m.tokens, m.window, wide ? 24 : 12).empty}</Text>
448          </Text>
449          <Text bold color={color}>
450            {m.percent}%
451          </Text>
452          <Text color={color}>{zoneName(m.tokens, mk)}</Text>
453          <Text dimColor>
454            {compact(m.tokens)}/{compact(m.window)}
455            {status ? ` · ${status}` : ''}
456            {!status && delta !== undefined ? ` · ${signed(delta)} last turn` : ''}
457            {!status && leftText ? ` · ${leftText}` : ''}
458            {!status && note ? ` · ${note}` : ''}
459            {m.limits.find(l => l.kind === 'five_hour') ? ` · 5h ${Math.round(m.limits.find(l => l.kind === 'five_hour')!.percent)}%` : ''}
460          </Text>
461        </Box>
462      )
463
464    const options = s?.options ?? []
465    const at = effort ? EFFORTS.indexOf(effort as (typeof EFFORTS)[number]) : -1
466    // Doc, fresh context, read-back: the steps the meter runs on entering the
467    // dumb zone. Offered at any fill, so a natural break can take it early;
468    // quiet before the zone, a button inside it.
469    const handoffButton =
470      !e.props.isWorking && (job === null || job.phase === 'queued') ? (
471        <Button
472          key="handoff"
473          {...(isDumb ? {} : { plain: true as const, dimColor: true })}
474          label={isDumb ? 'Handoff' : '⇥ Handoff'}
475          onPress={() => quietly(beginHandoff($))}
476        />
477      ) : null
478    // Two quiet rows, each a label column, its control, and an action at the
479    // right edge: a radio group for the model with Details, a stepped slider
480    // for effort with Handoff. Plain Buttons draw as bare text, so the glyphs
481    // carry the state.
482    const label = (text: string) => (
483      <Box width={8}>
484        <Text dimColor>{text}</Text>
485      </Box>
486    )
487    const controls = (
488      <Box flexDirection="column">
489        <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
490          <Box flexDirection="row" gap={2} flexWrap="wrap" alignItems="center">
491            {options.length ? label('Model') : null}
492            {families(options).map(f => (
493              <Button
494                key={`model-${f}`}
495                plain
496                dimColor={current?.family !== f}
497                label={`${current?.family === f ? '◉' : '○'} ${f.charAt(0).toUpperCase() + f.slice(1)}`}
498                // The 1M variant wherever a family has one; Haiku has none and stays 200k.
499                onPress={() => runCommand($, 'model', aliasFor(f, true, options))}
500              />
501            ))}
502          </Box>
503          {Svg ? toggle : null}
504        </Box>
505        <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
506          <Box flexDirection="row" alignItems="center" flexWrap="wrap">
507            {label('Effort')}
508            <Text dimColor>low </Text>
509            {EFFORTS.map((choice, i) => (
510              <Box key={`stop-${choice}`} flexDirection="row">
511                {i > 0 ? <Text dimColor={i > at}>──</Text> : null}
512                <Button
513                  key={`effort-${choice}`}
514                  plain
515                  dimColor={i > at}
516                  label={i === at ? '◉' : i < at ? '●' : '○'}
517                  onPress={() => runCommand($, 'effort', choice)}
518                />
519              </Box>
520            ))}
521            <Text dimColor> max</Text>
522            <Text bold>{effort ? `  ${effort}` : ''}</Text>
523          </Box>
524          {handoffButton}
525        </Box>
526      </Box>
527    )
528
529    if (!open) {
530      return (
531        <Box flexDirection="column">
532          {below}
533          {headline}
534          {controls}
535        </Box>
536      )
537    }
538
539    const used =(d?.categories ?? []).filter(c => c.kind === 'used').sort((a, z) => z.tokens - a.tokens)
540    const rest = (d?.categories ?? []).filter(c => c.kind !== 'used')
541
542    const fixed = d ? baseline(d.categories) : 0
543    return (
544      <Box flexDirection="column">
545        {below}
546        {headline}
547        {controls}
548        <Box flexDirection="column" paddingLeft={2} marginTop={1}>
549          {d === null ? <Text dimColor>Counting…</Text> : null}
550          {[...used, ...rest].map(c => {
551            const g = gauge(c.tokens, m.window, wide ? 20 : 10)
552            return (
553              <Box key={`cat-${c.name}`} flexDirection="row" gap={1}>
554                <Box width={22}>
555                  <Text dimColor={c.kind !== 'used'} wrap="truncate">
556                    {c.name}
557                  </Text>
558                </Box>
559                <Box width={6} justifyContent="flex-end">
560                  <Text>{compact(c.tokens)}</Text>
561                </Box>
562                <Text color={c.kind === 'used' ? c.color : undefined} dimColor={c.kind !== 'used'}>
563                  {c.kind === 'free' ? '' : g.filled}
564                </Text>
565                <Text dimColor>{Math.round((c.tokens / m.window) * 100)}%</Text>
566              </Box>
567            )
568          })}
569
570          <Box flexDirection="row" marginTop={1}>
571            {label('Growth')}
572            <Text dimColor>
573              {m.history.length >= 2 ? `${sparkline(m.history.slice(-12))} ` : 'one turn so far'}
574              {growth !== undefined ? ` avg ${signed(Math.round(growth))}/turn` : ''}
575              {` · handoff at ${compact(mk.dumb)}`}
576              {autoCompactAt ? ` · autocompact held until ${compact(forcedAt)}` : ' · autocompact off'}
577            </Text>
578          </Box>
579          {m.limits.length ? (
580            <Box flexDirection="row">
581              {label('Limits')}
582              <Text dimColor>
583                {m.limits
584                  .map(l => {
585                    const when = resetIn(l.resetsAt, now)
586                    return `${limitName(l.kind)} ${Math.round(l.percent)}%${when ? ` (resets ${when})` : ''}`
587                  })
588                  .join(' · ')}
589              </Text>
590            </Box>
591          ) : null}
592          <Box flexDirection="row">
593            {label('Session')}
594            <Text dimColor>
595              {m.usd !== undefined ? `$${m.usd.toFixed(2)}` : 'cost n/a'}
596              {d?.cacheHit !== undefined ? ` · cache hit ${d.cacheHit}% on the last request` : ''}
597              {d && d.mcpTokens > 0 ? ` · MCP tools ${compact(d.mcpTokens)}` : ''}
598              {fixed > 0 ? ` · baseline ${compact(fixed)} on every turn (prompt, tools, memory)` : ''}
599            </Text>
600          </Box>
601          {d && d.memoryFiles.length ? (
602            <Box flexDirection="row">
603              {label('Memory')}
604              <Text dimColor wrap="truncate">
605                {d.memoryFiles
606                  .slice(0, 4)
607                  .map(f => `${fileName(f.path)} ${compact(f.tokens)}`)
608                  .join(' · ')}
609              </Text>
610            </Box>
611          ) : null}
612        </Box>
613      </Box>
614    )
615  })
616}
617
hooks/rules.ts 327 lines
1// Pure logic for context-meter: number formats, bars, the estimates and when to warn.
2
3export type Reading = { tokens: number; window: number; percent: number }
4
5const BARS = '▁▂▃▄▅▆▇█'
6
7export const compact = (n: number) =>
8  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
9
10export const signed = (n: number) => `${n >= 0 ? '+' : '−'}${compact(Math.abs(n))}`
11
12// One bar per turn, scaled to the fullest turn shown.
13export function sparkline(history: readonly number[]): string {
14  const max = Math.max(...history, 1)
15  return history.map(t => BARS[Math.min(BARS.length - 1, Math.floor((t / max) * (BARS.length - 1)))]).join('')
16}
17
18// A bar of `cells` cells filled to `part / whole`; a sliver still shows one cell.
19export function gauge(part: number, whole: number, cells: number): { filled: string; empty: string } {
20  const ratio = whole > 0 ? Math.min(part / whole, 1) : 0
21  const n = part > 0 ? Math.max(1, Math.round(ratio * cells)) : 0
22  return { filled: '█'.repeat(n), empty: '░'.repeat(cells - n) }
23}
24
25// A rate limit's color: it only matters close to the cap.
26export const level = (percent: number) => (percent >= 90 ? 'error' : percent >= 75 ? 'warning' : 'success')
27
28// Context quality, not capacity. The 2025-2026 long-context results tie the drop
29// to token counts more than to a share of the window: on 1M models recall bends
30// near 128k-256k, code repair and multi-step work sooner. Each mark is the
31// smaller of a token count and a share of the window, so 200k windows warn early
32// too. Set a little above the measured knee: no results exist yet for the 5.x models.
33export const TIERS = {
34  fading: { tokens: 200_000, share: 0.35 },
35  // The dumb zone, where the Handoff button turns solid: a doc, a fresh
36  // context, the doc read back, each time the person asks for it, never alone.
37  dumb: { tokens: 350_000, share: 0.5 },
38} as const
39
40export type Marks = { fading: number; dumb: number }
41
42// With `autoCompactAt`, the handoff also comes before the engine's own compaction.
43export function marks(window: number, autoCompactAt?: number): Marks {
44  const dumb = Math.min(
45    TIERS.dumb.tokens,
46    Math.round(window * TIERS.dumb.share),
47    autoCompactAt ? Math.round(autoCompactAt * 0.9) : Infinity,
48  )
49  return { fading: Math.min(TIERS.fading.tokens, Math.round(window * TIERS.fading.share), dumb), dumb }
50}
51
52// The engine's own autocompact waits for a handoff until this close to the
53// window; past it, it runs so the session cannot jam.
54export const forcedCompactAt = (window: number) => window - 25_000
55
56// 0 under the fading mark, 1 fading, 2 dumb zone (the handoff).
57export const stageOf = (tokens: number, m: Marks) => (tokens >= m.dumb ? 2 : tokens >= m.fading ? 1 : 0)
58
59export const contextLevel = (tokens: number, m: Marks) =>
60  tokens >= m.dumb ? 'error' : tokens >= m.fading ? 'warning' : 'success'
61
62export type Zone = 'sharp' | 'quality fading' | 'dumb zone'
63
64export const zoneName = (tokens: number, m: Marks): Zone =>
65  tokens >= m.dumb ? 'dumb zone' : tokens >= m.fading ? 'quality fading' : 'sharp'
66
67export function zoneHint(zone: Zone, m: Marks): string {
68  if (zone === 'sharp') return `under ${compact(m.fading)}: full recall`
69  if (zone === 'quality fading') return `${compact(m.fading)}–${compact(m.dumb)}: answers tend to slip`
70  return `past ${compact(m.dumb)}: handoff, then a fresh context`
71}
72
73// Growth of the last turn; a negative value is a compaction.
74export const lastDelta = (h: readonly number[]) => (h.length >= 2 ? h[h.length - 1]! - h[h.length - 2]! : undefined)
75
76// Mean growth over the last few turns that grew; drops (compactions) are left out.
77export function averageGrowth(h: readonly number[], turns = 5): number | undefined {
78  const deltas: number[] = []
79  for (let i = h.length - 1; i > 0 && deltas.length < turns; i--) {
80    const d = h[i]! - h[i - 1]!
81    if (d > 0) deltas.push(d)
82  }
83  return deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : undefined
84}
85
86// Turns of average growth until `limit` (the handoff, or a forced compact).
87export function turnsLeft(tokens: number, limit: number, growth: number | undefined): number | undefined {
88  if (growth === undefined || growth <= 0) return undefined
89  return Math.max(0, Math.floor((limit - tokens) / growth))
90}
91
92const LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
93export const limitName = (kind: string) => LIMIT_NAMES[kind] ?? kind
94
95// "in 2h 10m" for a reset within a day, else the weekday.
96export function resetIn(iso: string | undefined, now: number): string | undefined {
97  if (iso === undefined) return undefined
98  const at = Date.parse(iso)
99  if (Number.isNaN(at)) return undefined
100  const mins = Math.max(0, Math.round((at - now) / 60_000))
101  if (mins < 60) return `in ${mins}m`
102  if (mins < 24 * 60) return `in ${Math.floor(mins / 60)}h ${mins % 60}m`
103  return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][new Date(at).getDay()]
104}
105
106// The last two path parts: `.claude/CLAUDE.md`.
107export const fileName = (path: string) => path.split(/[\\/]/).filter(Boolean).slice(-2).join('/')
108
109// ── model and effort ──────────────────────────────────────────────────────────
110
111export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
112
113// The `/config` model row's value, `opus[1m]`, as a family and its 1M flag.
114export function parseAlias(alias: string): { family: string; isLong: boolean } {
115  const m = /^(.*?)(\[1m\])?$/i.exec(alias.trim())
116  return { family: (m?.[1] ?? alias).toLowerCase(), isLong: Boolean(m?.[2]) }
117}
118
119// The families a button can pick, in the menu's order: plain aliases that are
120// not presets (`default`, `best`, `opusplan`).
121const PRESETS = new Set(['default', 'best', 'opusplan'])
122export const families = (options: readonly string[]) =>
123  options.filter(o => !o.includes('[') && !PRESETS.has(o.toLowerCase()))
124
125// The live model id as a `/model` alias: `claude-opus-5-5[1m]` → `opus[1m]`;
126// undefined for an id that names no family.
127export function aliasOf(id: string): string | undefined {
128  const m = /^claude-([a-z]+)-.*?(\[1m\])?$/i.exec(id.trim())
129  return m ? `${m[1]!.toLowerCase()}${m[2] ? '[1m]' : ''}` : undefined
130}
131
132// The alias to set for a family, keeping the 1M window when that family has one.
133export function aliasFor(family: string, isLong: boolean, options: readonly string[]): string {
134  const long = `${family}[1m]`
135  return isLong && options.includes(long) ? long : family
136}
137
138// `claude-opus-5-5` → `Opus 5.5`; an alias or unknown id comes back capitalised.
139export function modelName(id: string): string {
140  const m = /^claude-([a-z]+)-(\d+)(?:-(\d+))?/i.exec(id)
141  const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
142  if (!m) return cap(id.replace(/\[1m\]$/i, ''))
143  const version = m[3] && m[3].length <= 2 ? `${m[2]}.${m[3]}` : m[2]
144  return `${cap(m[1]!)} ${version}`
145}
146
147// ── the desktop card ──────────────────────────────────────────────────────────
148
149const COLORS = { success: '#2ea043', warning: '#d29922', error: '#f85149' } as const
150
151export type Card = {
152  percent: number
153  tokens: number
154  window: number
155  growth?: string
156  left?: string
157  // A handoff in progress; it takes the place of growth and the countdown.
158  status?: string
159  // The session's marks; the window's own when absent.
160  marks?: Marks
161  limits: { name: string; percent: number; reset?: string }[]
162}
163
164const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
165
166// The card's grid, in viewBox units. The surface scales the whole drawing to
167// the slot, so only the proportions matter: three columns that never touch.
168const CARD = {
169  w: 760,
170  h: 96,
171  ring: { cx: 48, cy: 48, r: 34 },
172  // The text column: the figures, the zone, then growth. Clipped at its edge.
173  text: { x: 104, right: 516 },
174  // The rate-limit column: one label and bar per limit.
175  limits: { x: 540, w: 204, first: 24, step: 36 },
176} as const
177
178// Cuts a line to fit its column, by an average glyph width for the font size
179// (a UI sans at mixed case and digits runs near half the size per glyph).
180export function fit(text: string, widthPx: number, fontSize: number): string {
181  const max = Math.floor(widthPx / (fontSize * 0.5))
182  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`
183}
184
185// One SVG: a ring gauge with the fill and ticks at the quality marks, the token
186// figures large, the quality zone, the growth, and a bar per rate limit. Each
187// line is cut to its column and the column is clipped, so nothing overlaps.
188// Colors follow light or dark.
189export function cardSvg(c: Card): string {
190  const { w: W, h: H, ring, text, limits } = CARD
191  const circ = 2 * Math.PI * ring.r
192  const fill = Math.min(c.percent, 100) / 100
193  const m = c.marks ?? marks(c.window)
194  const color = COLORS[contextLevel(c.tokens, m)]
195  const zone = zoneName(c.tokens, m)
196  const zoneLabel = zone.charAt(0).toUpperCase() + zone.slice(1)
197  const sub = c.status ?? [c.growth, c.left].filter(Boolean).join(' · ')
198  const col = text.right - text.x
199  // Ticks across the ring at the fading and dumb-zone marks.
200  const ticks = [m.fading, m.dumb].map(t => {
201    const a = ((t / c.window) * 360 - 90) * (Math.PI / 180)
202    const [x1, y1, x2, y2] = [
203      ring.cx + (ring.r - 7) * Math.cos(a),
204      ring.cy + (ring.r - 7) * Math.sin(a),
205      ring.cx + (ring.r + 7) * Math.cos(a),
206      ring.cy + (ring.r + 7) * Math.sin(a),
207    ]
208    return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke-width="2" class="tick"/>`
209  })
210  const bars = c.limits.slice(0, 2).map((l, i) => {
211    const y = limits.first + i * limits.step
212    const lc = COLORS[level(l.percent)]
213    const reset = l.reset ? ` · resets ${l.reset}` : ''
214    const label = fit(`${l.name} limit ${Math.round(l.percent)}%${reset}`, limits.w, 12)
215    const pct = `${Math.round(l.percent)}%`
216    // The percent in full strength, the rest dim; split only when the cut kept it.
217    const at = label.indexOf(pct)
218    const labelMarkup =
219      at >= 0
220        ? `${esc(label.slice(0, at))}<tspan class="t" font-weight="600">${esc(pct)}</tspan>${esc(label.slice(at + pct.length))}`
221        : esc(label)
222    return (
223      `<text x="${limits.x}" y="${y}" class="m" font-size="12">${labelMarkup}</text>` +
224      `<rect x="${limits.x}" y="${y + 8}" width="${limits.w}" height="6" rx="3" class="track"/>` +
225      `<rect x="${limits.x}" y="${y + 8}" width="${Math.max(3, (limits.w * Math.min(l.percent, 100)) / 100).toFixed(1)}" height="6" rx="3" fill="${lc}"/>`
226    )
227  })
228  const figure = `${compact(c.tokens)}`
229  const suffix = ` / ${compact(c.window)} context`
230  return (
231    `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">` +
232    `<style>.t{fill:#1f2328}.m{fill:#59636e}.track{fill:#d1d9e0}.ring{stroke:#d1d9e0}.tick{stroke:#59636e}.rule{stroke:#d1d9e0}` +
233    `@media (prefers-color-scheme: dark){.t{fill:#e6edf3}.m{fill:#9198a1}.track{fill:#3d444d}.ring{stroke:#3d444d}.tick{stroke:#9198a1}.rule{stroke:#3d444d}}` +
234    `text{font-family:ui-sans-serif,system-ui,'Segoe UI',sans-serif}</style>` +
235    `<defs><clipPath id="col"><rect x="${text.x}" y="0" width="${col}" height="${H}"/></clipPath></defs>` +
236    // The ring: track, fill, the marks, the percent.
237    `<circle cx="${ring.cx}" cy="${ring.cy}" r="${ring.r}" fill="none" stroke-width="9" class="ring"/>` +
238    `<circle cx="${ring.cx}" cy="${ring.cy}" r="${ring.r}" fill="none" stroke="${color}" stroke-width="9" stroke-linecap="round" ` +
239    `stroke-dasharray="${(circ * fill).toFixed(1)} ${circ.toFixed(1)}" transform="rotate(-90 ${ring.cx} ${ring.cy})"/>` +
240    ticks.join('') +
241    `<text x="${ring.cx}" y="${ring.cy + 7}" text-anchor="middle" font-size="20" font-weight="700" class="t">${c.percent}%</text>` +
242    // The text column, three lines, clipped at its right edge.
243    `<g clip-path="url(#col)">` +
244    `<text x="${text.x}" y="34" font-size="26" font-weight="700" class="t">${esc(figure)}` +
245    `<tspan class="m" font-size="16" font-weight="400">${esc(fit(suffix, col - figure.length * 15, 16))}</tspan></text>` +
246    `<circle cx="${text.x + 5}" cy="54" r="4" fill="${color}"/>` +
247    `<text x="${text.x + 15}" y="59" font-size="13" class="t" font-weight="600">${esc(zoneLabel)}` +
248    `<tspan class="m" font-weight="400">${esc(fit(` · ${zoneHint(zone, m)}`, col - 15 - zoneLabel.length * 8, 13))}</tspan></text>` +
249    `<text x="${text.x}" y="82" font-size="13" class="m">${esc(fit(sub || 'measuring growth after the next turn', col, 13))}</text>` +
250    `</g>` +
251    // A hairline between the text and the limits.
252    (bars.length ? `<line x1="${limits.x - 16}" y1="18" x2="${limits.x - 16}" y2="${H - 18}" stroke-width="1" class="rule"/>` : '') +
253    bars.join('') +
254    `</svg>`
255  )
256}
257
258// The highest stage newly reached (1 fading, 2 dumb zone), or undefined. A drop
259// (after a compaction) re-arms the stages above the new reading.
260export function crossed(stage: number, warned: number): { level?: number; warned: number } {
261  if (stage > warned) return { level: stage, warned: stage }
262  return { warned: Math.min(warned, stage) }
263}
264
265export function warning(stage: number, r: Reading, m: Marks): string {
266  const fill = `Context holds ${compact(r.tokens)} (${r.percent}% of ${compact(r.window)}).`
267  if (stage >= 2)
268    return `${fill} Past ${compact(m.dumb)} is the dumb zone: recall and reasoning drop, and each turn costs more. Press Handoff (or type /handoff) at a good stopping point: Claude writes a state doc, the context is cleared, and Claude reads the doc back.`
269  return `${fill} Past ${compact(m.fading)}, answer quality tends to slip. The dumb zone starts at ${compact(m.dumb)}; Handoff is yours to press.`
270}
271
272// A session that loaded a smaller compaction window than the model has (an old
273// `autoCompactWindow`, say) keeps it for the life of its process: `/clear` and
274// the handoff keep the process, so only a new chat loads the model's own.
275// Undefined when the two are close.
276export function windowNote(loaded: number | undefined, model: number): string | undefined {
277  if (loaded === undefined || loaded >= model * 0.9) return undefined
278  return `This chat loaded a ${compact(loaded)} window from an old setting. The handoff keeps it; a new chat gets the full ${compact(model)}.`
279}
280
281// The tokens every request carries before the conversation itself: the system
282// prompt, tool schemas, memory files, MCP tools. Paid on every turn.
283export const baseline = (categories: readonly { name: string; tokens: number; kind: string }[]) =>
284  categories.filter(c => c.kind === 'used' && !/^messages$/i.test(c.name)).reduce((n, c) => n + c.tokens, 0)
285
286// ── the handoff ───────────────────────────────────────────────────────────────
287
288export const HANDOFF_COMMAND = {
289  name: 'handoff',
290  description: 'Write a state doc, clear the context, read the doc back (context-meter).',
291} as const
292
293// Working notes in the repo, never committed (the folder ignores itself).
294export const HANDOFF_DIR = '.claude/handoff'
295
296// `2026-10-06-1000-abcdef12.md`: the time (UTC) keeps a retry from writing over
297// a good doc from earlier the same day.
298export function handoffPath(root: string, sessionId: string, now: number): string {
299  const base = root.replace(/\\/g, '/').replace(/\/+$/, '')
300  const iso = new Date(now).toISOString()
301  return `${base}/${HANDOFF_DIR}/${iso.slice(0, 10)}-${iso.slice(11, 16).replace(':', '')}-${sessionId.slice(0, 8)}.md`
302}
303
304export const handoffPrompt = (path: string, r: Reading) =>
305  [
306    `Context holds ${compact(r.tokens)} tokens. Before this context is cleared, write a handoff doc to ${path} with the Write tool (replace the file if it exists).`,
307    'Write it for a fresh session that has none of this conversation. Use these sections:',
308    '1. Goal: what the user wants, in their words where it matters.',
309    '2. Current state: what is done, what is in progress, the branch and uncommitted changes.',
310    '3. Decisions: what was chosen and why, and what the user rejected.',
311    '4. Key files: each path with one line on its role.',
312    '5. Open problems: errors seen, approaches that failed, gotchas.',
313    '6. Next steps: numbered, the very next action first.',
314    'Keep it under 300 lines. Do not start new work. When it is written, reply with one line.',
315  ].join('\n')
316
317// For the fallback, when `/clear` is refused.
318export const compactInstructions = (path: string) =>
319  `A handoff doc for this session is at ${path}. Name that path in the summary as the source of truth for state and next steps. Keep the user's latest request and any question still open to them.`
320
321export const readPrompt = (path: string, how: 'cleared' | 'compacted') =>
322  [
323    `The context was just ${how} for a handoff. Read the handoff doc at ${path}.`,
324    'Check it against the repo: git status, and the files it names.',
325    'Then say in a few lines what is stale or wrong, and what the next step is. Wait for me before you start it.',
326  ].join('\n')
327
types/index.d.ts 64 lines
1export type Limit = { kind: string; percent: number; resetsAt?: string }
2
3export type Meter = {
4  tokens: number
5  // The model's full window; the meter's percent is a share of it.
6  window: number
7  percent: number
8  // Tokens after each main-loop turn, newest last.
9  history: number[]
10  limits: Limit[]
11  usd?: number
12}
13
14export type Category = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
15
16// The /context breakdown, estimated locally; refreshed at turn end and on expand.
17export type Detail = {
18  // The window the session compacts against: the model's limit, or a smaller
19  // one from settings or the env (`autoCompactWindow`), as /context reports it.
20  window: number
21  windowSource: string
22  categories: Category[]
23  autoCompactAt?: number
24  // Share of the last request's input served from the prompt cache, 0 to 100.
25  cacheHit?: number
26  memoryFiles: { path: string; tokens: number }[]
27  mcpTokens: number
28}
29
30// What the model and effort buttons show and set.
31export type Setup = {
32  // The `/config` model row: its value (`opus[1m]`) and its choices.
33  alias?: string
34  options: string[]
35  // The last main-loop request's resolved model id and effort.
36  model?: string
37  effort?: string | number
38}
39
40// A handoff in progress: queued for the end of the turn, the doc being written,
41// the context being cleared, then the doc read back. `/clear` starts a new
42// session with empty state, so the read-back step sets its phase afresh.
43export type Handoff = {
44  phase: 'queued' | 'writing' | 'clearing' | 'reading'
45  path?: string
46  // When the doc was asked for; a doc older than this is stale.
47  since?: number
48  // Turns ended while waiting for the doc; it gives up after two.
49  waited?: number
50}
51
52declare module 'claude-code' {
53  interface PluginState {
54    'context-meter': {
55      meter: Meter | null
56      detail: Detail | null
57      setup: Setup | null
58      isOpen: boolean
59      warned: number
60      handoff: Handoff | null
61    }
62  }
63}
64