SLOPSHOPPER

token-ledger

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…

newpanebandguardcommandtoast
★ 1v0.5.0MITupdated 2026-10-12bully911210/claude-code-mods/token-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-ledger
│ ┃ Token ledger ✕ › fix the failing auth test and add an audit log call │ ┃ Limits │ ┃ 5-hour 31% · resets 09:53 AM · week no ⏺ Read(src/auth.ts) │ ┃ reading · context 49% ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ This session ⎿ Added 2 lines, removed 1 line │ ┃ 1 turns · 99k tokens · cache hit 93% ⏺ Bash(bun test) │ ┃ in 2k · out 1k · cache read 91k · cache ⎿ 3 pass, 1 fail │ ┃ write 4k │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Subagents │ ┃ 0 this session · about 0% of the session's ✻ Worked for 42s · done 4:20 PM │ ┃ usage │ ┃ › /ledger │ ┃ Last 7 days ⎿ token-ledger: 5-hour limit 31% used. │ ┃ 1 turns · 99k tokens │ ┃ │ ┃ Cache rewrites after idle │ ┃ 0 this session · 0 in 7 days │ ┃ │ ┃ Recent turns │ ┃ 8:53:20 AM · in 97k · out 1k · 42s │ ▤ 31% 5h limit │ ϟ +0.0/min │ ▤ 49% │ ▤ 93% │ Opus 5.5 ? ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▤ 31% 5h limit │ ϟ +0.0/min │ ▤ 49% │ ▤ 93% │ Opus 5.5 ?
Pane · Token ledger
Limits 5-hour 31% · resets 09:53 AM · week no reading · context 49% This session 1 turns · 99k tokens · cache hit 93% in 2k · out 1k · cache read 91k · cache write 4k Subagents 0 this session · about 0% of the session's usage Last 7 days 1 turns · 99k tokens Cache rewrites after idle 0 this session · 0 in 7 days Recent turns 8:53:20 AM · in 97k · out 1k · 42s
Pane · Questions worth asking
Couldn't generate questions: undefined is not an object (evaluating 'm.content.filter') Try again Close
README

Claude Code mods

ci licence: MIT

Three function-hook mods for Claude Code:

  • token-ledger puts your plan limits in one row above the prompt and adds a ? button that asks Haiku what you should ask next.
  • next-steps suggests your next prompts after each turn.
  • auto-docs keeps your README in step with every commit.

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%">

ModWhat you getCommands
token-ledgerA 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-steps1-3 suggested next prompts after each real turn, in a pane of their own./next, /next off, /next on
auto-docsAfter 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.


token-ledger

The telemetry row

<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:

  • 5-hour limit % (orange)
  • burn rate
  • when the 5-hour window resets
  • time left
  • weekly limit % (blue)
  • context window % (purple)
  • cache-hit % (green)
  • model
  • effort

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 asking

Click ? 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:

  • your limits
  • this session's tokens and cache-hit rate
  • each subagent's share of the session
  • the last 7 days
  • cache rewrites after idle: turns where the prompt cache expired and had to be rewritten

Settings (/config, or pluginConfigs in settings.json):

SettingDefaultWhat it does
agentAlertPercent10Shows a toast when one subagent has used about this much of your 5-hour limit. 0 turns it off.
animationsonOff draws a still row.
toastOnCacheMissonShows a toast when a turn after an idle gap rewrote the prompt cache.

next-steps

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:

  • ID numbers
  • phone numbers
  • email addresses
  • card numbers

The scrubbing covers file and project names too.

SettingDefaultWhat it does
modelhaikuhaiku or sonnet.
minAnswerChars300Skips suggestions after shorter answers, unless the turn made 3 or more tool calls.

auto-docs

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%">

  • An edit is applied only when the text it replaces appears exactly once. Any edits it skips are reported.
  • It skips commits marked [skip docs], docs: commits, and commits that only touch documentation.
  • It never pushes.
  • It only sees commits this session makes. For commits made elsewhere, run /docs or /docs <commit>.
SettingDefaultWhat it does
docsREADME.mdComma-separated paths from the repository root.
modecommitcommit makes a separate docs commit. stage leaves the changes staged for you to review.
modelsonnetThe model that reads the diff.
maxDiffChars60000Larger diffs are cut down by whole files.

Install

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.

Develop

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:

  1. Copy docs/capture/<mod>.capture.ts into that mod's tests/ folder.
  2. Run claude plugin test <mod>, and save the lines that start with CAPTURE (without that prefix) to docs/capture/<mod>.jsonl.
  3. Run python docs/tools/render.py.

The interactive demo (docs/index.html) reads the same JSONL files.

More

Licence

MIT

Source 10 files
hooks/register.tsx 569 lines
1import { 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}
569
hooks/lib/agents.ts 112 lines
1// 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) : []
112
hooks/lib/furnace.ts 122 lines
1// 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}
122
hooks/lib/ledger.ts 180 lines
1// 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(' · ')
180
hooks/lib/prices.ts 42 lines
1// 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 || '?')
42
hooks/insightsView.tsx 113 lines
1// 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}
113
hooks/lib/insights.ts 110 lines
1// 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}
110
hooks/telemetry.tsx 166 lines
1// 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}
166
hooks/views.tsx 102 lines
1// 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}
102
types/index.d.ts 105 lines
1export 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