Two rows above the prompt in three divided columns: context with the model, effort and cache hit rate; the 5-hour window with its pace (used over the window…

Personal collection of Claude Code and Codex skills.
Each top-level folder containing a SKILL.md is one skill. This repo is the single source of truth; skills become active by linking them into each agent's skill directory, where Claude Code and Codex discover them.
| Skill | What it does |
|---|---|
thegraph-codex | Codex-native, explicitly invoked version of thegraph's end-to-end discipline: establish the task from repository guidance and primary sources, make routine implementation decisions autonomously, ask only for consequential product or scope decisions, prove behavioural tests can fail, then run verification proportionate to the changed surface. It does not require Claude Code platform skills or create external tracker records without an explicit request. |
to-html | Convert a markdown file — or content already in the conversation — into a single self-contained, styled HTML file: inline CSS, sticky table of contents, callouts, premium layout blocks. No build step. |
brief | Explain the work in flight to the person who has to decide about it — state, the next action, what it changes and what it leaves alone, and what they must decide (including "nothing", which is information). Publishes a one-screen Artifact with its own briefing layout; falls back to chat when there is no structure worth laying out. Status claims are queried with gh/git rather than copied out of the conversation, and measured facts are marked apart from inferred ones. Vocabulary is governed by a structural check — every noun must point at something the user can see or do — not by a banned-word list, because the words that lose someone next are never the ones already on the list. A view, never a source: nothing may exist only in a briefing. Invoked only (/brief, /brief <issue>, /brief session) — judging whether someone understands you is not yours to do. |
gate | The Acceptance Gate — review an AFK agent's finished work on an issue, render a verdict (Pass / Rework / Respec / Escalate) with the matching next action, and run it on the tracker once you approve. Delegates the diff review to the code-review skill. |
to-deck | Retired — no longer installed; moved to retired/, see ADR-0059 and ADR-0027. Turned a study PDF into a reusable JSON question bank through an interactive Korean Q&A session: keeps the workbook's own questions in order (origin: workbook) and adds generated extras (origin: extra) — a mix of explanatory and single-term recall questions — each a plain {question, answer} pair. Offers a one-at-a-time session or batch extraction; no question-type labels — a downstream tool classifies them. |
grill-code | Retired — no longer installed; moved to retired/, see ADR-0058 and ADR-0027. Grilled code along one chosen mode — security, common-component extraction, refactoring, performance, or learning — scanning the scope relentlessly. Defect modes score each finding on severity + effort and rank them by priority (P0–P3); the non-defect learning mode explains AI-written code as Lessons ranked by Value. Report only; standalone (no dependency on code-review/audit). Can save the report as HTML via to-html. |
img-to-pdf | Combine a folder of images (or an explicit file list) into one PDF, one image per page, in natural-sort order (2.jpg before 10.jpg). Default: a searchable PDF — the page image plus an invisible, selectable/searchable text layer via macOS Vision OCR (Korean + English). --image-only instead embeds JPEG/PNG losslessly with no text layer (HEIC/HEIF/WEBP pre-converted via sips; img2pdf auto-installed into a cached venv). OCR mode is macOS-only; the Swift helper compiles once and caches. |
theflow | Retired — no longer installed; moved to retired/, see ADR-0027. The seven-step working discipline for a substantive change to a boundary-defined codebase, succeeded by thegraph which carries every rule it held (ADR-0019). Kept readable because the docs/agents/theflow.md bindings it read still exist in the repos it onboarded — nothing reads them now, grill-the-graph's path for compiling one having gone with ADR-0067. |
grill-the-flow | Retired — no longer installed; moved to retired/, see ADR-0027. The setup half of theflow — it interrogated a maintainer to author the repo's docs/agents/theflow.md bindings. Succeeded by grill-the-graph, which asks a much smaller question and writes a much smaller file. Nothing reads the bindings docs it wrote, that path having gone with ADR-0067. |
read-it | Read the situation before touching anything, and come back with a route. The first act of a change and the only one whose mistakes nothing downstream catches — a wrong proof method fails at the proof and a wrong placement fails at the diff, while a wrong reading of the issue passes everything, a run that solves the wrong problem well having still solved something well. Four reads, three of them conditional on something you check rather than weigh: the build (short, and answering little on purpose); the cluster, where one tracker query finds a parent (spine — the anchor's root is a hypothesis to test); the territory map where the repo keeps one; and always the real sources plus the hidden state (firsthand, one reflex rather than two — the source list widens what you read, it never decides whether you read, and with no outside source named you still enumerate the hidden state of the code you are touching). Doing one of the four and doing all four are both ordinary; what is not is skipping one and being unable to say why. Then restates the issue in its own words and stops to ask — the only guaranteed stop, since every later one is conditional and a run where nothing goes wrong first reaches a person with the work already done and nowhere to have redirected it. Returns a route label, never a skill name, so what runs next stays the caller's to decide. Everything it finds is a candidate; nothing reaches a tracker unasked. |
make-it | Write the change. Calls tdd for the tests and redden after every one of them — a green from a test nobody watched fail is not evidence, which is the one thing tdd does not do. Keeps the narrow exception where compilation itself enforces completeness and a round-trip test replaces strict RED, and the rhythm a whole-suite-at-the-end habit loses: typecheck and the nearby tests often, everything once at the end. Prose changes walk it too, with the test section conditional. Carries the rule that a comment says what the code is, and follows the repo's CLAUDE.md for where the rest goes rather than holding its own copy — comments are written on every edit, and most edits never pass through make-it. Its substance is the four signals that only appear while your hands are in the code, since you cannot schedule the moment you will feel one: "this is a bit odd, I'll just fix it up here" — the tell that the problem is one step further in, tempting because it works and costs nothing visible, until the day somebody fixes the real thing and every patch like it becomes silently wrong; a new file with no obvious home; writing against an outside reference; and a call that is the maintainer's. What you notice and do not fix is carried, never filed. |
check-it | Measure a finished change before anyone calls it done — the hand that shaped a thing is the worst judge of whether it is finished. Eight checks, ordered by cost rather than ceremony, each conditional: the gates bare (bare), whether new or moved files landed where the layout rule said, proof against something real rather than a fake, the diff read for shape (assay), for cost (silt) and for exposure (security-review) at the same time, the whole corpus read for what is missing (lens), and the surfaces that describe the behaviour (sweep). A prose change runs the last two and nothing else. One question runs through all of it — a red, a defect, a defensive line: is it mine? Answered by re-running against the tree without this change and comparing the failure, never the exit code, since a baseline red for its own reason makes every red compare 1 to 1 and inverts the attribution silently. And one shape is hunted assuming the writer left no help at all, because whoever did not notice could not have written it down: for each defensive line, what would have to be true for this to be unnecessary? — if the answer names something you did not change, that is where it belongs. Fixes nothing; measures, attributes, hands on. |
decant | Sort source comments paragraph by paragraph under one policy: a comment says what the code is, and the why, the trap, the measured value and the history move to the repo's map. Six bins; the unit is the paragraph; the drop question is asked last, because a wrong move costs a lookup and a wrong drop costs a measurement. Checks every DESIGN against the code and then its note, and reports what the map is missing (notes owed) and where note and comment give different reasons (two grounds). Runs on a whole repository, territory by territory in an order derived by counting, when a maintainer asks — never per change, because a check after the fact is undone by the next edit made outside it; the write-time rule lives in the repo's CLAUDE.md. Reports and stops — unless the maintainer hands it the writes, and then it moves sentences rather than rewriting them, accounts for every one, and re-points whatever quoted them. The measurements behind it are ADR-0076. In a repository you contribute to, upstream's guidance and closest merged PR replace that policy, and only the comments the branch adds or changes are read (ADR-0079). |
winnow | Cut an always-loaded agent document — CLAUDE.md, AGENTS.md, a skill body — down to what every run needs. Five bins per paragraph: rule stays, detail moves behind a pointer that names its trigger, number stays only where a decision turns on it, roster becomes the command that derives it, history leaves for version control. History is asked last, and an incident story has its rule extracted before the story goes. Shows the table — every history part quoted, not counted — and stops for the maintainer; on approval writes, then proves every rule still has a home by reading the original from version control, and repoints references to moved sections. The case it was cut from is ADR-0077. |
assay | Test what a change is made of, along two axes kept apart because a change can pass one and fail the other: does the code hold up, and does it do what was asked. The first carries this repo's own documented standards — which always outrank it — plus Fowler's twelve code smells as labelled heuristics, never hard violations, covering cohesion (Divergent Change), coupling (Feature Envy, Shotgun Surgery, Message Chains) and duplication (Duplicated Code, Data Clumps, Primitive Obsession); and it opens the module the change landed in, since "edited for several unrelated reasons" is a claim about a module and a diff shows one slice of it. The second asks what the issue wanted and got, what it wanted and did not, and what arrived that nobody asked for. Findings are graded violation / judgement / already-decided — the third is why the records get read, since a finding that contradicts a recorded decision re-opens a call somebody made. Never merged, never ranked across axes, one closing line per axis. |
silt | Read a change for what will make it slow, and refuse to call anything a defect without a number. Silt is what a river drops: nothing dramatic, nothing wrong on any given day, and one season later the channel is shallow — which is the shape of the performance defects that actually cost, since the ones that announce themselves get found. Carries seven shapes with, for each, the cheapest way to find out whether it is real here: registered and never released, a container that only grows, I/O inside a loop, a large structure copied by value, recomputed every time, blocking the thread that must not block, a lookup with nothing to make it fast. A suspicion arrives with the way to settle it or it does not go out at all — that bar is what stops this becoming a list of things that make somebody uneasy. And a number with nothing beside it is not a number: measure the same thing on the tree without the change. Proposes no optimisation, does not widen, and does not accept "it's fine, it's small" — if it is small the measurement is cheap. |
ask-it | Put everything that has been carried in front of a person, once, and file only what they keep — the one door, since nothing may evaporate into a pull-request body or a code comment, where it is buried on merge. envelope owns the collecting and the seven columns; what this adds is that the items arrive from different places with dispositions that share no vocabulary — something to file, something to decide, something belonging to another repository, something a release will oblige — and interleaved, the reader classifies before answering, which is the sorting that presenting one at a time was bought to remove. byartifact searches before anything is opened, and only for items actually being filed. A follow-up is parented through the tracker's own relation in the same act as filing it, and whether it also joins the cluster is a separate judgement: provenance and root are different claims, and the error is not symmetric — missing a sibling costs a reconstruction, counting an adjacent one corrupts the roster that somebody later copies almost verbatim. A release's obligations are the consumer tests that actually broke, named; a purely additive release obliges nothing, and that is an answer worth writing. |
thegraph | Take one change from an issue to done, in a codebase whose identity is a boundary — a core that stays correct by not absorbing the concerns of the things that consume it. Four skills in order — read-it → make-it → check-it → ask-it — with one guaranteed stop before any of them run: the reading of the issue, confirmed by the person who holds it, since a change where no signal fires otherwise reaches a person for the first time at the very end, with the work done and nowhere to have redirected it. Two routes after that, and the one that gets missed is expensive: an issue asking for a choice gets the adversarial read over the options before any proposal, or the enumeration lands after approval and its costs get demoted into follow-ups. Everything else is a signal you cannot schedule — the urge to patch around something one step further in, a test just written, an outside reference about to be relied on, a call that is the maintainer's — acted on where it arrives rather than noted for later. What holds throughout: nothing reaches a tracker unasked; what can be counted is counted; a derivation falls to a better derivation and a judgement falls only to the person who made it, so a record that does not say which it holds reads as the first. Human-invoked (/thegraph). |
grill-the-graph | The setup half of thegraph. Writes one short prose file, docs/agents/thegraph.md, holding what a person already knows and no file in the repo answers — which is very nearly just the real sources the project is built against, each marked how it works or where files go, raw or summarized, an example to follow or a spec that binds. A line earns its place only by clearing three conditions together: no file here answers it, a person already knows it, and something reads it. The second is the one that used to be missing — "only a person could decide it" and "a person already knows it" are different sets, and everything this skill once asked for (sacred paths, the tie-breaker, the seams, a proof method per layer) cleared the first and failed the second, so the answers came back drafted by the builder and ratified by someone who could not have produced them. Generates no scripts and no agents, carries no build stamp, and copies nothing the repo already states; the build grows as runs ask for what it lacks, and is never rebuilt. An older generated build — scripts/thegraph/, .claude/agents/thegraph-* — is handed to salvage rather than read or updated. A project built against nothing outside itself yet answers none yet, and the file records that the question was put rather than inventing a row. Where the repo has no territory map, it makes the map's folder with a one-line hub instead of building one — the rule needs a destination on disk, and an empty map is a young repo's honest state; notes arrive as edits need them or all at once through grill-map. Then it writes the comment rule into the repo's CLAUDE.md: a comment says what the code is, with the rest sent to the map, the decision records or the commit message, naming only destinations that exist on disk. That is the one place the rule is written, because comments are written on every edit and a pass that checks them afterwards is undone by the next one. In a repository you contribute to rather than own — named by the argument (/grill-the-graph local, or own for your own), and asked first when there is none, with viewerPermission on the receiving repository as the recommendation — it commits nothing: the same file and a rule deferring to upstream's own guidance go in untracked, excluded files, no map is made, and a hook blocks Claude attribution on the way out (ADR-0079). In your own repository attribution stays unless you turn it off when asked, and off installs the same check, locally. |
salvage | Clear an older grill-the-graph build out of a repo without losing the answers a person gave it. The whole job is telling two things apart: what somebody sat down and answered, which has to survive, and what was copied out of the repository, which is why that build went. Finds what is ours by the build stamp every generated file carries and by the build document's own manifest, and reads both — a stamped file the manifest does not name is the case that matters, the manifest being a copy like any other. Four rings: ours (build doc, generated agents and scripts, the ignore line, and the run-state cache version control never shows you); somebody else's file pointing at ours, where only the line comes out — a repo left with a hook invoking a deleted script cannot commit, so clearing the first ring and reporting the second is a break with a note attached; produced by our rules but not written by us, shown and asked, and any generated file a later commit touched, because that made it theirs; and outside, untouched — decision records describe the world when they were written, broken links included. Lifts the reference list into the short file the current skill defines, drops the rest with a reason each, and re-reads the tree afterwards, since a cleanup reporting success without looking again has checked nothing. |
tickets | Retired — no longer installed; moved to retired/, see ADR-0069 and ADR-0027. Put back what slicing dropped — the third producer in the thegraph family. A general slicer keeps a ticket thin because it cannot know who will execute it; here the executor was known, so the calls a maintainer had already made travelled with the ticket instead of staying behind in the spec. Ran right after the tickets were cut, in the same unbroken window as the grilling: transcribed the settled calls into each ticket, enrolled the parent as the relation the tracker actually renders (which is still why spine's roster is a query and not a body parse), and marked each entry human / derived / unknown. It read thegraph's "What the issue must supply" at runtime rather than hardcoding it, and that is the one thing it demonstrably bought: the schema went from five slots to six and the producer needed zero edits. Retired on the maintainer's call, with the falsification ADR-0045 had written in advance already fired — on the only run anyone measured, every contract entry was marked derived, which licenses nothing. The schema it filled is live; thegraph reads a contract wherever one is written, and a ticket that answers none of it was always the ordinary case. |
spine | Read the cluster before the ticket, extracted from thegraph's node of the same name (ADR-0042). An issue is written at filing time and read at work time; in between, the context that made it obvious decays, so a fix reasoned from the ticket alone re-decides what a sibling already settled — which is exactly how closing one issue produces the next one, while looking like diligence. Read the anchor and its siblings for three things: the suspected shared root, what each established including what it rejected, and what is explicitly still open. The anchor's root is a hypothesis to test, and falsifying it is the more valuable of the two outcomes. Briefs from live state, anchored on the human's last touch, in outcome voice. Fuses catchup. |
firsthand | Read the real thing before you guess, and never promote ignorance into fact — thegraph's reference and enumerate nodes, which turned out to be one reflex split across two slots (ADR-0042). Fetch raw source and grep the actual lines: never a summarizing fetch, because summary drops method bodies and a handler that is there reads as absent. A feature being new never excuses skipping its mechanism layer, which the reference almost always has. Pin a runtime value with a throwaway probe and record the number — reading code is not observing what it does. Then the exit guard: unconfirmed is a gap, not an absence. Re-confirming costs one fetch; guessing wrong costs days. Clear a worry and you record the condition it holds under. Fuses factchk. |
| sweep | Every surface that describes the behaviour drifts the moment the behaviour moves, and nothing compiles the drift away (ADR-0042). Doc-comments ship verbatim and are the last thing describing a fixed bug as a contract; a published changelog entry is never rewritten, only superseded; a decision record whose premise the change falsified is amended in that same change, because
hooks/register.tsx 335 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
4import type { Live } from '../types'
5import {
6 CACHE_READINGS,
7 FIGURE_COLOR,
8 WINDOW_MS,
9 cacheHit,
10 cacheLevel,
11 clockOf,
12 TODAY_LOUD_USD,
13 contextUsed,
14 effortFromSettings,
15 evenPace,
16 fiveHourRate,
17 gaugeCells,
18 gaugeWidth,
19 isLoud,
20 isTodayUnpriced,
21 levelOf,
22 paceLevel,
23 paceOf,
24 dailyShare,
25 modelLabel,
26 parseBurn,
27 parseToday,
28 recordFive,
29 runsOutAt,
30 shareLevel,
31 sinceArg,
32 usd,
33} from './format'
34
35const live = atom({ plugin: 'usage-band', key: 'live' } as const, null)
36const effort = atom({ plugin: 'usage-band', key: 'effort' } as const, null)
37const ledger = atom({ plugin: 'usage-band', key: 'ledger' } as const, null)
38const fiveSamples = atom({ plugin: 'usage-band', key: 'fiveSamples' } as const, [])
39const cacheReadings = atom({ plugin: 'usage-band', key: 'cacheReadings' } as const, [])
40
41/** Blank cells at the band's left, so its text lines up with the turn line's after `✻ `; and at its right. */
42const LEFT = 2
43const RIGHT = 1
44
45/** Cells for a gauge's label (`ctx `, `5h `), and the divider between two columns. */
46const GAUGE_LABEL = 4
47const DIVIDER = ' │ '
48
49/** How often ccusage is asked between turns; each answer also redraws, so %/h decays while idle. */
50const LEDGER_MS = 120_000
51/** A turn's measurement asks again only once this long has passed since the last ask. */
52const LEDGER_GAP_MS = 30_000
53
54// No shell runs the argv: on Windows `ccusage` is a .cmd shim that only cmd.exe starts.
55const LAUNCHERS: readonly (readonly string[])[] = [['ccusage'], ['cmd', '/c', 'ccusage']]
56
57type Figures = Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'> | SessionMeasureInput
58
59const toLive = (model: string, u: Figures): Live => ({
60 model,
61 context: {
62 tokens: u.context.tokens ?? null,
63 window: u.context.window,
64 percent: u.context.percent ?? null,
65 },
66 rateLimits: u.rateLimits.map(r => ({
67 kind: r.kind,
68 percentUsed: r.percentUsed,
69 resetsAt: r.resetsAt ?? null,
70 })),
71 usd: u.cost?.usd ?? null,
72})
73
74const fiveOf = (l: Live | null) => l?.rateLimits.find(r => r.kind === 'five_hour')
75
76// Takes a reading: the 5-hour window's goes in its history for the forecast.
77const refreshLive = async ($: EngineInterface, figures?: Figures) => {
78 const model = await $.session.model()
79 const u = figures ?? (await $.session.usage())
80 const next = toLive(model, u)
81 const previous = fiveOf(await read($, live))
82 const five = fiveOf(next)
83 const now = await $.clock.now()
84 if (five) {
85 await update($, fiveSamples, samples =>
86 recordFive(samples, five.percentUsed, five.resetsAt, previous?.resetsAt ?? five.resetsAt, now),
87 )
88 }
89 await update($, live, () => next)
90}
91
92// Module state: a reload starts these over, which costs one extra ccusage run.
93let launcher: readonly string[] | null = null
94let lastAsk = -Infinity
95let isAsking = false
96
97const ccusage = async ($: EngineInterface, args: readonly string[]): Promise<string | null> => {
98 for (const l of launcher ? [launcher] : LAUNCHERS) {
99 try {
100 const r = await $.process.run([...l, ...args], { timeoutMs: 20_000 })
101 if (r.exitCode === 0) {
102 launcher = l
103 return r.stdout
104 }
105 } catch {
106 // This launcher cannot start here; try the next.
107 }
108 }
109 return null
110}
111
112const parsed = <T,>(stdout: string | null, parse: (s: string) => T): T | null => {
113 if (stdout === null) return null
114 try {
115 return parse(stdout)
116 } catch {
117 return null
118 }
119}
120
121const askLedger = async ($: EngineInterface, isForced = false) => {
122 const now = await $.clock.now()
123 if (isAsking || (!isForced && now - lastAsk < LEDGER_GAP_MS)) return
124 isAsking = true
125 lastAsk = now
126 try {
127 const [blocks, daily] = await Promise.all([
128 ccusage($, ['blocks', '--active', '--json', '--offline']),
129 ccusage($, ['daily', '--json', '--offline', '--since', sinceArg(now)]),
130 ])
131 await update($, ledger, () => ({ burnPerHour: parsed(blocks, parseBurn), today: parsed(daily, parseToday) }))
132 } finally {
133 isAsking = false
134 }
135}
136
137// Until the first request says what effort it was sent with, show what settings name.
138const seedEffort = async ($: EngineInterface) => {
139 if ((await read($, effort)) !== null) return
140 const l = await read($, live)
141 if (l === null) return
142 const fromSettings = effortFromSettings(await $.settings.read(), l.model)
143 if (fromSettings !== null) await update($, effort, () => fromSettings)
144}
145
146// Everything the band shows, read again: at start, and after /clear, /resume or /branch
147// reset $.state, which session.start does not follow.
148const fill = async ($: EngineInterface, isForced: boolean) => {
149 await refreshLive($)
150 await seedEffort($)
151 await askLedger($, isForced)
152}
153
154export const register: Register = on => {
155 on('session.start', async ($, e, next) => {
156 const result = await next(e)
157 await refreshLive($)
158 await seedEffort($)
159 // Off the start's path: ccusage takes about a second and the first prompt need not wait.
160 $.clock.after(0, () => void askLedger($))
161 $.clock.every(LEDGER_MS, () => void askLedger($))
162 return result
163 })
164
165 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
166 const result = await next(e)
167 await fill($, true)
168 return result
169 }).catch(($, e, next) => next(e))
170
171 on('session.measure', async ($, e, next) => {
172 const result = await next(e)
173 await refreshLive($, e)
174 await askLedger($)
175 return result
176 })
177
178 // A compaction empties the window, which no measurement reports until the next response.
179 // A precompute leaves the conversation as it is, and a subagent's compacts only its own.
180 on('session.compact', async ($, e, next) => {
181 const result = await next(e)
182 if (result.skip === undefined && e.trigger !== 'precompute' && e.agentId === undefined) {
183 await refreshLive($)
184 }
185 return result
186 })
187
188 on('classic.PostModelSwitch', async ($, e, next) => {
189 await update($, live, prev => (prev === null ? null : { ...prev, model: e.to_model }))
190 return next(e)
191 }).catch(($, e, next) => next(e))
192
193 // The effort a main-thread request is actually sent with, after any downgrade for the model,
194 // and what the cache served of its prompt.
195 on('turn.step', async function* ($, e, next) {
196 if (e.agentId === undefined) {
197 const sent = e.effort === undefined ? null : String(e.effort)
198 if ((await read($, effort)) !== sent) {
199 await update($, effort, () => sent)
200 }
201 }
202 const result = yield* next(e)
203 const usage = result.usage
204 if (e.agentId === undefined && usage !== null) {
205 const reading = { read: usage.cache_read_input_tokens, written: usage.cache_creation_input_tokens, uncached: usage.input_tokens }
206 await update($, cacheReadings, list => [...list, reading].slice(-CACHE_READINGS))
207 }
208 return result
209 })
210
211 // Three columns, divided, each a head over its gauge: context (the model, its effort, the cache),
212 // the 5-hour window (its pace or when it runs out, and its reset), and the week with the money.
213 // Quiet until something needs saying: colour is kept for a limit past 80%, a pace that runs
214 // out, a cold cache, a heavy day, and a week with less than a day's share to spare — or, the
215 // one colour for good news, a day's share or more.
216 // Whatever other bands draw goes above, so these rows stay next to the prompt whichever hook runs first.
217 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
218 const below = await next(e)
219 const l = await read($, live)
220 if (e.props.hasSurvey || l === null) {
221 return below
222 }
223
224 const eff = await read($, effort)
225 const led = await read($, ledger)
226 const samples = await read($, fiveSamples)
227 const now = await $.clock.now()
228 const { Box, Text } = $.ui.resolve(e)
229
230 // A piece of a row: its text and how it is drawn.
231 type Seg = { text: string; color?: string; bold?: boolean; dim?: boolean }
232 const draw = (segs: Seg[]) => segs.map(g => <Text color={g.color} bold={g.bold} dimColor={g.dim}>{g.text}</Text>)
233 // A column's head, cut or padded to the gauge's width so the dividers line up.
234 const fit = (segs: Seg[], n: number): Seg[] => {
235 const out: Seg[] = []
236 let used = 0
237 for (const g of segs) {
238 const take = [...g.text].slice(0, Math.max(0, n - used)).join('')
239 if (take !== '') out.push({ ...g, text: take })
240 used += [...take].length
241 }
242 return used < n ? [...out, { text: ' '.repeat(n - used) }] : out
243 }
244 const dot: Seg = { text: ' · ', dim: true }
245
246 // The heads.
247 const hit = cacheHit(await read($, cacheReadings))
248 const contextHead: Seg[] = [
249 { text: modelLabel(l.model), color: FIGURE_COLOR.model, bold: true },
250 ...(eff !== null ? [{ text: ` ${eff}`, dim: true }] : []),
251 ...(hit !== null ? [dot, { text: 'cache ', dim: true }, { text: `${hit}%`, color: hit >= 80 ? undefined : cacheLevel(hit) }] : []),
252 ]
253 const five = fiveOf(l)
254 const rate = five ? fiveHourRate(samples, five.percentUsed, five.resetsAt, now) : null
255 const outAt = five && rate !== null ? runsOutAt(rate, five.percentUsed, five.resetsAt, now) : null
256 // Pieces of a head, a dot between those that say something.
257 const join = (...parts: Seg[][]): Seg[] => parts.filter(p => p.length > 0).flatMap((p, i) => (i === 0 ? p : [dot, ...p]))
258 // A limit's pace leads its head: the share used over the share of its window gone, quiet up to 80%.
259 const pace = (used: number, resetsAt: string | null, windowMs: number): Seg[] => {
260 const p = paceOf(used, resetsAt, windowMs, now)
261 if (p === null) return []
262 const lv = paceLevel(p)
263 return [{ text: `${p}% pace`, bold: true, ...(lv === 'success' ? {} : { color: lv }) }]
264 }
265 const fiveHead: Seg[] | null =
266 five === undefined ? null
267 : join(
268 pace(five.percentUsed, five.resetsAt, WINDOW_MS.fiveHour),
269 outAt !== null ? [{ text: `out at ${clockOf(outAt)}`, color: 'error', bold: true }] : [],
270 five.resetsAt != null ? [{ text: `resets ${clockOf(Date.parse(five.resetsAt))}`, dim: true }] : [],
271 )
272 // The week: what it has left over the days left, today counted whole.
273 const seven = l.rateLimits.find(r => r.kind === 'seven_day')
274 const share = seven && seven.percentUsed < 100 ? dailyShare(seven.percentUsed, seven.resetsAt, now) : null
275 const shareLv = share === null ? null : shareLevel(share.basis)
276 const weekLead: Seg[] = share === null ? [] : [{ text: `${share.perDay.toFixed(1)}%/day`, bold: true, ...(shareLv === null ? {} : { color: shareLv }) }]
277 const moneyHead: Seg[] = [
278 ...(led?.today == null ? []
279 : isTodayUnpriced(led, l) ? [{ text: 'today $0 · model not priced in ccusage', color: 'warning' }]
280 : [{ text: 'today ', dim: true }, { text: usd(led.today), ...(led.today >= TODAY_LOUD_USD ? { color: 'warning', bold: true } : {}) }]),
281 ...(led?.burnPerHour != null ? [...(led.today == null ? [] : [dot]), { text: `${usd(led.burnPerHour)}/h`, dim: five !== undefined }] : []),
282 ]
283
284 // The gauges: the 5-hour one marked where an even pace would have it; the week's has no mark.
285 const gauges = [
286 { label: 'ctx', used: contextUsed(l.context), mark: null as number | null, head: contextHead },
287 ...(five ? [{ label: '5h', used: five.percentUsed, mark: evenPace(five.resetsAt, WINDOW_MS.fiveHour, now), head: fiveHead ?? [] }] : []),
288 ...(seven ? [{ label: 'wk', used: seven.percentUsed, mark: null, head: weekLead }] : []),
289 ]
290 // The money goes after the last head: the week's share, or whatever column comes last.
291 const last = gauges[gauges.length - 1]
292 if (last) last.head = join(last.head, moneyHead)
293
294 const each = gaugeWidth(e.props.bodyColumns - LEFT - RIGHT, gauges.length, DIVIDER.length)
295 const divider = <Text dimColor>{DIVIDER}</Text>
296 const top = (
297 <Text wrap="truncate-end">
298 {gauges.map((g, i) => (
299 <Text>
300 {i > 0 && divider}
301 {draw(fit(g.head, each))}
302 </Text>
303 ))}
304 </Text>
305 )
306 const bottom = (
307 <Text wrap="truncate-end">
308 {gauges.map((g, i) => {
309 const figure = ` ${String(g.used).padStart(3)}%`
310 const cells = gaugeCells(Math.max(4, each - GAUGE_LABEL - figure.length), g.used, g.mark)
311 const loud = isLoud(g.used) ? levelOf(g.used) : undefined
312 return (
313 <Text>
314 {i > 0 && divider}
315 <Text dimColor>{g.label.padEnd(GAUGE_LABEL)}</Text>
316 {cells.map(c => (c === 'mark' ? <Text bold>┊</Text> : c === 'fill' ? <Text color={loud}>━</Text> : <Text dimColor>─</Text>))}
317 <Text bold color={loud}>{figure}</Text>
318 </Text>
319 )
320 })}
321 </Text>
322 )
323
324 return (
325 <Box flexDirection="column">
326 {below}
327 <Box flexDirection="column" paddingLeft={LEFT} paddingRight={RIGHT}>
328 {top}
329 {bottom}
330 </Box>
331 </Box>
332 )
333 })
334}
335hooks/format.ts 242 lines1import type { FiveSample, Ledger, Live } from '../types'
2
3export type Level = 'success' | 'warning' | 'error'
4
5/** 80% warns, 90% is critical; below that a figure stays quiet. */
6export const levelOf = (used: number): Level =>
7 used >= 90 ? 'error' : used >= 80 ? 'warning' : 'success'
8
9export const isLoud = (used: number): boolean => used >= 80
10
11/** Percent of the context window used; 0 before the first response reports a fill. */
12export const contextUsed = (context: Live['context']): number => {
13 if (context.percent !== null) return context.percent
14 if (context.tokens === null || context.window === 0) return 0
15 return Math.round((context.tokens / context.window) * 100)
16}
17
18// `claude-haiku-4-5-20251001` → haiku 4.5; a trailing date or `[1m]` is not a minor version.
19const MODEL_ID = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/
20
21export const modelLabel = (id: string): string => {
22 const m = MODEL_ID.exec(id)
23 if (!m || !m[1] || !m[2]) return id.toLowerCase()
24 return `${m[1]} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
25}
26
27/** The one colour the band keeps while quiet: the model's, from the Claude Code theme. */
28export const FIGURE_COLOR = { model: 'claude' } as const
29
30export const usd = (n: number): string => `$${n.toFixed(2)}`
31
32/**
33 * The effort settings name for a model, before any request has said what it was sent
34 * with: `modelSettings[<model>].effortLevel`, else `effortLevel`. The model id's
35 * trailing `[1m]` and the like are not part of the settings key.
36 */
37export const effortFromSettings = (settings: Readonly<Record<string, unknown>>, model: string): string | null => {
38 const id = model.replace(/\[.*\]$/, '')
39 const perModel = (settings.modelSettings as Record<string, { effortLevel?: unknown }> | undefined)?.[id]?.effortLevel
40 const level = perModel ?? settings.effortLevel
41 return typeof level === 'string' ? level : null
42}
43
44/** The figure today's cost is loud past. */
45export const TODAY_LOUD_USD = 100
46
47/** ccusage prices from a bundled table; a model missing from it costs $0, silently. */
48export const isTodayUnpriced = (ledger: Ledger, live: Live): boolean =>
49 ledger.today === 0 && (live.usd ?? 0) > 0
50
51/** `ccusage blocks --active --json`: the active block's cost per hour. */
52export const parseBurn = (stdout: string): number | null => {
53 const blocks = (JSON.parse(stdout) as { blocks?: { isActive?: boolean; burnRate?: { costPerHour?: number } | null }[] }).blocks
54 const active = blocks?.find(b => b.isActive)
55 return active?.burnRate?.costPerHour ?? null
56}
57
58/**
59 * `ccusage daily --json`: the latest day's total. The query starts a day back
60 * (`sinceArg`) so no time zone's today is cut off; until today's first response
61 * anywhere on this machine the latest day is still yesterday, a window this
62 * accepts rather than guessing the local date in an environment with no zone.
63 */
64export const parseToday = (stdout: string): number | null => {
65 const daily = (JSON.parse(stdout) as { daily?: { totalCost?: number }[] }).daily ?? []
66 const last = daily.at(-1)
67 return last ? (last.totalCost ?? 0) : null
68}
69
70/** `--since` for the daily query: yesterday in UTC, which no time zone's today precedes. */
71export const sinceArg = (now: number): string =>
72 new Date(now - 86_400_000).toISOString().slice(0, 10).replaceAll('-', '')
73
74// --- the 5-hour window's pace ---------------------------------------------------
75
76const HOUR = 3_600_000
77const WINDOW = 5 * HOUR
78/** The trailing stretch %/h is measured over. */
79const TRAIL = HOUR
80/** Below this much history, the window's own average stands in for the trailing rate. */
81const MIN_SPAN = 15 * 60_000
82
83/**
84 * The window's history with one more reading: a new window (a later reset time, or
85 * a fall in use) starts it over; an unchanged reading adds nothing.
86 */
87export const recordFive = (
88 samples: readonly FiveSample[],
89 percentUsed: number,
90 resetsAt: string | null,
91 previousResetsAt: string | null,
92 now: number,
93): FiveSample[] => {
94 const last = samples.at(-1)
95 const isNewWindow = resetsAt !== previousResetsAt || (last !== undefined && percentUsed < last.percentUsed)
96 if (isNewWindow || last === undefined) return [{ at: now, percentUsed }]
97 if (last.percentUsed === percentUsed) return [...samples]
98 return [...samples.filter(x => now - x.at <= TRAIL + MIN_SPAN), { at: now, percentUsed }]
99}
100
101/**
102 * How fast the 5-hour window is being used, in percent per hour: the rise over the
103 * last hour, measured to now so an idle stretch brings it down. With under 15
104 * minutes of history it is the window's average so far, which needs no history.
105 * Null when neither can be told (no reset time, or the window only just began).
106 */
107export const fiveHourRate = (
108 samples: readonly FiveSample[],
109 percentUsed: number,
110 resetsAt: string | null,
111 now: number,
112): number | null => {
113 const base = samples.find(x => now - x.at <= TRAIL)
114 if (base !== undefined && now - base.at >= MIN_SPAN) {
115 return Math.max(0, ((percentUsed - base.percentUsed) / (now - base.at)) * HOUR)
116 }
117 if (resetsAt === null) return null
118 const elapsed = now - (Date.parse(resetsAt) - WINDOW)
119 if (Number.isNaN(elapsed) || elapsed < 10 * 60_000) return null
120 return (percentUsed / elapsed) * HOUR
121}
122
123/** How long each limit's window runs before it resets. */
124export const WINDOW_MS = { fiveHour: WINDOW, week: 7 * 24 * HOUR } as const
125
126/**
127 * Where a limit's bar would end at an even pace: the share of its window already
128 * gone, in percent. Null when the reset time is unknown or past.
129 */
130export const evenPace = (resetsAt: string | null, windowMs: number, now: number): number | null => {
131 if (resetsAt === null) return null
132 const left = Date.parse(resetsAt) - now
133 if (!(left > 0)) return null
134 return Math.min(100, Math.max(0, ((windowMs - left) / windowMs) * 100))
135}
136
137/**
138 * A limit's pace: the share used over the share of its window gone, in percent. 100 runs
139 * out exactly at the reset, above it sooner. Null at the window's very start (nothing gone
140 * to divide by) and when the reset time is unknown or past.
141 */
142export const paceOf = (percentUsed: number, resetsAt: string | null, windowMs: number, now: number): number | null => {
143 const gone = evenPace(resetsAt, windowMs, now)
144 if (gone === null || gone === 0) return null
145 return Math.round((percentUsed / gone) * 100)
146}
147
148/** A pace's colour: past 80% warns, past 100% (out before the reset) is red, below that it stays quiet. */
149export const paceLevel = (pace: number): Level => (pace > 100 ? 'error' : pace > 80 ? 'warning' : 'success')
150
151// --- the week's daily share --------------------------------------------------------
152
153const DAY = 24 * HOUR
154const WEEK_DAYS = 7
155
156/** An even day's share of the week, in percent: what each day would get were nothing used yet. */
157export const DAY_SHARE = 100 / WEEK_DAYS
158
159/** What the week has left for each of its days. */
160export type DailyShare = {
161 /** Days to the reset, today counted whole: a day runs 24 hours from the reset's time of day. */
162 daysLeft: number
163 /** What is left over the days left, in percent of the week a day. */
164 perDay: number
165 /**
166 * What the colour is judged on: the same split with today's use already taken out of the
167 * days after, so a day that has used its share stays at it; on the last day, what is left.
168 */
169 basis: number
170}
171
172/**
173 * What is left of the week over the days left in it, today counted whole. Only the account's
174 * use, the reset time and now go in, so every machine shows the same and nothing is kept.
175 * Null when the reset time is unknown or past.
176 */
177export const dailyShare = (percentUsed: number, resetsAt: string | null, now: number): DailyShare | null => {
178 if (resetsAt === null) return null
179 const left = Date.parse(resetsAt) - now
180 if (!(left > 0)) return null
181 const daysLeft = Math.min(WEEK_DAYS, Math.ceil(left / DAY))
182 const rest = Math.max(0, 100 - percentUsed)
183 return { daysLeft, perDay: rest / daysLeft, basis: daysLeft > 1 ? rest / (daysLeft - 1) : rest }
184}
185
186/**
187 * A day's share or more to spare is green: the one colour that says there is plenty. Down to
188 * 80% of it the figure stays quiet, down to half it warns, below that it is red.
189 */
190export const shareLevel = (basis: number): Level | null =>
191 basis >= DAY_SHARE - 1e-9 ? 'success' : basis >= DAY_SHARE * 0.8 ? null : basis >= DAY_SHARE * 0.5 ? 'warning' : 'error'
192
193// --- the forecast and the cache -----------------------------------------------------
194
195/**
196 * When the 5-hour limit runs out at this pace, if that comes before the window
197 * resets; null when it holds until the reset, or the pace or the reset is unknown.
198 */
199export const runsOutAt = (rate: number, percentUsed: number, resetsAt: string | null, now: number): number | null => {
200 if (resetsAt === null || !(rate > 0)) return null
201 const reset = Date.parse(resetsAt)
202 if (!(reset > now)) return null
203 const at = now + ((100 - percentUsed) / rate) * HOUR
204 return at < reset ? at : null
205}
206
207/** `16:27`, the engine's local time of day. */
208export const clockOf = (at: number): string => {
209 const d = new Date(at)
210 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
211}
212
213/** The cache hit rate is taken over this many of the latest requests. */
214export const CACHE_READINGS = 10
215
216/** Percent of the prompt tokens the cache served over these requests; null before any request. */
217export const cacheHit = (readings: readonly { read: number; written: number; uncached: number }[]): number | null => {
218 const total = readings.reduce((n, r) => n + r.read + r.written + r.uncached, 0)
219 if (total === 0) return null
220 return Math.round((readings.reduce((n, r) => n + r.read, 0) / total) * 100)
221}
222
223/** A hit rate's colour: most of the prompt served from the cache is the healthy case. */
224export const cacheLevel = (percent: number): Level => (percent >= 80 ? 'success' : percent >= 50 ? 'warning' : 'error')
225
226// --- the gauges ------------------------------------------------------------------
227
228/** What each cell of a gauge holds: used, not yet used, or the even-pace mark. */
229export type GaugeCell = 'fill' | 'empty' | 'mark'
230
231/** A gauge `width` cells long filled to `used`%, with the mark (when given) over whatever cell it lands on. */
232export const gaugeCells = (width: number, used: number, mark: number | null): GaugeCell[] => {
233 const filled = Math.round((Math.min(100, Math.max(0, used)) / 100) * width)
234 const at = mark === null ? -1 : Math.min(width - 1, Math.round((mark / 100) * width))
235 return Array.from({ length: width }, (_, i): GaugeCell => (i === at ? 'mark' : i < filled ? 'fill' : 'empty'))
236}
237
238/** The cells each of `count` gauges gets once `room` holds them and the gaps between them. */
239export const gaugeWidth = (room: number, count: number, gap: number): number =>
240 Math.floor((room - gap * (count - 1)) / count)
241
242types/index.d.ts 42 lines1export type RateWindow = {
2 kind: string
3 percentUsed: number
4 resetsAt: string | null
5}
6
7/** What the engine measures for this session. */
8export type Live = {
9 model: string
10 context: { tokens: number | null; window: number; percent: number | null }
11 rateLimits: RateWindow[]
12 usd: number | null
13}
14
15/** What ccusage reads from every transcript on this machine. */
16export type Ledger = {
17 /** The active 5-hour block's cost per hour; null when no block is active. */
18 burnPerHour: number | null
19 /** Today's total across all sessions; null when ccusage reported no day. */
20 today: number | null
21}
22
23/** One main-thread request's prompt, as the API reported it: what the cache served, what it wrote, what neither. */
24export type CacheReading = { read: number; written: number; uncached: number }
25
26/** One reading of the 5-hour window: when it was taken, and how much was used. */
27export type FiveSample = { at: number; percentUsed: number }
28
29declare module 'claude-code' {
30 interface PluginState {
31 'usage-band': {
32 live: Live | null
33 effort: string | null
34 ledger: Ledger | null
35 /** The current 5-hour window's readings, oldest first: what %/h is measured from. */
36 fiveSamples: FiveSample[]
37 /** The latest main-thread requests' prompt tokens, oldest first: what the cache hit rate is taken over. */
38 cacheReadings: CacheReading[]
39 }
40 }
41}
42