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…

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.
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
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.
Replace each <!-- placeholder --> with the project's name, stack, and real commands, and delete the sections you don't need.
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.CLAUDE_SKIP_CHECKS=1 claude turns the hook off for a session.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.
| 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.
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.cat. A script that opens a file itself is only stopped by the OS sandbox.*.pem and *.key are denied wholesale.** Delete those two lines from settings.json if your repo keeps non-secret files with those extensions.npm ls <pkg>, pnpm why <pkg>), or delete the lockfile lines from settings.json.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.
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.
hooks/register.tsx 617 lines1import { 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}
617hooks/rules.ts 327 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>')
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')
327types/index.d.ts 64 lines1export 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