Your 5-hour, weekly, context and cache-hit figures as one line above the prompt, a ? button that asks Haiku 5.5 for questions to ask next, and a /ledger pane…

Three function-hook mods for Claude Code:
? button that asks Haiku what you should ask next.Every picture below was rendered from the element trees these mods really return, recorded with the Claude Code plugin test kit. They show what you get, not a mock-up. Try the interactive demo →
Real recording (Claude Code 2.1.295 desktop app, Windows 11): click ?, Haiku writes five questions about this conversation, tick three, and insert them into the prompt box together.
<img src="docs/img/demo-real.gif" alt="Screen recording: clicking ? on the token-ledger row, five Haiku questions appear in a pane, three are ticked and inserted into the prompt box" width="100%">
Rendered from the mod's own draw output:
<img src="docs/img/demo-questions.svg" alt="Animated demo: click ?, tick two of Haiku's questions, and insert them into the prompt box" width="100%">
| Mod | What you get | Commands |
|---|---|---|
| token-ledger | A telemetry row above the prompt (5-hour %, burn rate, reset, time left, weekly %, context %, cache hit %, model, effort). A ? button for five Haiku questions. A /ledger pane. | /ask, /ledger |
| next-steps | 1-3 suggested next prompts after each real turn, in a pane of their own. | /next, /next off, /next on |
| auto-docs | After each commit, a follow-up docs: sync with <sha> commit that updates README.md, or the docs you list. | /docs, /docs <commit> |
Both panes are multi-select. Tick as many rows as you like by clicking or pressing 1-5. Use Select all (a) or Clear (c), then Insert (i). The ticked text goes into the prompt box in the order you ticked it, one blank line apart, and is never sent for you. Anything you had already typed stays.
<img src="docs/img/telemetry-row.svg" alt="token-ledger telemetry row: 34% 5h limit, +1.5/min, 04:30 PM reset, 2h 16m left, 58% weekly, 41% context, 96% cache hit, Opus 5.5, High, and the ? button" width="100%">
From left to right:
Every figure comes from the engine's own readings. A metric with no reading is left out, never faked. On a narrow window, secondary metrics drop out so the row stays on one line. It shows percentages, never money. The row appears after the session's first reply, because that's when the first limit figures arrive.
?: questions worth askingClick ? at the end of the row, or run /ask. Haiku 5.5 reads the project (folder, git branch, status and recent commits, CLAUDE.md), Claude's visible reasoning, and the chat. It writes five specific questions you haven't explored yet, which open in their own pane, so the row above the prompt never moves.
<img src="docs/img/questions-pane.svg" alt="Questions worth asking pane with two questions ticked and the Insert 2 questions button" width="560">
/ledger<img src="docs/img/ledger-pane.svg" alt="Token ledger pane: limits, this session, subagents, last 7 days, cache rewrites after idle, recent turns" width="600">
The pane shows:
Settings (/config, or pluginConfigs in settings.json):
| Setting | Default | What it does |
|---|---|---|
agentAlertPercent | 10 | Shows a toast when one subagent has used about this much of your 5-hour limit. 0 turns it off. |
animations | on | Off draws a still row. |
toastOnCacheMiss | on | Shows a toast when a turn after an idle gap rewrote the prompt cache. |
After a real turn (a long answer, or one that used 3 or more tools), Haiku suggests 1-3 prompts you could send next. They open in their own pane.
<img src="docs/img/demo-next-steps.svg" alt="Animated demo: the next-steps pane, two steps ticked and inserted into the prompt box" width="100%">
<img src="docs/img/next-steps-pane.svg" alt="Next steps pane with two of three steps ticked" width="560">
Before anything is sent to Haiku, these are replaced with stable tokens:
The scrubbing covers file and project names too.
| Setting | Default | What it does |
|---|---|---|
model | haiku | haiku or sonnet. |
minAnswerChars | 300 | Skips suggestions after shorter answers, unless the turn made 3 or more tool calls. |
When the session runs a git commit, auto-docs reads the diff and asks the model for the smallest edits that make your docs true again. It applies them as exact find-and-replace edits, so it never rewrites a whole file, and commits them separately.
<img src="docs/img/demo-auto-docs.svg" alt="Animated demo: a feat commit, the auto-docs status line, its toast, and the follow-up docs commit in git log" width="100%">
[skip docs], docs: commits, and commits that only touch documentation./docs or /docs <commit>.| Setting | Default | What it does |
|---|---|---|
docs | README.md | Comma-separated paths from the repository root. |
mode | commit | commit makes a separate docs commit. stage leaves the changes staged for you to review. |
model | sonnet | The model that reads the diff. |
maxDiffChars | 60000 | Larger diffs are cut down by whole files. |
These are function-hook plugins (a hooks/register.tsx module), so you need a Claude Code build that supports them. They were built and tested on 2.1.29x.
From the marketplace (in a Claude Code session):
/plugin marketplace add bully911210/claude-code-mods
/plugin install token-ledger@claude-code-mods
/plugin install next-steps@claude-code-mods
/plugin install auto-docs@claude-code-mods
Or from a clone. Point CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json at the folders you want (use ; between them on Windows, : elsewhere):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-mods/token-ledger:/path/to/claude-code-mods/next-steps:/path/to/claude-code-mods/auto-docs"
}
}
Plugin folders load when a session starts, so fully quit and reopen Claude after installing or changing them.
claude plugin validate token-ledger
claude plugin test token-ledger
Each mod has its own tests/. To redraw the README pictures after changing a view:
docs/capture/<mod>.capture.ts into that mod's tests/ folder.claude plugin test <mod>, and save the lines that start with CAPTURE (without that prefix) to docs/capture/<mod>.jsonl.python docs/tools/render.py.The interactive demo (docs/index.html) reads the same JSONL files.
hooks/register.tsx 569 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LedgerAgent, LedgerDay, LedgerMiss, LedgerSample, LedgerSession, LedgerTurn } from '../types'
5import { addStep, agentPoints, crossedAlert, isDone, laneAgents, liveCost, mergeListed, reconcile, setTool } from './lib/agents'
6import { burnRate, heatOf, minutesToLimit, SAMPLES_KEEP, stageOf } from './lib/furnace'
7import type { FurnaceProps, FurnaceSource } from './lib/furnace'
8import {
9 addToDay,
10 addUsage,
11 asDay,
12 cacheHitPercent,
13 compact,
14 detectMiss,
15 EMPTY_DAY,
16 EMPTY_SESSION,
17 fromApi,
18 keysOfDay,
19 lastDayKeys,
20 pct,
21 sessionDayKey,
22 sumDays,
23} from './lib/ledger'
24import { estimateUsd } from './lib/prices'
25import { askButton, insightsPane } from './insightsView'
26import { buildPrompt, fillPlan, INSIGHT_MODEL, parseInsights, SYSTEM as INSIGHT_SYSTEM } from './lib/insights'
27import type { InsightContext } from './lib/insights'
28import { telemetryTree } from './telemetry'
29import { paneTree } from './views'
30
31const PANE = 'ledger'
32const INSIGHTS_PANE = 'insights'
33const RECENT_KEEP = 30
34const MISSES_KEEP = 50
35const LANES_MAX = 4
36/** While a subagent runs, its status is refreshed this often; with none running, nothing polls. */
37const AGENT_POLL_MS = 3_000
38
39const sessionAtom = atom({ plugin: 'token-ledger', key: 'session' } as const, EMPTY_SESSION as LedgerSession)
40const recentAtom = atom({ plugin: 'token-ledger', key: 'recent' } as const, [] as LedgerTurn[])
41const missesAtom = atom({ plugin: 'token-ledger', key: 'misses' } as const, [] as LedgerMiss[])
42const todayAtom = atom({ plugin: 'token-ledger', key: 'today' } as const, EMPTY_DAY as LedgerDay)
43const weekAtom = atom({ plugin: 'token-ledger', key: 'week' } as const, EMPTY_DAY as LedgerDay)
44const lastEndAtom = atom({ plugin: 'token-ledger', key: 'lastEndAt' } as const, 0)
45const lastCostAtom = atom({ plugin: 'token-ledger', key: 'lastCostUsd' } as const, -1)
46const contextAtom = atom({ plugin: 'token-ledger', key: 'contextPercent' } as const, 0)
47const fiveHourAtom = atom({ plugin: 'token-ledger', key: 'fiveHourPercent' } as const, -1)
48const weekPercentAtom = atom({ plugin: 'token-ledger', key: 'weekPercent' } as const, -1)
49const resetsAtAtom = atom({ plugin: 'token-ledger', key: 'fiveHourResetsAt' } as const, '')
50const startPercentAtom = atom({ plugin: 'token-ledger', key: 'startPercent' } as const, -1)
51const agentsAtom = atom({ plugin: 'token-ledger', key: 'agents' } as const, [] as LedgerAgent[])
52const mainPendingAtom = atom({ plugin: 'token-ledger', key: 'mainPendingUsd' } as const, 0)
53const samplesAtom = atom({ plugin: 'token-ledger', key: 'samples' } as const, [] as LedgerSample[])
54const effortAtom = atom({ plugin: 'token-ledger', key: 'effort' } as const, '')
55const insightsAtom = atom({ plugin: 'token-ledger', key: 'insights' } as const, {
56 status: 'idle' as 'idle' | 'loading' | 'ready' | 'empty' | 'error',
57 items: [] as { title: string; question: string }[],
58 /** Indexes ticked for insertion, in the order they were ticked. */
59 picked: [] as number[],
60 /** True when the pane could not be placed and the questions fall back into the band. */
61 inBand: false,
62 error: '',
63 insertError: '',
64})
65
66type Config = {
67 readonly alertPoints: number
68 readonly isMissToastOn: boolean
69}
70
71const configOf = (options: Readonly<Record<string, unknown>>): Config => ({
72 alertPoints: typeof options.agentAlertPercent === 'number' && options.agentAlertPercent >= 0 ? options.agentAlertPercent : 10,
73 isMissToastOn: options.toastOnCacheMiss !== false,
74})
75
76type Figures = { costUsd: number; context: number; five: number; week: number; resetsAt: string }
77
78async function readEngineFigures($: EngineInterface): Promise<Figures> {
79 const usage = await $.session.usage()
80 const five = usage.rateLimits.find(r => r.kind === 'five_hour')
81 const week = usage.rateLimits.find(r => r.kind === 'seven_day')
82 return {
83 costUsd: usage.cost?.usd ?? 0,
84 context: usage.context.percent ?? 0,
85 five: five ? five.percentUsed : -1,
86 week: week ? week.percentUsed : -1,
87 resetsAt: five?.resetsAt ?? '',
88 }
89}
90
91/** Keeps the limit readings, and a sample for the burn rate. A drop in the 5-hour figure is a reset. */
92async function recordLimits($: EngineInterface, f: Figures, now: number): Promise<void> {
93 await update($, contextAtom, () => f.context)
94 await update($, weekPercentAtom, () => f.week)
95 await update($, resetsAtAtom, () => f.resetsAt)
96 if (f.five < 0) return
97 const before = await read($, fiveHourAtom)
98 await update($, fiveHourAtom, () => f.five)
99 const start = await read($, startPercentAtom)
100 if (start < 0 || f.five < start) await update($, startPercentAtom, () => f.five)
101 if (before >= 0 && f.five < before) await update($, samplesAtom, () => [])
102 await update($, samplesAtom, list => [...list, { at: now, percent: f.five }].slice(-SAMPLES_KEEP))
103}
104
105async function refreshRollups($: EngineInterface, now: number): Promise<void> {
106 const all = await $.store.keys()
107 const days = await Promise.all(
108 lastDayKeys(now, 7).map(async dk => sumDays(await Promise.all(keysOfDay(all, dk).map(async k => asDay(await $.store.get(k)))))),
109 )
110 await update($, todayAtom, () => days[0] ?? EMPTY_DAY)
111 await update($, weekAtom, () => sumDays(days))
112}
113
114type Furnace = {
115 readonly props: FurnaceProps
116 readonly five: number
117 readonly week: number
118 readonly resetsAt: string
119 readonly rate: number
120 readonly toLimit: number | null
121 readonly running: number
122 readonly agentShare: number
123 readonly agents: readonly LedgerAgent[]
124 readonly sessionTotal: number
125}
126
127/** Everything the strip, the band and the alerts read: the 5-hour limit as fire, agents in blue. */
128async function furnaceNow($: EngineInterface, cfg: Config, now: number): Promise<Furnace> {
129 const five = await read($, fiveHourAtom)
130 const resetsAt = await read($, resetsAtAtom)
131 const agents = await read($, agentsAtom)
132 const rate = burnRate(await read($, samplesAtom), now)
133 const heat = heatOf(rate)
134 const minutesToReset = resetsAt ? Math.max(0, (Date.parse(resetsAt) - now) / 60_000) : null
135 const sessionTotal =
136 (await read($, sessionAtom)).costUsd + (await read($, mainPendingAtom)) + agents.reduce((s, a) => s + a.pendingUsd, 0)
137 const total = Math.max(0.000001, sessionTotal)
138 const agentShare = Math.min(1, agents.reduce((s, a) => s + liveCost(a), 0) / total)
139 const lanes = laneAgents(agents, now, LANES_MAX)
140 const sources: FurnaceSource[] = [
141 { key: 'main', kind: 'main', share: 1 - agentShare, heat },
142 ...lanes.map(a => ({ key: a.id, kind: 'agent' as const, share: liveCost(a) / total, heat: isDone(a.status) ? 0 : heat })),
143 ]
144 const fill = five < 0 ? 0 : five / 100
145 return {
146 props: { fill, stage: stageOf(fill), sources, animate: false },
147 five,
148 week: await read($, weekPercentAtom),
149 resetsAt,
150 rate,
151 toLimit: five < 0 ? null : minutesToLimit(five, rate, minutesToReset),
152 running: agents.filter(a => !isDone(a.status)).length,
153 agentShare,
154 agents,
155 sessionTotal: total,
156 }
157}
158
159// The last status text sent, so an unchanged line is never re-sent.
160let lastStatus = ''
161
162// The agent-list poll, alive only while some subagent is running.
163let agentPoll: { cancel: () => void } | undefined
164
165/** Starts polling the agent list if it isn't already; the poll stops itself once no agent runs. */
166function ensureAgentPolling($: EngineInterface, cfg: Config): void {
167 if (agentPoll) return
168 agentPoll = $.clock.every(AGENT_POLL_MS, () => {
169 void (async () => {
170 await syncAgents($, cfg)
171 const agents = await read($, agentsAtom)
172 if (!agents.some(a => !isDone(a.status))) {
173 agentPoll?.cancel()
174 agentPoll = undefined
175 }
176 })().catch(() => undefined)
177 })
178}
179
180async function showStatus($: EngineInterface, cfg: Config): Promise<void> {
181 // The row above the prompt carries every figure, so the status bar shows nothing.
182 if (lastStatus === 'cleared') return
183 lastStatus = 'cleared'
184 $.ui.status(undefined)
185}
186
187/** Runs git in the project and returns its output, or '' when git or the repo isn't there. */
188async function gitOut($: EngineInterface, root: string, args: readonly string[]): Promise<string> {
189 const r = await $.process.run(['git', ...args], { cwd: root, timeoutMs: 10_000 }).catch(() => null)
190 return r && r.exitCode === 0 ? r.stdout.trim() : ''
191}
192
193/** Everything Haiku may read: the project, Claude's visible reasoning, and the chat so far. */
194async function gatherInsightContext($: EngineInterface): Promise<InsightContext> {
195 const root = await $.session.root()
196 const name = root.replace(/\\/g, '/').split('/').filter(Boolean).at(-1) ?? root
197 const [branch, gitStatus, recentCommits] = await Promise.all([
198 gitOut($, root, ['rev-parse', '--abbrev-ref', 'HEAD']),
199 gitOut($, root, ['status', '--short']),
200 gitOut($, root, ['log', '--oneline', '-8']),
201 ])
202 const ancestors = await $.fs.ancestors({ names: ['CLAUDE.md'] }).catch(() => [])
203 const rows = await $.session.messages()
204 const api = await $.session.messages({ as: 'api' })
205 const messages = 'deny' in rows ? [] : rows.map(m => ({ role: m.role, text: m.text, tools: m.toolUses.map(t => t.tool) }))
206 const thinking =
207 'deny' in api
208 ? []
209 : api.flatMap(m => m.content.filter(b => b.type === 'thinking' && typeof b.thinking === 'string').map(b => String(b.thinking)))
210 return {
211 project: { name, branch, gitStatus, recentCommits, docs: ancestors.slice(-2).map(a => ({ path: `${a.dir}/${a.name}`, text: a.content })) },
212 messages,
213 thinking,
214 }
215}
216
217// Claimed synchronously before any await, so two quick clicks can't both start a request.
218let isInsightRunning = false
219// Each request and each close takes a new number; a reply only lands if its number is still current.
220let insightTicket = 0
221
222/** One Haiku request for five questions; a second press while it runs does nothing. */
223async function runInsights($: EngineInterface): Promise<void> {
224 if (isInsightRunning) return
225 isInsightRunning = true
226 try {
227 await generateInsights($)
228 } catch {
229 // The state write itself failed; nothing more to show.
230 } finally {
231 isInsightRunning = false
232 }
233}
234
235async function generateInsights($: EngineInterface): Promise<void> {
236 insightTicket += 1
237 const ticket = insightTicket
238 const land = (next: (s: Awaited<ReturnType<typeof readInsights>>) => Awaited<ReturnType<typeof readInsights>>) =>
239 ticket === insightTicket ? update($, insightsAtom, next) : Promise.resolve()
240 // The questions live in their own pane, so the telemetry bar above the prompt
241 // never moves or scrolls with them. A terminal too narrow for a pane is the one
242 // case they fall back into the band.
243 const opened = await $.ui.open({ id: INSIGHTS_PANE, title: 'Questions worth asking' }).catch(() => ({ isPlaced: false }))
244 await update($, insightsAtom, () => ({
245 status: 'loading' as const,
246 items: [],
247 picked: [],
248 inBand: !opened.isPlaced,
249 error: '',
250 insertError: '',
251 }))
252 try {
253 const context = await gatherInsightContext($)
254 const reply = await $.model.complete({
255 model: INSIGHT_MODEL,
256 system: INSIGHT_SYSTEM,
257 prompt: buildPrompt(context),
258 maxTokens: 2_000,
259 effort: 'medium',
260 timeoutMs: 90_000,
261 })
262 if (!reply.isAnswered) {
263 const why = reply.reason === 'api-error' ? 'the API returned an error' : reply.reason.replace('-', ' ')
264 await land(s => ({ ...s, status: 'error' as const, error: why }))
265 return
266 }
267 const items = parseInsights(reply.text)
268 await land(s => ({ ...s, status: items.length ? ('ready' as const) : ('empty' as const), items, picked: [] }))
269 } catch (err) {
270 const why = err instanceof Error ? err.message : String(err)
271 await land(s => ({ ...s, status: 'error' as const, error: why }))
272 }
273}
274
275const readInsights = ($: EngineInterface) => read($, insightsAtom)
276
277/** Folds the questions away; a reply still on its way is dropped. The bar is untouched: it never held them. */
278async function closeInsights($: EngineInterface): Promise<void> {
279 insightTicket += 1
280 await update($, insightsAtom, () => ({ status: 'idle' as const, items: [], picked: [], inBand: false, error: '', insertError: '' }))
281 await $.ui.close({ id: INSIGHTS_PANE }).catch(() => undefined)
282}
283
284/** Ticks a question on or off. Any number may be ticked before they go in together. */
285async function toggleInsight($: EngineInterface, index: number): Promise<void> {
286 await update($, insightsAtom, s => ({
287 ...s,
288 insertError: '',
289 picked: s.picked.includes(index) ? s.picked.filter(i => i !== index) : [...s.picked, index],
290 }))
291}
292
293/** Ticks every question (or unticks all when every one is ticked), keeping the order of any already ticked first. */
294async function pickAllInsights($: EngineInterface, isOn: boolean): Promise<void> {
295 await update($, insightsAtom, s => ({
296 ...s,
297 insertError: '',
298 picked: isOn ? [...s.picked, ...s.items.map((_, i) => i).filter(i => !s.picked.includes(i))] : [],
299 }))
300}
301
302/** The pane's handlers, shared by the pane and the narrow-terminal band fallback. */
303const insightHandlers = ($: EngineInterface) => ({
304 toggle: (i: number) => {
305 void toggleInsight($, i)
306 },
307 all: (isOn: boolean) => {
308 void pickAllInsights($, isOn)
309 },
310 insert: () => {
311 void insertInsights($)
312 },
313 retry: () => {
314 void runInsights($)
315 },
316 close: () => {
317 void closeInsights($)
318 },
319})
320
321/** Puts every ticked question in the prompt box without sending it, keeping any text already there. */
322async function insertInsights($: EngineInterface): Promise<void> {
323 const state = await read($, insightsAtom)
324 const questions = state.picked.map(i => state.items[i]?.question ?? '').filter(q => q !== '')
325 if (questions.length === 0) return
326 let filled: Awaited<ReturnType<typeof $.prompt.fill>>
327 try {
328 const box = await $.prompt.read()
329 const plan = fillPlan(box.text, questions)
330 filled = await $.prompt.fill({ text: plan.text, mode: plan.mode })
331 } catch (err) {
332 const why = err instanceof Error ? err.message : String(err)
333 await update($, insightsAtom, s => ({ ...s, insertError: `Not inserted: ${why}.` }))
334 return
335 }
336 if (!filled.isFilled) {
337 const why = filled.refusal === 'dialog' ? 'a dialog is holding the prompt; close it and pick again' : 'the prompt box could not take the text'
338 await update($, insightsAtom, s => ({ ...s, insertError: `Not inserted: ${why}.` }))
339 return
340 }
341 await closeInsights($)
342}
343
344/** Folds in the engine's agent list, and toasts once for any agent past the alert line. */
345async function syncAgents($: EngineInterface, cfg: Config): Promise<void> {
346 const now = await $.clock.now()
347 const listed = await $.agent.list()
348 await update($, agentsAtom, agents => mergeListed(agents, listed, now))
349 const f = await furnaceNow($, cfg, now)
350 const pointsUsed = Math.max(0, f.five - (await read($, startPercentAtom)))
351 const crossed = crossedAlert(f.agents, f.sessionTotal, pointsUsed, cfg.alertPoints)
352 if (crossed.length === 0) return
353 const ids = new Set(crossed.map(a => a.id))
354 await update($, agentsAtom, agents => agents.map(a => (ids.has(a.id) ? { ...a, isAlerted: true } : a)))
355 for (const a of crossed) {
356 const points = Math.round(agentPoints(a, f.sessionTotal, pointsUsed))
357 $.ui.toast(`Subagent "${a.description || a.type}" has used about ${points}% of your 5-hour limit.`, { timeoutMs: 8000 })
358 }
359}
360
361export const register: Register = (on, options) => {
362 const cfg = configOf(options)
363
364 on('session.start', async ($, e, next) => {
365 // Each step stands alone: one failing (a command name taken, a store hiccup) never stops the rest or the session.
366 const step = (work: () => Promise<unknown>) => work().catch(() => undefined)
367 await step(() => $.command.register({ name: 'ledger', description: 'Token ledger: limits, subagents, cache hits and cache rewrites' }))
368 await step(() => $.command.register({ name: 'ask', description: 'Ask Haiku for five questions worth asking this conversation next' }))
369 // A request cut off by a reload or restart must not leave the "?" stuck on "…".
370 await step(async () => {
371 if ((await read($, insightsAtom)).status === 'loading') await closeInsights($)
372 })
373 await step(async () => {
374 const figures = await readEngineFigures($)
375 // A resumed session already used something; count turns from here on.
376 if ((await read($, lastCostAtom)) < 0) await update($, lastCostAtom, () => figures.costUsd)
377 // A resumed session's first turn is the classic cache rewrite: keep the gap.
378 if ((await read($, lastEndAtom)) === 0) {
379 const stored = await $.store.get(`lastEnd:${await $.session.id()}`)
380 if (typeof stored === 'number') await update($, lastEndAtom, () => stored)
381 }
382 await recordLimits($, figures, await $.clock.now())
383 })
384 await step(async () => refreshRollups($, await $.clock.now()))
385 await step(() => showStatus($, cfg))
386 // No timer here: the agent list is polled only while a subagent runs.
387 return next(e)
388 })
389
390 // /clear starts a new conversation without a new session.start: forget the old one's figures and questions.
391 on('session.end', async ($, e, next) => {
392 if (e.reason === 'clear') {
393 await Promise.all([
394 closeInsights($),
395 update($, sessionAtom, () => EMPTY_SESSION as LedgerSession),
396 update($, recentAtom, () => [] as LedgerTurn[]),
397 update($, missesAtom, () => [] as LedgerMiss[]),
398 update($, agentsAtom, () => [] as LedgerAgent[]),
399 update($, mainPendingAtom, () => 0),
400 update($, lastCostAtom, () => -1),
401 update($, lastEndAtom, () => 0),
402 ]).catch(() => undefined)
403 }
404 return next(e)
405 })
406
407 // Every model call, main loop and subagents alike, as its response completes.
408 on('turn.step', async function* ($, e, next) {
409 const result = yield* next(e)
410 if (result.usage !== null) {
411 const usage = fromApi(result.usage)
412 const estUsd = estimateUsd(usage, result.usage.model)
413 const now = await $.clock.now()
414 if (e.agentId === undefined) {
415 await update($, mainPendingAtom, p => p + estUsd)
416 const effort = e.effort === undefined ? '' : String(e.effort)
417 await update($, effortAtom, () => effort)
418 } else {
419 const id = e.agentId
420 await update($, agentsAtom, agents => addStep(agents, { id, model: result.usage?.model ?? e.model, usage, estUsd, at: now }))
421 ensureAgentPolling($, cfg)
422 }
423 await recordLimits($, await readEngineFigures($), now)
424 }
425 return result
426 })
427
428 on('tool.call', async ($, e, next) => {
429 if (e.agentId !== undefined) {
430 const id = e.agentId
431 const now = await $.clock.now()
432 await update($, agentsAtom, agents => setTool(agents, id, String(e.tool), now))
433 }
434 const ran = await next(e)
435 if (String(e.tool) === 'Agent') await syncAgents($, cfg).catch(() => undefined)
436 return ran
437 }).catch(($, e, next) => next(e))
438
439 on('turn.complete', async ($, e, next) => {
440 const result = await next(e)
441 if (e.usage === undefined) return result
442 const usage = fromApi(e.usage)
443 const now = await $.clock.now()
444
445 // A subagent's tokens count toward the session; its share is settled
446 // against the engine's session cost when the main loop's turn ends.
447 if (e.agentId !== undefined) {
448 await update($, sessionAtom, s => ({ ...s, usage: addUsage(s.usage, usage) }))
449 await syncAgents($, cfg).catch(() => undefined)
450 return result
451 }
452
453 await syncAgents($, cfg).catch(() => undefined)
454 const figures = await readEngineFigures($)
455 const lastCost = await read($, lastCostAtom)
456 const realUsd = Math.max(0, figures.costUsd - (lastCost < 0 ? figures.costUsd : lastCost))
457 const settled = reconcile(await read($, agentsAtom), await read($, mainPendingAtom), realUsd)
458 const lastEndAt = await read($, lastEndAtom)
459 const gapMs = lastEndAt > 0 ? now - e.durationMs - lastEndAt : 0
460 const miss = detectMiss({ gapMs, usage, costUsd: settled.mainUsd, at: now })
461 const sessionId = await $.session.id()
462
463 await update($, agentsAtom, () => settled.agents)
464 await update($, mainPendingAtom, () => 0)
465 await update($, lastCostAtom, () => figures.costUsd)
466 await update($, lastEndAtom, () => now)
467 await update($, sessionAtom, s => ({ costUsd: s.costUsd + realUsd, turns: s.turns + 1, usage: addUsage(s.usage, usage) }))
468 await update($, recentAtom, list => [...list, { at: now, costUsd: realUsd, usage, durationMs: e.durationMs }].slice(-RECENT_KEEP))
469 if (miss) await update($, missesAtom, list => [...list, miss].slice(-MISSES_KEEP))
470 await recordLimits($, figures, now)
471
472 const key = sessionDayKey(now, sessionId)
473 await $.store.set(key, addToDay(asDay(await $.store.get(key)), { costUsd: realUsd, usage, miss, agentUsd: settled.agentUsd }))
474 await $.store.set(`lastEnd:${sessionId}`, now)
475 await refreshRollups($, now)
476 await showStatus($, cfg)
477
478 if (miss && cfg.isMissToastOn) {
479 $.ui.toast(`Cache expired after ${miss.gapMinutes} min idle: ${compact(miss.tokensRewritten)} tokens rewritten.`, { timeoutMs: 8000 })
480 }
481 return result
482 })
483
484 on('command.run', { command: 'ledger' }, async ($, e) => {
485 const opened = await $.ui.open({ id: PANE, title: 'Token ledger' })
486 const five = await read($, fiveHourAtom)
487 const summary = five < 0 ? 'No limit reading yet.' : `5-hour limit ${pct(five)}% used.`
488 return { text: opened.isPlaced ? summary : `${summary} Widen the terminal to see the ledger pane.` }
489 })
490
491 // The telemetry row above the prompt and the "?" launcher at its right.
492 // Nothing else is ever drawn here: the bar is static and never scrolls with
493 // the questions, which live in their own pane.
494 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
495 const now = await $.clock.now()
496 if (e.props.hasSurvey) return next(e)
497 const f = await furnaceNow($, cfg, now)
498 const session = await read($, sessionAtom)
499 const hasContext = session.turns > 0 || f.five >= 0
500 const els = $.ui.resolve(e)
501 const insights = await read($, insightsAtom)
502 const row = telemetryTree(
503 els,
504 {
505 fivePercent: f.five,
506 ratePerMin: f.rate,
507 resetsAt: f.resetsAt,
508 now,
509 weekPercent: f.week,
510 contextPercent: hasContext ? await read($, contextAtom) : null,
511 cacheHit: cacheHitPercent(session.usage),
512 model: await $.session.model(),
513 effort: await read($, effortAtom),
514 },
515 Math.max(20, (e.props.bodyColumns ?? 80) - 6),
516 )
517 const ask = askButton(els, insights.status, () => {
518 void runInsights($)
519 })
520 const { Box } = els
521 const bar = (
522 <Box key="bar" flexDirection="row" alignItems="center" justifyContent="space-between">
523 {row}
524 {ask}
525 </Box>
526 )
527 const questions =
528 insights.inBand && insights.status !== 'idle'
529 ? insightsPane(els, insights, e.props.bodyColumns ?? 80, insightHandlers($))
530 : undefined
531 if (questions === undefined) return bar
532 return (
533 <Box flexDirection="column">
534 {questions}
535 {bar}
536 </Box>
537 )
538 })
539
540 // The questions, in a pane of their own: they scroll there, the bar does not.
541 on('ui.render', { component: 'Pane', requestId: INSIGHTS_PANE }, async ($, e) => {
542 const insights = await read($, insightsAtom)
543 const width = e.props.bodyColumns ?? e.viewport?.columns ?? 80
544 return insightsPane($.ui.resolve(e), insights, width, insightHandlers($))
545 })
546
547 on('command.run', { command: 'ask' }, async $ => {
548 void runInsights($)
549 return { text: 'Asking Haiku for five questions to ask next.' }
550 })
551
552 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
553 const resetsAt = await read($, resetsAtAtom)
554 return paneTree($.ui.resolve(e), {
555 session: await read($, sessionAtom),
556 week: await read($, weekAtom),
557 misses: await read($, missesAtom),
558 recent: await read($, recentAtom),
559 agents: await read($, agentsAtom),
560 ctx: await read($, contextAtom),
561 five: await read($, fiveHourAtom),
562 weekPercent: await read($, weekPercentAtom),
563 resetsLabel: resetsAt ? new Date(resetsAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '',
564 rows: e.viewport?.rows ?? 30,
565 now: await $.clock.now(),
566 })
567 })
568}
569hooks/lib/agents.ts 112 lines1// Pure subagent bookkeeping: every function returns new values.
2
3import type { LedgerAgent, LedgerUsage } from '../../types'
4import { addUsage, EMPTY_USAGE } from './ledger'
5
6export const AGENTS_KEEP = 40
7/** How long a finished agent stays in the lanes. */
8export const LANE_LINGER_MS = 10 * 60_000
9
10const DONE = new Set(['completed', 'failed', 'killed'])
11export const isDone = (status: string): boolean => DONE.has(status)
12
13export const newAgent = (id: string, at: number): LedgerAgent => ({
14 id,
15 type: 'agent',
16 description: '',
17 model: '',
18 status: 'running',
19 calls: 0,
20 usage: EMPTY_USAGE,
21 pendingUsd: 0,
22 costUsd: 0,
23 tool: '',
24 startedAt: at,
25 isAlerted: false,
26})
27
28const upsert = (agents: readonly LedgerAgent[], id: string, at: number, fn: (a: LedgerAgent) => LedgerAgent): LedgerAgent[] => {
29 const found = agents.find(a => a.id === id)
30 const next = fn(found ?? newAgent(id, at))
31 const rest = found ? agents.map(a => (a.id === id ? next : a)) : [...agents, next]
32 return rest.slice(-AGENTS_KEEP)
33}
34
35/** One model call made inside an agent's loop. */
36export const addStep = (
37 agents: readonly LedgerAgent[],
38 step: { readonly id: string; readonly model: string; readonly usage: LedgerUsage; readonly estUsd: number; readonly at: number },
39): LedgerAgent[] =>
40 upsert(agents, step.id, step.at, a => ({
41 ...a,
42 model: step.model,
43 calls: a.calls + 1,
44 usage: addUsage(a.usage, step.usage),
45 pendingUsd: a.pendingUsd + step.estUsd,
46 status: isDone(a.status) ? a.status : 'running',
47 }))
48
49export const setTool = (agents: readonly LedgerAgent[], id: string, tool: string, at: number): LedgerAgent[] =>
50 upsert(agents, id, at, a => ({ ...a, tool }))
51
52type Listed = { readonly id: string; readonly type: string; readonly description: string; readonly status: string; readonly parentId?: string }
53
54/** Folds in the engine's agent list: names, types, statuses, and when each one ended. */
55export const mergeListed = (agents: readonly LedgerAgent[], listed: readonly Listed[], at: number): LedgerAgent[] =>
56 listed.reduce<LedgerAgent[]>(
57 (acc, l) =>
58 upsert(acc, l.id, at, a => ({
59 ...a,
60 type: l.type,
61 description: l.description,
62 status: l.status,
63 ...(l.parentId ? { parentId: l.parentId } : {}),
64 ...(isDone(l.status) && a.endedAt === undefined ? { endedAt: at, tool: '' } : {}),
65 })),
66 [...agents],
67 )
68
69/**
70 * Splits the engine's real cost for the last stretch across the main loop and
71 * each agent, in proportion to their price-table estimates. With no estimate
72 * at all, the main loop takes the whole of it.
73 */
74export const reconcile = (
75 agents: readonly LedgerAgent[],
76 mainPendingUsd: number,
77 realUsd: number,
78): { readonly agents: LedgerAgent[]; readonly mainUsd: number; readonly agentUsd: number } => {
79 const estimated = mainPendingUsd + agents.reduce((s, a) => s + a.pendingUsd, 0)
80 if (estimated <= 0) return { agents: [...agents], mainUsd: realUsd, agentUsd: 0 }
81 const factor = realUsd / estimated
82 const scaled = agents.map(a => ({ ...a, costUsd: a.costUsd + a.pendingUsd * factor, pendingUsd: 0 }))
83 const agentUsd = agents.reduce((s, a) => s + a.pendingUsd * factor, 0)
84 return { agents: scaled, mainUsd: realUsd - agentUsd, agentUsd }
85}
86
87/** An agent's cost as shown now: settled cost plus its unsettled estimate. */
88export const liveCost = (a: LedgerAgent): number => a.costUsd + a.pendingUsd
89
90/** Agents for the lanes: running ones, then recently finished, most expensive first. */
91export const laneAgents = (agents: readonly LedgerAgent[], now: number, max: number): LedgerAgent[] =>
92 agents
93 .filter(a => !isDone(a.status) || now - (a.endedAt ?? now) <= LANE_LINGER_MS)
94 .sort((x, y) => Number(isDone(x.status)) - Number(isDone(y.status)) || liveCost(y) - liveCost(x))
95 .slice(0, max)
96
97/**
98 * Points of the 5-hour limit an agent has used, roughly: its share of the
99 * session's usage times the points the session has used since it started.
100 */
101export const agentPoints = (a: LedgerAgent, sessionTotal: number, pointsUsed: number): number =>
102 sessionTotal > 0 ? (liveCost(a) / sessionTotal) * pointsUsed : 0
103
104/** Agents that just crossed the alert line, in points of the 5-hour limit, and have not been alerted. */
105export const crossedAlert = (
106 agents: readonly LedgerAgent[],
107 sessionTotal: number,
108 pointsUsed: number,
109 alertPoints: number,
110): LedgerAgent[] =>
111 alertPoints > 0 ? agents.filter(a => !a.isAlerted && agentPoints(a, sessionTotal, pointsUsed) >= alertPoints) : []
112hooks/lib/furnace.ts 122 lines1// Pure maths for the furnace: burn rate, how the bar is split between the main
2// loop and each agent, and each frame's flame heights. The Client module only
3// draws what these return, so tests cover the behaviour without a surface.
4
5import type { LedgerSample } from '../../types'
6
7export const BURN_WINDOW_MS = 5 * 60_000
8export const SAMPLES_KEEP = 120
9/** Burn rate at which the flames reach full height: one point of the 5-hour limit a minute. */
10export const FULL_HEAT_POINTS_PER_MIN = 1
11
12export type Stage = 'calm' | 'warm' | 'hot' | 'over'
13
14export type FurnaceSource = {
15 readonly key: string
16 readonly kind: 'main' | 'agent'
17 /** This source's share of today's spend, 0-1. */
18 readonly share: number
19 /** How hard it burns now, 0-1. */
20 readonly heat: number
21}
22
23export type FurnaceProps = {
24 readonly fill: number
25 readonly stage: Stage
26 readonly sources: readonly FurnaceSource[]
27 readonly animate: boolean
28}
29
30/** Points of the 5-hour limit a minute over the window: newest reading less the oldest inside it. */
31export const burnRate = (samples: readonly LedgerSample[], now: number, windowMs = BURN_WINDOW_MS): number => {
32 const inside = samples.filter(s => now - s.at <= windowMs)
33 const first = inside[0]
34 const last = inside.at(-1)
35 if (!first || !last || last === first) return 0
36 const minutes = Math.max(1, (now - first.at) / 60_000)
37 return Math.max(0, (last.percent - first.percent) / minutes)
38}
39
40export const heatOf = (pointsPerMin: number): number => Math.max(0, Math.min(1, pointsPerMin / FULL_HEAT_POINTS_PER_MIN))
41
42/** Minutes until the limit at this rate, or null when it resets first or nothing burns. */
43export const minutesToLimit = (percent: number, pointsPerMin: number, minutesToReset: number | null): number | null => {
44 if (pointsPerMin <= 0 || percent >= 100) return percent >= 100 ? 0 : null
45 const minutes = (100 - percent) / pointsPerMin
46 return minutesToReset !== null && minutes >= minutesToReset ? null : Math.round(minutes)
47}
48
49/** The ember strip as status-bar text: one eighth-block glyph per column, dots where unlit. */
50const GLYPHS = ['·', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
51export const emberText = (heights: readonly number[]): string =>
52 heights.map(h => GLYPHS[Math.max(0, Math.min(8, Math.round(h)))] ?? '·').join('')
53
54export const stageOf = (fill: number): Stage => (fill > 1 ? 'over' : fill >= 0.9 ? 'hot' : fill >= 0.6 ? 'warm' : 'calm')
55
56/** How many of `columns` each source gets, in order; the rest of the bar stays unlit. */
57export const splitColumns = (columns: number, fill: number, sources: readonly FurnaceSource[]): number[] => {
58 const lit = Math.round(columns * Math.min(1, Math.max(0, fill)))
59 const total = sources.reduce((s, x) => s + x.share, 0)
60 if (lit === 0 || total <= 0) return sources.map(() => 0)
61 const raw = sources.map(s => (s.share / total) * lit)
62 const floors = raw.map(Math.floor)
63 const left = lit - floors.reduce((a, b) => a + b, 0)
64 const order = raw.map((r, i) => [r - Math.floor(r), i] as const).sort((a, b) => b[0] - a[0])
65 return floors.map((f, i) => f + (order.slice(0, left).some(([, j]) => j === i) ? 1 : 0))
66}
67
68/** Heights are measured in eighths of a row, so flame tips can be drawn smoothly. */
69export const SUB = 8
70
71/**
72 * The flame silhouette across the bar: two slow waves, stable from frame to
73 * frame, so the bar reads as tongues of fire rather than random bars.
74 */
75const shapeAt = (column: number): number =>
76 Math.min(1, Math.max(0.3, 0.62 + 0.24 * Math.sin(column * 0.55) + 0.14 * Math.sin(column * 1.7 + 1)))
77
78/** Each lit column's target height in eighths of a row; 0 for unlit columns. Idle still shows low flames. */
79export const targetHeights = (columns: number, rows: number, props: FurnaceProps): number[] => {
80 const spans = splitColumns(columns, props.fill, props.sources)
81 const heights: number[] = []
82 props.sources.forEach((source, i) => {
83 const span = spans[i] ?? 0
84 for (let c = 0; c < span; c += 1) {
85 const base = 0.45 + source.heat * 0.55
86 heights.push(Math.max(3, Math.round(rows * SUB * base * shapeAt(heights.length))))
87 }
88 })
89 return [...heights, ...Array.from({ length: Math.max(0, columns - heights.length) }, () => 0)]
90}
91
92/** Frames between drift beats: at 200 ms a frame, one beat a second. */
93export const DRIFT_EVERY = 5
94/** On a beat, one column in this many lifts slightly. */
95export const DRIFT_SPREAD = 12
96/** How far a column moves per frame, and how far a drift lifts it, in eighths. */
97const EASE = 2
98const LIFT = 3
99
100/**
101 * The next frame, gently: each column eases two eighths toward its target.
102 * While burning, once a second one column in twelve lifts three eighths.
103 * Low flicker by design.
104 */
105export const stepHeights = (current: readonly number[], target: readonly number[], tick: number, isBurning: boolean): number[] => {
106 const isBeat = tick % DRIFT_EVERY === 0
107 const beat = Math.floor(tick / DRIFT_EVERY)
108 return target.map((t, i) => {
109 const drifts = isBurning && isBeat && t > SUB && (i * 5 + beat) % DRIFT_SPREAD === 0
110 const goal = drifts ? t + LIFT : t
111 const now = current[i] ?? 0
112 return now < goal ? Math.min(goal, now + EASE) : now > goal ? Math.max(goal, now - EASE) : now
113 })
114}
115
116/** Which source owns each column, for colour. */
117export const ownerOf = (columns: number, props: FurnaceProps): ('main' | 'agent' | 'none')[] => {
118 const spans = splitColumns(columns, props.fill, props.sources)
119 const owners = props.sources.flatMap((s, i) => Array.from({ length: spans[i] ?? 0 }, () => s.kind))
120 return [...owners, ...Array.from({ length: Math.max(0, columns - owners.length) }, () => 'none' as const)]
121}
122hooks/lib/ledger.ts 180 lines1// Pure ledger arithmetic: no engine calls, every function returns new values.
2
3import type { LedgerDay, LedgerMiss, LedgerSession, LedgerUsage } from '../../types'
4
5export const FIVE_MIN = 5 * 60_000
6export const ONE_HOUR = 60 * 60_000
7/** Below this many rewritten tokens a cache miss is noise, not a cost. */
8export const MISS_MIN_TOKENS = 10_000
9
10export const EMPTY_USAGE: LedgerUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
11export const EMPTY_DAY: LedgerDay = { costUsd: 0, turns: 0, usage: EMPTY_USAGE, misses: 0, missCostUsd: 0, agentUsd: 0 }
12export const EMPTY_SESSION: LedgerSession = { costUsd: 0, turns: 0, usage: EMPTY_USAGE }
13
14type ApiUsage = {
15 readonly input_tokens: number
16 readonly output_tokens: number
17 readonly cache_read_input_tokens: number
18 readonly cache_creation_input_tokens: number
19}
20
21export const fromApi = (u: ApiUsage): LedgerUsage => ({
22 input: u.input_tokens,
23 output: u.output_tokens,
24 cacheRead: u.cache_read_input_tokens,
25 cacheWrite: u.cache_creation_input_tokens,
26})
27
28export const addUsage = (a: LedgerUsage, b: LedgerUsage): LedgerUsage => ({
29 input: a.input + b.input,
30 output: a.output + b.output,
31 cacheRead: a.cacheRead + b.cacheRead,
32 cacheWrite: a.cacheWrite + b.cacheWrite,
33})
34
35/** Share of input tokens the cache served, 0-100; null with no input yet. */
36export const cacheHitPercent = (u: LedgerUsage): number | null => {
37 const totalIn = u.input + u.cacheRead + u.cacheWrite
38 return totalIn === 0 ? null : Math.round((u.cacheRead / totalIn) * 100)
39}
40
41/**
42 * A turn after an idle gap that wrote far more cache than it read: the
43 * prefix expired and was billed again at the cache-write price.
44 */
45export const detectMiss = (args: {
46 readonly gapMs: number
47 readonly usage: LedgerUsage
48 readonly costUsd: number
49 readonly at: number
50}): LedgerMiss | null => {
51 const { gapMs, usage, costUsd, at } = args
52 const isIdle = gapMs >= FIVE_MIN
53 const isRewrite = usage.cacheWrite >= MISS_MIN_TOKENS && usage.cacheWrite > usage.cacheRead * 2
54 if (!isIdle || !isRewrite) return null
55 return {
56 at,
57 gapMinutes: Math.round(gapMs / 60_000),
58 ttl: gapMs >= ONE_HOUR ? '1h' : '5m',
59 tokensRewritten: usage.cacheWrite,
60 costUsd,
61 }
62}
63
64export const addToDay = (
65 day: LedgerDay,
66 turn: { readonly costUsd: number; readonly usage: LedgerUsage; readonly miss: LedgerMiss | null; readonly agentUsd?: number },
67): LedgerDay => ({
68 costUsd: day.costUsd + turn.costUsd,
69 turns: day.turns + 1,
70 usage: addUsage(day.usage, turn.usage),
71 misses: day.misses + (turn.miss ? 1 : 0),
72 missCostUsd: day.missCostUsd + (turn.miss?.costUsd ?? 0),
73 agentUsd: day.agentUsd + (turn.agentUsd ?? 0),
74})
75
76export const sumDays = (days: readonly LedgerDay[]): LedgerDay =>
77 days.reduce<LedgerDay>(
78 (acc, d) => ({
79 costUsd: acc.costUsd + d.costUsd,
80 turns: acc.turns + d.turns,
81 usage: addUsage(acc.usage, d.usage),
82 misses: acc.misses + d.misses,
83 missCostUsd: acc.missCostUsd + d.missCostUsd,
84 agentUsd: acc.agentUsd + d.agentUsd,
85 }),
86 EMPTY_DAY,
87 )
88
89const pad = (n: number): string => String(n).padStart(2, '0')
90
91/** The machine's local date, `YYYY-MM-DD`. */
92export const dayKey = (ms: number): string => {
93 const d = new Date(ms)
94 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
95}
96
97/** Store keys of the last `n` days, today first. */
98export const lastDayKeys = (ms: number, n: number): string[] =>
99 Array.from({ length: n }, (_, i) => `day:${dayKey(ms - i * 24 * ONE_HOUR)}`)
100
101/**
102 * This session's own key for a day. Each session writes only its own, so
103 * parallel sessions never overwrite each other; readers sum every key of the day.
104 */
105export const sessionDayKey = (ms: number, sessionId: string): string => `day:${dayKey(ms)}:${sessionId}`
106
107/** Every stored key belonging to the day `dayStoreKey` names (`day:YYYY-MM-DD`). */
108export const keysOfDay = (allKeys: readonly string[], dayStoreKey: string): string[] =>
109 allKeys.filter(k => k === dayStoreKey || k.startsWith(`${dayStoreKey}:`))
110
111/** A stored value read back as a day, whatever shape an older version left. */
112export const asDay = (value: unknown): LedgerDay => {
113 if (typeof value !== 'object' || value === null) return EMPTY_DAY
114 const v = value as Partial<LedgerDay>
115 const u = (v.usage ?? {}) as Partial<LedgerUsage>
116 const num = (x: unknown): number => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
117 return {
118 costUsd: num(v.costUsd),
119 turns: num(v.turns),
120 usage: { input: num(u.input), output: num(u.output), cacheRead: num(u.cacheRead), cacheWrite: num(u.cacheWrite) },
121 misses: num(v.misses),
122 missCostUsd: num(v.missCostUsd),
123 agentUsd: num(v.agentUsd),
124 }
125}
126
127export const money = (usd: number, zarPerUsd: number): string =>
128 zarPerUsd > 0 ? `R${(usd * zarPerUsd).toFixed(2)}` : `$${usd.toFixed(2)}`
129
130export const compact = (n: number): string => {
131 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
132 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
133 return String(n)
134}
135
136/** `45m`, `2h05`. */
137export const duration = (minutes: number): string =>
138 minutes < 60 ? `${Math.max(0, Math.round(minutes))}m` : `${Math.floor(minutes / 60)}h${String(Math.round(minutes % 60)).padStart(2, '0')}`
139
140/** A whole percentage, or one decimal under 10 so small moves still show. */
141export const pct = (n: number): string => (n < 10 && n % 1 !== 0 ? n.toFixed(1) : String(Math.round(n)))
142
143/** The status-bar entry: the ember strip, then limits, agents, context and cache. */
144export const statusLine = (args: {
145 readonly strip: string
146 readonly fivePercent: number
147 readonly weekPercent: number
148 readonly toLimitMinutes: number | null
149 readonly running: number
150 readonly agentShare: number
151 readonly contextPercent: number
152 readonly cacheHit: number | null
153}): string => {
154 const a = args
155 const limit = a.fivePercent < 0 ? 'no limit reading' : `5h ${pct(a.fivePercent)}%`
156 return [
157 `${a.strip} ${limit}`,
158 a.toLimitMinutes === null ? null : `limit in ~${duration(a.toLimitMinutes)}`,
159 a.weekPercent < 0 ? null : `week ${pct(a.weekPercent)}%`,
160 a.running > 0 ? `${a.running} agent${a.running === 1 ? '' : 's'} ${Math.round(a.agentShare * 100)}%` : null,
161 `ctx ${a.contextPercent}%`,
162 a.cacheHit === null ? null : `cache ${a.cacheHit}%`,
163 ]
164 .filter(Boolean)
165 .join(' · ')
166}
167
168/** The status-bar entry: only what needs attention, '' when nothing does. */
169export const alertLine = (args: {
170 readonly toLimitMinutes: number | null
171 readonly running: number
172 readonly agentShare: number
173}): string =>
174 [
175 args.toLimitMinutes === null ? null : `5h limit in ~${duration(args.toLimitMinutes)}`,
176 args.running > 0 ? `${args.running} agent${args.running === 1 ? '' : 's'} running · ${Math.round(args.agentShare * 100)}%` : null,
177 ]
178 .filter(Boolean)
179 .join(' · ')
180hooks/lib/prices.ts 42 lines1// List prices per million tokens, from the Claude API reference (cached
2// 2026-10-06). Only ratios between sources matter: estimates are scaled to
3// the engine's own session cost after every main-loop turn.
4
5import type { LedgerUsage } from '../../types'
6
7type Price = { readonly input: number; readonly output: number; readonly cacheRead: number }
8
9const PRICES: readonly (readonly [RegExp, Price])[] = [
10 [/fable|mythos/, { input: 10, output: 50, cacheRead: 0.25 }],
11 [/opus-5-5/, { input: 4, output: 20, cacheRead: 0.2 }],
12 [/opus/, { input: 5, output: 25, cacheRead: 0.5 }],
13 [/sonnet-4/, { input: 3, output: 15, cacheRead: 0.3 }],
14 [/sonnet/, { input: 2, output: 10, cacheRead: 0.2 }],
15 [/haiku-4/, { input: 1, output: 5, cacheRead: 0.1 }],
16 [/haiku/, { input: 0.1, output: 0.5, cacheRead: 0.01 }],
17]
18
19/** Unknown models price as the current Opus, the default. */
20const FALLBACK: Price = { input: 4, output: 20, cacheRead: 0.2 }
21
22/** A 5-minute cache write costs 1.25x input. */
23const CACHE_WRITE_FACTOR = 1.25
24
25export const priceFor = (model: string): Price =>
26 PRICES.find(([pattern]) => pattern.test(model.toLowerCase()))?.[1] ?? FALLBACK
27
28export const estimateUsd = (usage: LedgerUsage, model: string): number => {
29 const p = priceFor(model)
30 return (
31 (usage.input * p.input +
32 usage.output * p.output +
33 usage.cacheRead * p.cacheRead +
34 usage.cacheWrite * p.input * CACHE_WRITE_FACTOR) /
35 1_000_000
36 )
37}
38
39/** A short model name for the lanes: `claude-opus-5-5` -> `opus`. */
40export const shortModel = (model: string): string =>
41 ['fable', 'mythos', 'opus', 'sonnet', 'haiku'].find(m => model.toLowerCase().includes(m)) ?? (model || '?')
42hooks/insightsView.tsx 113 lines1// Pure drawings for the Haiku insight launcher: the "?" button in the
2// telemetry row and the suggestions pane. Handlers come in from the hooks module.
3
4import type { Elements } from 'claude-code'
5
6import type { Insight } from './lib/insights'
7
8type Table = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
9
10export type InsightStatus = 'idle' | 'loading' | 'ready' | 'empty' | 'error'
11
12export type InsightsState = {
13 readonly status: InsightStatus
14 readonly items: readonly Insight[]
15 /** Indexes ticked for insertion, in the order they were ticked. */
16 readonly picked: readonly number[]
17 readonly error: string
18 readonly insertError: string
19}
20
21export const TOOLTIP = 'Generate insightful questions with Haiku'
22
23/** The hover card that names the launcher. */
24const tooltip = (els: Table, isBusy: boolean) => {
25 const { Box, Text } = els
26 return (
27 <Box position="absolute" bottom={1} right={0} display="none" hover={{ display: 'flex' }} paddingX={1} borderStyle="round" borderDimColor>
28 <Text dimColor wrap="truncate">
29 {isBusy ? 'Haiku is thinking…' : TOOLTIP}
30 </Text>
31 </Box>
32 )
33}
34
35/** The "?" launcher at the row's far right: "…" and inert while Haiku works; a hover card names it. */
36export const askButton = (els: Table, status: InsightStatus, onPress: () => void) => {
37 const { Box, Button } = els
38 const isBusy = status === 'loading'
39 return (
40 <Box key="ask-scope" flexDirection="row" alignItems="center" position="relative" marginLeft={2}>
41 <Button key="ask" plain dimColor={isBusy} label={isBusy ? '…' : '?'} onPress={onPress} />
42 {tooltip(els, isBusy)}
43 </Box>
44 )
45}
46
47const HOTKEYS = ['1', '2', '3', '4', '5']
48
49/** The label of the insert action: it names how many questions are going in. */
50export const insertLabel = (count: number): string =>
51 count === 0 ? 'Tick questions to insert' : count === 1 ? 'Insert 1 question' : `Insert ${count} questions`
52
53export const insightsPane = (
54 els: Table,
55 s: InsightsState,
56 width: number,
57 on: {
58 readonly toggle: (i: number) => void
59 readonly all: (isOn: boolean) => void
60 readonly insert: () => void
61 readonly retry: () => void
62 readonly close: () => void
63 },
64) => {
65 const { Box, Text, Button } = els
66 const room = Math.max(20, width - 8)
67 const clip = (t: string): string => (t.length > room ? `${t.slice(0, room - 1)}…` : t)
68 const isPicked = (i: number): boolean => s.picked.includes(i)
69 const count = s.picked.length
70 const isAllPicked = s.items.length > 0 && count === s.items.length
71 return (
72 <Box flexDirection="column">
73 {s.status === 'loading' && <Text dimColor>Haiku is reading the conversation and project…</Text>}
74 {s.status === 'empty' && <Text>Haiku returned no usable questions. Try again.</Text>}
75 {s.status === 'error' && <Text color="error">Couldn't generate questions: {s.error}</Text>}
76 {s.status === 'ready' && (
77 <Text dimColor>Tick any number (click or {s.items.length === 1 ? 'press 1' : `1-${s.items.length}`}), then insert them together.</Text>
78 )}
79 {s.status === 'ready' &&
80 s.items.map((item, i) => (
81 <Box key={`row-${i}`} flexDirection="column" marginBottom={i < s.items.length - 1 ? 1 : 0}>
82 <Button
83 key={`q-${i}`}
84 hotkey={HOTKEYS[i]}
85 dimColor={!isPicked(i)}
86 label={`${isPicked(i) ? '[x]' : '[ ]'} ${clip(item.title)}`}
87 onPress={() => on.toggle(i)}
88 />
89 <Text dimColor wrap="wrap">
90 {item.question}
91 </Text>
92 </Box>
93 ))}
94 {s.insertError !== '' && <Text color="warning">{s.insertError}</Text>}
95 <Box key="actions" flexDirection="row" marginTop={1} gap={2}>
96 {s.status === 'ready' && (
97 <Button key="insert" hotkey="i" variant="primary" dimColor={count === 0} label={insertLabel(count)} onPress={on.insert} />
98 )}
99 {s.status === 'ready' &&
100 (isAllPicked ? (
101 <Button key="clear" hotkey="c" dimColor label="Clear" onPress={() => on.all(false)} />
102 ) : (
103 <Button key="all" hotkey="a" dimColor label="Select all" onPress={() => on.all(true)} />
104 ))}
105 {(s.status === 'error' || s.status === 'empty' || s.status === 'ready') && (
106 <Button key="retry" plain dimColor label={s.status === 'ready' ? 'Regenerate' : 'Try again'} onPress={on.retry} />
107 )}
108 <Button key="close" plain dimColor role="dismiss" label="Close" onPress={on.close} />
109 </Box>
110 </Box>
111 )
112}
113hooks/lib/insights.ts 110 lines1// Pure logic for the Haiku insight launcher: what context goes in, how the
2// reply is read, and how a picked question joins the prompt box. No engine calls.
3
4export const INSIGHT_MODEL = 'claude-haiku-5-5'
5export const QUESTION_COUNT = 5
6
7/** Budgets keep the request small and fast; the newest context matters most. */
8const MAX_MESSAGES = 40
9const MAX_MESSAGE_CHARS = 1_500
10const MAX_THINKING_CHARS = 4_000
11const MAX_PROJECT_DOC_CHARS = 6_000
12const MAX_TOTAL_CHARS = 60_000
13
14export type Insight = { readonly title: string; readonly question: string }
15
16export type InsightContext = {
17 readonly project: {
18 readonly name: string
19 readonly branch: string
20 readonly gitStatus: string
21 readonly recentCommits: string
22 readonly docs: readonly { readonly path: string; readonly text: string }[]
23 }
24 readonly messages: readonly { readonly role: 'user' | 'assistant'; readonly text: string; readonly tools: readonly string[] }[]
25 readonly thinking: readonly string[]
26}
27
28export const SYSTEM = [
29 'You help a developer get more out of their current Claude Code conversation.',
30 `Write exactly ${QUESTION_COUNT} questions the developer could ask Claude next.`,
31 'Each question must uncover something the conversation has not adequately explored:',
32 'information or capabilities the system already has but has not surfaced; non-obvious technical opportunities;',
33 'critical assumptions nobody has verified; failure modes, edge cases and architectural weaknesses;',
34 'or a high-leverage experiment or investigation that would materially improve the outcome.',
35 'Tie every question to specifics in the context (files, tools, decisions, errors, names). No generic prompts.',
36 'Make the five meaningfully different from one another. Keep each question under 220 characters and actionable.',
37 'Use only what the context shows. Do not claim to have seen anything it does not contain.',
38 'Reply with JSON only: {"questions":[{"title":"2-5 word label","question":"the full question"}]}',
39].join(' ')
40
41const cut = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n)} [cut]` : s)
42
43/** The context as one prompt, newest conversation last, inside the total budget. */
44export const buildPrompt = (c: InsightContext): string => {
45 const project = [
46 `Project folder: ${c.project.name || 'unknown'}`,
47 c.project.branch ? `Git branch: ${c.project.branch}` : 'Not a git repository (or branch unknown).',
48 c.project.gitStatus ? `Uncommitted changes:\n${cut(c.project.gitStatus, 2_000)}` : '',
49 c.project.recentCommits ? `Recent commits:\n${cut(c.project.recentCommits, 1_500)}` : '',
50 ...c.project.docs.map(d => `Project instructions (${d.path}):\n${cut(d.text, MAX_PROJECT_DOC_CHARS)}`),
51 ].filter(Boolean)
52 const thinking = c.thinking.filter(t => t.trim() !== '')
53 const thinkingPart = thinking.length
54 ? [`Claude's visible reasoning notes (most recent):\n${cut(thinking.slice(-3).join('\n---\n'), MAX_THINKING_CHARS)}`]
55 : ["Claude's reasoning is not visible for this conversation; do not guess at it."]
56 const convo = c.messages.slice(-MAX_MESSAGES).map(m => {
57 const tools = m.tools.length ? ` [tools: ${[...new Set(m.tools)].join(', ')}]` : ''
58 return `${m.role === 'user' ? 'USER' : 'CLAUDE'}${tools}: ${cut(m.text.trim(), MAX_MESSAGE_CHARS)}`
59 })
60 const head = ['## Project', ...project, '', '## Reasoning', ...thinkingPart, '', '## Conversation (oldest first)'].join('\n')
61 // Drop the oldest messages until the whole prompt fits.
62 let body = convo
63 while (body.length > 1 && head.length + body.join('\n').length > MAX_TOTAL_CHARS) body = body.slice(1)
64 return `${head}\n${body.join('\n')}`
65}
66
67/** Reads the reply strictly: at most five distinct questions, anything malformed dropped. */
68export const parseInsights = (reply: string): Insight[] => {
69 const match = reply.match(/\{[\s\S]*\}/)
70 if (!match) return []
71 let parsed: unknown
72 try {
73 parsed = JSON.parse(match[0])
74 } catch {
75 return []
76 }
77 const list = (parsed as { questions?: unknown }).questions
78 if (!Array.isArray(list)) return []
79 const seen = new Set<string>()
80 const out: Insight[] = []
81 for (const item of list) {
82 if (typeof item !== 'object' || item === null) continue
83 const question = typeof (item as Insight).question === 'string' ? (item as Insight).question.trim() : ''
84 const title = typeof (item as Insight).title === 'string' ? (item as Insight).title.trim() : ''
85 const key = question.toLowerCase()
86 if (question === '' || seen.has(key)) continue
87 seen.add(key)
88 out.push({ title: title || question.split(/\s+/).slice(0, 4).join(' '), question })
89 if (out.length === QUESTION_COUNT) break
90 }
91 return out
92}
93
94/**
95 * How the ticked questions join the prompt box: never over the user's own text.
96 * Several go in together, one blank line apart, in the order they were ticked.
97 */
98export const fillPlan = (
99 current: string,
100 questions: readonly string[],
101): { readonly text: string; readonly mode: 'replace' | 'append' } => {
102 const joined = questions
103 .map(q => q.trim())
104 .filter(q => q !== '')
105 .join('\n\n')
106 return current.trim() === ''
107 ? { text: joined, mode: 'replace' }
108 : { text: `${current.endsWith('\n') ? '' : '\n\n'}${joined}`, mode: 'append' }
109}
110hooks/telemetry.tsx 166 lines1// The telemetry row above the prompt: one line of glyph + value groups, drawn as
2// text on every surface (the desktop shows plugin SVG blank, so none is used).
3// Pure: data in, tree out. A metric with no real reading is left out, never faked.
4
5import type { Elements } from 'claude-code'
6
7import { pct } from './lib/ledger'
8
9/** Any surface's table: the row needs only Box and Text. */
10type Table = Pick<Elements['terminal'], 'Box' | 'Text'>
11
12export type TelemetryData = {
13 readonly fivePercent: number
14 readonly ratePerMin: number
15 readonly resetsAt: string
16 readonly now: number
17 readonly weekPercent: number
18 readonly contextPercent: number | null
19 readonly cacheHit: number | null
20 readonly model: string
21 readonly effort: string
22}
23
24type IconKey = 'database' | 'zap' | 'clock' | 'hourglass' | 'chart'
25
26export type Group = {
27 readonly key: string
28 readonly icon: IconKey
29 /** Icon colour: a hex for the desktop's Svg, a theme key for the terminal glyph. */
30 readonly iconHex: string
31 readonly iconTheme: string
32 readonly value: string
33 readonly valueColor?: string
34 readonly label?: string
35 readonly isBold?: boolean
36 /** Lower drops first when the line is too narrow. */
37 readonly priority: number
38}
39
40/** One-cell stand-ins for the terminal, which draws no Svg. */
41const GLYPHS: Record<IconKey, string> = { database: '▤', zap: 'ϟ', clock: '◷', hourglass: '⧗', chart: '▥' }
42
43const ORANGE = '#EF9F27'
44const MUTED = '#9C9A92'
45const BLUE = '#4FA3E0'
46const PURPLE = '#9B8CF0'
47const GREEN = '#4CB782'
48
49/** `claude-opus-5-5` -> `Opus 5.5`; a name already shown as `/model` shows it stays. */
50export const prettyModel = (model: string): string => {
51 const m = model.toLowerCase().match(/(fable|mythos|opus|sonnet|haiku)[-\s]?(\d+)(?:[-.](\d+))?/)
52 if (!m) return model.replace(/\[.*\]$/, '').trim()
53 const family = (m[1] ?? '').charAt(0).toUpperCase() + (m[1] ?? '').slice(1)
54 return m[3] ? `${family} ${m[2]}.${m[3]}` : `${family} ${m[2]}`
55}
56
57export const prettyEffort = (effort: string): string => (effort ? effort.charAt(0).toUpperCase() + effort.slice(1) : '')
58
59/** `2h 15m`, `45m`. */
60export const remaining = (minutes: number): string => {
61 const m = Math.max(0, Math.round(minutes))
62 return m >= 60 ? `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m` : `${m}m`
63}
64
65/** `09:00 AM`. */
66export const clockTime = (iso: string): string => {
67 const d = new Date(iso)
68 if (Number.isNaN(d.getTime())) return ''
69 const h = d.getHours()
70 return `${String(h % 12 || 12).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`
71}
72
73/** The nine groups in the specified order, each only when its value is real. */
74export const buildGroups = (d: TelemetryData): Group[] => {
75 const resetMs = d.resetsAt ? Date.parse(d.resetsAt) : NaN
76 const groups: (Group | null)[] = [
77 d.fivePercent < 0
78 ? null
79 : { key: 'five', icon: 'database', iconHex: ORANGE, iconTheme: 'warning', value: `${pct(d.fivePercent)}%`, valueColor: 'warning', label: '5h limit', priority: 9 },
80 d.fivePercent < 0
81 ? null
82 : { key: 'rate', icon: 'zap', iconHex: ORANGE, iconTheme: 'warning', value: `+${d.ratePerMin.toFixed(1)}/min`, priority: 4 },
83 Number.isNaN(resetMs)
84 ? null
85 : { key: 'reset', icon: 'clock', iconHex: MUTED, iconTheme: 'subtle', value: clockTime(d.resetsAt), priority: 3 },
86 Number.isNaN(resetMs)
87 ? null
88 : { key: 'left', icon: 'hourglass', iconHex: MUTED, iconTheme: 'subtle', value: remaining((resetMs - d.now) / 60_000), priority: 8 },
89 d.weekPercent < 0
90 ? null
91 : { key: 'week', icon: 'chart', iconHex: BLUE, iconTheme: 'suggestion', value: `${pct(d.weekPercent)}%`, priority: 7 },
92 d.contextPercent === null
93 ? null
94 : { key: 'ctx', icon: 'database', iconHex: PURPLE, iconTheme: 'merged', value: `${d.contextPercent}%`, priority: 6 },
95 d.cacheHit === null
96 ? null
97 : { key: 'cache', icon: 'database', iconHex: GREEN, iconTheme: 'success', value: `${d.cacheHit}%`, priority: 5 },
98 d.model ? { key: 'model', icon: 'database', iconHex: '', iconTheme: '', value: prettyModel(d.model), isBold: true, priority: 2 } : null,
99 d.effort ? { key: 'effort', icon: 'database', iconHex: '', iconTheme: '', value: prettyEffort(d.effort), priority: 1 } : null,
100 ]
101 return groups.filter((g): g is Group => g !== null)
102}
103
104const hasIcon = (g: Group): boolean => g.iconHex !== ''
105
106/** Cells one group takes: icon and space, value, label. */
107const widthOf = (g: Group): number => (hasIcon(g) ? 2 : 0) + g.value.length + (g.label ? g.label.length + 1 : 0)
108
109/** Cells between groups: a space, the divider, a space; tighter when room is short. */
110const SEP_WIDE = 5
111const SEP_TIGHT = 3
112
113/**
114 * Keeps the row on one line: first tightens the dividers, then drops the
115 * lowest-priority groups until it fits. Order is kept; nothing scrolls.
116 */
117export const fitGroups = (groups: readonly Group[], columns: number): { groups: Group[]; sep: number } => {
118 const total = (gs: readonly Group[], sep: number): number => gs.reduce((s, g) => s + widthOf(g), 0) + Math.max(0, gs.length - 1) * sep
119 if (total(groups, SEP_WIDE) <= columns) return { groups: [...groups], sep: SEP_WIDE }
120 let kept = [...groups]
121 while (kept.length > 1 && total(kept, SEP_TIGHT) > columns) {
122 const lowest = kept.reduce((a, b) => (b.priority < a.priority ? b : a))
123 kept = kept.filter(g => g !== lowest)
124 }
125 return { groups: kept, sep: SEP_TIGHT }
126}
127
128export const telemetryTree = (els: Table, d: TelemetryData, columns: number) => {
129 const { Box, Text } = els
130 const { groups, sep } = fitGroups(buildGroups(d), columns)
131 const pad = sep === SEP_WIDE ? 2 : 1
132 return (
133 <Box flexDirection="row" alignItems="center" flexWrap="nowrap" overflow="hidden">
134 {groups.flatMap((g, i) => {
135 const parts = [
136 <Box key={`g-${g.key}`} flexDirection="row" alignItems="center" flexShrink={0}>
137 {hasIcon(g) ? (
138 <Text key={`i-${g.key}`} color={g.iconTheme}>
139 {GLYPHS[g.icon]}
140 </Text>
141 ) : null}
142 <Text key={`v-${g.key}`} {...(g.valueColor ? { color: g.valueColor } : { color: 'text' })} bold={g.isBold === true}>
143 {hasIcon(g) ? ' ' : ''}
144 {g.value}
145 </Text>
146 {g.label ? (
147 <Text key={`l-${g.key}`} color="inactive">
148 {' '}
149 {g.label}
150 </Text>
151 ) : null}
152 </Box>,
153 ]
154 if (i < groups.length - 1) {
155 parts.push(
156 <Text key={`s-${g.key}`} color="subtle" dimColor>
157 {' '.repeat(pad)}│{' '.repeat(pad)}
158 </Text>,
159 )
160 }
161 return parts
162 })}
163 </Box>
164 )
165}
166hooks/views.tsx 102 lines1// Pure drawings for the /ledger pane: element tables and data in, a tree
2// out. No engine calls here; the hooks module gathers the data. Everything
3// is shown as limit percentages and tokens, never money.
4
5import type { Elements } from 'claude-code'
6
7import type { LedgerAgent, LedgerDay, LedgerMiss, LedgerSession, LedgerTurn } from '../types'
8import { isDone, liveCost } from './lib/agents'
9import { cacheHitPercent, compact, pct } from './lib/ledger'
10import { shortModel } from './lib/prices'
11
12type Table = Elements['terminal']
13
14
15export type PaneData = {
16 readonly session: LedgerSession
17 readonly week: LedgerDay
18 readonly misses: readonly LedgerMiss[]
19 readonly recent: readonly LedgerTurn[]
20 readonly agents: readonly LedgerAgent[]
21 readonly ctx: number
22 readonly five: number
23 readonly weekPercent: number
24 readonly resetsLabel: string
25 readonly rows: number
26 readonly now: number
27}
28
29const ago = (now: number, at: number): string => {
30 const m = Math.round((now - at) / 60_000)
31 return m < 1 ? 'now' : `${m}m`
32}
33
34const tokensOf = (u: { input: number; output: number; cacheRead: number; cacheWrite: number }): number =>
35 u.input + u.output + u.cacheRead + u.cacheWrite
36
37export const paneTree = (els: Pick<Table, 'Box' | 'Text'>, d: PaneData) => {
38 const { Box, Text } = els
39 const s = d.session
40 const hit = cacheHitPercent(s.usage)
41 const agentTotal = d.agents.reduce((sum, a) => sum + liveCost(a), 0)
42 const sessionTotal = Math.max(0.000001, s.costUsd + d.agents.reduce((sum, a) => sum + a.pendingUsd, 0))
43 const shareOf = (a: LedgerAgent): string => `${Math.round((liveCost(a) / sessionTotal) * 100)}%`
44 const room = Math.max(3, d.rows - 24)
45 return (
46 <Box flexDirection="column">
47 <Text bold>Limits</Text>
48 <Text>
49 5-hour {d.five < 0 ? 'no reading' : `${pct(d.five)}%`}
50 {d.resetsLabel ? ` · resets ${d.resetsLabel}` : ''} · week {d.weekPercent < 0 ? 'no reading' : `${pct(d.weekPercent)}%`} · context {d.ctx}%
51 </Text>
52 <Text> </Text>
53 <Text bold>This session</Text>
54 <Text>
55 {s.turns} turns · {compact(tokensOf(s.usage))} tokens · cache hit {hit === null ? 'n/a' : `${hit}%`}
56 </Text>
57 <Text dimColor>
58 in {compact(s.usage.input)} · out {compact(s.usage.output)} · cache read {compact(s.usage.cacheRead)} · cache write{' '}
59 {compact(s.usage.cacheWrite)}
60 </Text>
61 <Text> </Text>
62 <Text bold>Subagents</Text>
63 <Text>
64 {d.agents.length} this session · about {Math.round((agentTotal / sessionTotal) * 100)}% of the session's usage
65 </Text>
66 {[...d.agents]
67 .sort((x, y) => liveCost(y) - liveCost(x))
68 .slice(0, 5)
69 .map(a => (
70 <Text key={`agent-${a.id}`} {...(isDone(a.status) ? { dimColor: true } : { color: '#378ADD' })}>
71 {isDone(a.status) ? '✓' : '▶'} {a.type} · {a.description || a.id.slice(0, 8)} · {shortModel(a.model)} · {a.calls} calls ·{' '}
72 {compact(tokensOf(a.usage))} tokens · {shareOf(a)} · {isDone(a.status) ? `done ${ago(d.now, a.endedAt ?? d.now)}` : a.tool || 'thinking'}
73 </Text>
74 ))}
75 <Text> </Text>
76 <Text bold>Last 7 days</Text>
77 <Text>
78 {d.week.turns} turns · {compact(tokensOf(d.week.usage))} tokens
79 </Text>
80 <Text> </Text>
81 <Text bold>Cache rewrites after idle</Text>
82 <Text>
83 {d.misses.length} this session · {d.week.misses} in 7 days
84 </Text>
85 {d.misses.slice(-3).map(x => (
86 <Text key={`miss-${x.at}`} dimColor>
87 {new Date(x.at).toLocaleTimeString()} · idle {x.gapMinutes} min ({x.ttl}) · {compact(x.tokensRewritten)} tokens rewritten
88 </Text>
89 ))}
90 <Text> </Text>
91 <Text bold>Recent turns</Text>
92 {d.recent.length === 0 && <Text dimColor>No turns yet.</Text>}
93 {d.recent.slice(-room).map(t => (
94 <Text key={`turn-${t.at}`} dimColor>
95 {new Date(t.at).toLocaleTimeString()} · in {compact(t.usage.input + t.usage.cacheRead + t.usage.cacheWrite)} · out{' '}
96 {compact(t.usage.output)} · {Math.round(t.durationMs / 1000)}s
97 </Text>
98 ))}
99 </Box>
100 )
101}
102types/index.d.ts 105 lines1export type LedgerUsage = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6}
7
8/** A turn that rewrote the prompt cache after the session sat idle. */
9export type LedgerMiss = {
10 at: number
11 gapMinutes: number
12 /** "5m" when the gap passed the 5-minute cache, "1h" past the 1-hour one. */
13 ttl: '5m' | '1h'
14 tokensRewritten: number
15 costUsd: number
16}
17
18export type LedgerTurn = {
19 at: number
20 costUsd: number
21 usage: LedgerUsage
22 durationMs: number
23}
24
25export type LedgerDay = {
26 costUsd: number
27 turns: number
28 usage: LedgerUsage
29 misses: number
30 missCostUsd: number
31 /** The part of costUsd that subagents spent. */
32 agentUsd: number
33}
34
35export type LedgerSession = {
36 costUsd: number
37 turns: number
38 usage: LedgerUsage
39}
40
41/** One subagent as the ledger tracks it. */
42export type LedgerAgent = {
43 id: string
44 type: string
45 description: string
46 model: string
47 status: string
48 parentId?: string
49 calls: number
50 usage: LedgerUsage
51 /** Price-table estimate not yet matched against the engine's cost. */
52 pendingUsd: number
53 /** Cost so far, scaled so all sources add up to the engine's total. */
54 costUsd: number
55 tool: string
56 startedAt: number
57 endedAt?: number
58 isAlerted: boolean
59}
60
61/** The 5-hour limit's percentage used at a moment, for the burn rate. */
62export type LedgerSample = {
63 at: number
64 percent: number
65}
66
67declare module 'claude-code' {
68 interface PluginState {
69 'token-ledger': {
70 agents: LedgerAgent[]
71 /** The main loop's estimate not yet matched against the engine's cost. */
72 mainPendingUsd: number
73 /** The 5-hour limit's percentage used over time, newest last. */
74 samples: LedgerSample[]
75 /** The weekly limit's percentage used, -1 with no reading. */
76 weekPercent: number
77 /** When the 5-hour window resets (ISO), '' with no reading. */
78 fiveHourResetsAt: string
79 /** The 5-hour percentage when the session started, for points used since. */
80 startPercent: number
81 /** The effort the main loop's last model call asked for, '' when none was reported. */
82 effort: string
83 /** The Haiku insight launcher: its state and the questions it last produced. */
84 insights: {
85 status: 'idle' | 'loading' | 'ready' | 'empty' | 'error'
86 items: { title: string; question: string }[]
87 error: string
88 insertError: string
89 }
90 session: LedgerSession
91 recent: LedgerTurn[]
92 misses: LedgerMiss[]
93 today: LedgerDay
94 week: LedgerDay
95 /** When the last main-loop turn ended, for the idle-gap check. */
96 lastEndAt: number
97 /** The engine's session cost at the last turn, to price each turn. */
98 lastCostUsd: number
99 /** The context fill and the 5-hour window, as of the last turn. */
100 contextPercent: number
101 fiveHourPercent: number
102 }
103 }
104}
105