Cost control for AI agents: a budget per task, a live ledger of every step, and a circuit breaker that stops loops, burn spikes and runaway spend.

Cost control for AI agents. TokenMunim is a Claude Code mod that gives every task its own budget, keeps a live ledger of every step your agent takes, and trips a circuit breaker the moment the agent starts wasting money.
<img src="docs/screenshots/dashboard-window.png" alt="The TokenMunim dashboard docked beside a Claude Code session after a research batch, transcript blurred">
Agents now run for hours with nobody watching. When one gets stuck it rarely crashes: it retries, rereads, rewrites, and keeps spending. Your bill shows the total, not which task spent it, and nothing stops the spending while it happens.
TokenMunim answers three questions while the agent works, not after:
Budgets per task. The agent starts a task with the start_task tool, and everything it does from then on is counted against that task: cost, tokens, calls and failures, with an optional budget. An overnight run of twenty tasks becomes twenty lines on a ledger instead of one big number.
A live ledger. Every tool call is recorded as it happens: what it was for, how long it took, and whether it worked. The full ledger is written to .tokenmunim/ledger-<session>.md in your project, one click away in the pane's footer. The folder ignores itself in git.
A circuit breaker. TokenMunim stops a call before it runs when:
| Circuit | Trips when | The agent is told |
|---|---|---|
| Loop | The same action fails 3 times in a row | Stop retrying, read the error, change the approach |
| Burn | Spend over the last two minutes passes the rate limit | Pause and work leaner |
| Budget | A task spends more than its budget | Leave this task and move to the next one |
| Session | The session spends more than its budget | Stop and report |
A trip blocks one call and says why, so the agent can recover. It never ends the session, and it never blocks the tools an agent needs to get itself unstuck.
<img src="docs/screenshots/dashboard-alerts.png" width="640" alt="Three circuit trips from one run: a task halted over its budget, a burn spike paused, and a retry loop blocked, each with its own buttons">
Per agent accounting. Every reply is booked to the loop that made it, the main agent or a subagent, with its own tokens, cost, steps and cache hit rate. When a run fans out, you can see which subagent did the spending.
Plan limits and runway. How much of your 5 hour and weekly limits is left, and whether the last twenty minutes' pace lasts until the reset: ✓ lasts, or ⚠ out in 42m while there is still time to slow down.
Token mix. Where the tokens went: cache reads, fresh input, cache writes and output, with the cache hit rate that decides what a long session really costs.
A dashboard that docks beside the transcript.
| Tab | What it shows |
|---|---|
| Overview | Plan limits and runway, spend, cost and tokens per minute, calls stopped, the burn chart, the token mix, tasks, recent activity and alerts |
| Tasks | Every task with its spend, tokens, budget bar and calls. › shows that task's calls |
| Activity | Every call, newest first, or only one task's after you drill in. › opens a call's details |
| Alerts | Everything the circuit breaker stopped, and what was done about it |
| Agents | The main agent and each subagent, with their model, steps, tokens, cost and cache hit rate |
● 2 new badge until you open it, then lists every alert the badge counts, each halted task once.+$0.50 budget gives a halted task more room, Allow once lets the next call through, Skip task moves the agent on. Each choice is recorded on the alert./munim fold activity, /munim unfold burn.<img src="docs/screenshots/dashboard-top.png" width="420" alt="Plan limits, spend, cost and tokens per minute, and the burn chart over its limit"> <img src="docs/screenshots/dashboard-tasks.png" width="420" alt="The token mix and the tasks: strategy 6 halted at 1.2 times its budget, with three new alerts">
Needs Claude Code 2.1.287 or later.
claude plugin marketplace add PiyushSinha-9/tokenmunim
claude plugin install tokenmunim@tokenmunim
The installer may say the settings aren't set yet. They all have defaults, so you can skip that.
TokenMunim protects every session from the start, in the background, and shows nothing until you ask. Run /munim and a single TokenMunim button appears at the end of the line under the prompt, for that session only. Click it to open the dashboard. While the dashboard is open the button shows a ✕, and clicking it again closes it.
Ask for tasks in your prompt:
Migrate all twelve services to the new config. Start a task per service with a $0.50 budget.
| Command | What it does |
|---|---|
/munim | Shows the TokenMunim button under the prompt, for this session |
/munim open / /munim close | Opens or closes the dashboard, like the button |
/munim off | Hides the button and the dashboard; protection carries on |
/munim statement | Prints the session's statement |
/munim ledger | Writes the ledger file and opens it |
/munim tab <name> | Switches to overview, tasks, activity, alerts or agents |
/munim fold <section> | Folds burn, mix, tasks or activity; /munim unfold <section> opens it |
/munim task <name> | Starts a task by hand |
/munim end | Ends the current task |
/munim reset | Clears this session's ledger |
Scripts and agents that can't see the tool can start a task with a shell line, munim:task <name> <budget>, and end it with munim:end. TokenMunim answers these itself; the shell never runs them.
Change them in /plugin, under TokenMunim's configuration.
| Setting | Default | Meaning |
|---|---|---|
| Budget per task | $1 | A task that spends this much is halted |
| Session budget | $20 | Every call is blocked once the session spends this much |
| Loop limit | 3 | Identical failures before a retry is blocked |
| Burn limit | $2 a minute | Measured over two minutes; pauses the agent once |
| Open the dashboard when a session starts | Off | Turn it on to see the dashboard without running /munim |
TokenMunim runs inside Claude Code, on your machine. It never calls a model itself, and nothing it records leaves your machine.
examples/flaky-feed is a feed that answers "busy, retry" for one item forever, the shape most agent loops take. Ask Claude to fetch from it and retry when it is busy, and watch the loop circuit stop the retries.
TokenMunim hooks five events: every tool call (check, run, record), every model reply (book its tokens and cost to the task and the agent), the end of each turn (save the ledger), its slash command, and the pane's draw. The rules are pure functions over one immutable ledger, the pane is pure drawing over that ledger and its own view state, and both are tested without a live session, buttons and all. docs/DESIGN.md covers the design, the trade-offs, and the bugs TokenMunim found in itself.
2% of my week is the natural one. Claude Code reports how much of each window is used, rounded, but not how big a window is, so a share can't be measured precisely yet. docs/DESIGN.md has what was tried.Munim (मुनीम) is the Hindi word for the bookkeeper of a traditional Indian business, who writes down every rupee that comes in or goes out. TokenMunim keeps the same kind of books for your agent's tokens.
claude plugin validate ./plugin
claude plugin test ./plugin
MIT
hooks/register.tsx 428 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SessionSummary, Tab, View } from '../types'
5import {
6 addTrip,
7 allowOnce,
8 ledgerMarkdown,
9 bookCost,
10 bookTokens,
11 check,
12 endTask,
13 emptyBook,
14 fingerprint,
15 haltIfOver,
16 haltedNamed,
17 limitsFrom,
18 MAIN,
19 money,
20 nameAgents,
21 noteAgentCall,
22 normalize,
23 startTask,
24 parseMarker,
25 raiseBudget,
26 record,
27 setLimits,
28 skipTask,
29 statement,
30 summarize,
31 summary,
32 toolLabel,
33} from './ledger'
34import type { Limits, Usage } from './ledger'
35import { DEFAULT_VIEW, drawBar, drawPane, migrateView, toggleSection } from './pane'
36
37const PANE = 'tokenmunim'
38const OWN = 'mcp__tokenmunim__'
39// Tools the agent needs to get itself out of a halt are never blocked.
40const EXEMPT = new Set(['ToolSearch'])
41const HISTORY_KEY = 'history'
42const TABS: readonly Tab[] = ['overview', 'tasks', 'activity', 'alerts', 'agents']
43const SECTIONS: readonly string[] = ['burn', 'mix', 'tasks', 'activity', 'alerts']
44const book = atom({ plugin: 'tokenmunim', key: 'book' } as const, emptyBook())
45const view = atom({ plugin: 'tokenmunim', key: 'view' } as const, DEFAULT_VIEW)
46const bar = atom({ plugin: 'tokenmunim', key: 'bar' } as const, { shown: false, open: false })
47
48const fileStamp = (ms: number) => {
49 const d = new Date(ms)
50 const pad = (n: number) => String(n).padStart(2, '0')
51 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
52}
53
54// Where this session's ledger lives, worked out once per load.
55let limits: Limits = limitsFrom({})
56let cwd: string | undefined
57let home: string | undefined
58let startedAt = 0
59let ledgerFile: string | undefined
60let isAlerting = false
61let agentsCheckedAt = 0
62
63async function costNow($: EngineInterface) {
64 return (await $.session.usage()).cost?.usd
65}
66
67async function locate($: EngineInterface) {
68 if (ledgerFile !== undefined) return
69 try {
70 startedAt = (await $.session.usage()).startedAt
71 cwd = (await $.fs.stat('.', { resolve: true })).realPath
72 home = await $.env.get('HOME')
73 if (cwd) ledgerFile = `${cwd}/.tokenmunim/ledger-${fileStamp(startedAt)}.md`
74 } catch {
75 // No file system here (a test, a remote host): the pane still works.
76 }
77}
78
79// The ledger file is best effort: a failed write never gets in the agent's way.
80async function writeLedger($: EngineInterface): Promise<string | undefined> {
81 try {
82 await locate($)
83 if (ledgerFile === undefined || cwd === undefined) return undefined
84 const b = normalize(await read($, book))
85 const now = await $.clock.now()
86 await $.fs.write(`${cwd}/.tokenmunim/.gitignore`, '*\n')
87 await $.fs.write(ledgerFile, ledgerMarkdown(b, { startedAt, now, cwd, limits }))
88 return ledgerFile
89 } catch {
90 return undefined
91 }
92}
93
94async function openLedger($: EngineInterface) {
95 const file = await writeLedger($)
96 if (file !== undefined) await $.process.run(['open', file])
97}
98
99// Names and states for subagents, from the engine's list, a few seconds apart.
100async function refreshAgents($: EngineInterface, now: number) {
101 if (now - agentsCheckedAt < 5_000) return
102 agentsCheckedAt = now
103 try {
104 const b = normalize(await read($, book))
105 if (!b.agents.some(a => a.id !== MAIN)) return
106 const infos = await $.agent.list()
107 await update($, book, cur =>
108 nameAgents(normalize(cur), infos.map(i => ({ id: i.id, description: i.description, type: i.type, status: i.status }))),
109 )
110 } catch {
111 // Names are a nicety; the numbers stand without them.
112 }
113}
114
115// One model request finished: book its tokens, its cost and the plan limits,
116// and halt its task if the reply carried it past its budget.
117async function noteStep($: EngineInterface, usage: Usage, agentId: string | undefined) {
118 try {
119 const now = await $.clock.now()
120 const s = await $.session.usage()
121 await update($, book, b =>
122 haltIfOver(setLimits(bookCost(bookTokens(normalize(b), usage, now, agentId), s.cost?.usd, now, agentId), s.rateLimits, now), now),
123 )
124 if (agentId !== undefined) await refreshAgents($, now)
125 } catch {
126 // Accounting never gets in the way of the model's answer.
127 }
128}
129
130// Opens or closes the dashboard. The bar shows whenever the dashboard has been
131// asked for, and its button follows the pane, however the pane was closed.
132async function setPane($: EngineInterface, open: boolean) {
133 try {
134 if (open) await $.ui.open({ id: PANE, title: 'TokenMunim' })
135 else await $.ui.close({ id: PANE })
136 } catch {
137 // A surface with no panes: the bar and the ledger still work.
138 }
139 await update($, bar, () => ({ shown: true, open }))
140}
141
142async function togglePane($: EngineInterface) {
143 let isOpen = (await read($, bar)).open
144 try {
145 isOpen = (await $.ui.panes()).some(p => p.id === PANE)
146 } catch {
147 // Fall back to what the bar last knew.
148 }
149 await setPane($, !isOpen)
150}
151
152// The status line is an alarm, not a ticker: it shows while a circuit is tripped.
153function alarm($: EngineInterface, text: string | undefined) {
154 if (text === undefined && !isAlerting) return
155 isAlerting = text !== undefined
156 $.ui.status(text)
157}
158
159// What the person did from the circuit card.
160async function decide($: EngineInterface, kind: 'raise' | 'allow' | 'skip', taskId?: string, by?: number) {
161 const b = normalize(await read($, book))
162 const name = b.tasks.find(k => k.id === taskId)?.name ?? 'the task'
163 if (kind === 'raise' && taskId !== undefined && by !== undefined) {
164 await update($, book, cur => raiseBudget(normalize(cur), taskId, by))
165 $.ui.toast(`TokenMunim: ${name} gets ${money(by)} more budget`)
166 } else if (kind === 'allow') {
167 await update($, book, cur => allowOnce(normalize(cur)))
168 $.ui.toast('TokenMunim: the next call goes through')
169 } else if (kind === 'skip' && taskId !== undefined) {
170 await update($, book, cur => skipTask(normalize(cur), taskId))
171 $.ui.toast(`TokenMunim: ${name} skipped; the agent moves on at its next call`)
172 }
173 alarm($, undefined)
174 await writeLedger($)
175}
176
177export const register: Register = (on, options) => {
178 limits = limitsFrom(options)
179 const openOnStart = options.openOnStart === true
180
181 on('session.start', async ($, e, next) => {
182 await $.tool.register({
183 name: 'start_task',
184 description:
185 'Start a task: TokenMunim tracks its cost, tokens, calls and failures separately from everything else, with an optional budget. Call it when you begin each distinct piece of work in a batch; it ends the task before it. If TokenMunim halts a task, start the next one to continue.',
186 inputSchema: {
187 type: 'object',
188 properties: {
189 name: { type: 'string', description: 'Short name of the task, like "strategy 3" or "migrate billing service".' },
190 budget_usd: { type: 'number', description: 'Optional budget in USD for this task.' },
191 },
192 required: ['name'],
193 },
194 })
195 await $.tool.register({
196 name: 'end_task',
197 description: 'End the current task when its work is done.',
198 })
199 await $.command.register({
200 name: 'munim',
201 description: 'TokenMunim: show its button for this session, or open, close, off, statement, ledger, tab, fold, unfold, task, end, reset',
202 argumentHint: 'open | close | off | statement | ledger | tab <name> | fold <section> | unfold <section> | task <name> | end | reset',
203 })
204 await locate($)
205 // A status line left by an earlier load is stale; the alarm starts quiet.
206 $.ui.status(undefined)
207 // Nothing shows until the person asks for it, unless they set it to open.
208 if (openOnStart) await setPane($, true)
209 return next(e)
210 })
211
212 on('tool.call', { tool: 'mcp__tokenmunim__start_task' }, async ($, e) => {
213 const name = typeof e.name === 'string' && e.name.trim() !== '' ? e.name.trim() : 'task'
214 const budget = typeof e.budget_usd === 'number' && e.budget_usd > 0 ? e.budget_usd : limits.taskBudgetUsd
215 const now = await $.clock.now()
216 const usd = await costNow($)
217 const halted = haltedNamed(normalize(await read($, book)), name)
218 if (halted !== undefined) {
219 return { result: `Task "${halted.name}" was halted (${halted.haltReason ?? 'over budget'}) and stays halted. Tell the user, then start a different task or stop.` }
220 }
221 await update($, book, b => startTask(bookCost(normalize(b), usd, now, e.agentId), name, budget, now)[0])
222 alarm($, undefined)
223 await writeLedger($)
224 return { result: `Task "${name}" started with a budget of ${money(budget)}. Its cost, tokens and calls are now tracked on their own.` }
225 })
226
227 on('tool.call', { tool: 'mcp__tokenmunim__end_task' }, async ($, e) => {
228 const now = await $.clock.now()
229 const usd = await costNow($)
230 await update($, book, b => endTask(bookCost(normalize(b), usd, now, e.agentId)))
231 await writeLedger($)
232 return { result: 'Task ended.' }
233 })
234
235 on('tool.call', async ($, e, next) => {
236 const tool = String(e.tool)
237 if (tool.startsWith(OWN) || EXEMPT.has(tool)) return next(e)
238
239 const input = e as unknown as Readonly<Record<string, unknown>>
240 const agentId = e.agentId
241 const startedAtMs = await $.clock.now()
242 const usage = await $.session.usage()
243 const usd = usage.cost?.usd
244
245 const marker = tool === 'Bash' && typeof input.command === 'string' ? parseMarker(input.command) : null
246 if (marker !== null) {
247 const said =
248 marker.verb === 'end' ? 'Task ended.' : `Task "${marker.name}" started with a budget of ${money(marker.budgetUsd ?? limits.taskBudgetUsd)}.`
249 await update($, book, b => {
250 const charged = bookCost(normalize(b), usd, startedAtMs, agentId)
251 return marker.verb === 'end'
252 ? endTask(charged)
253 : startTask(charged, marker.name, marker.budgetUsd ?? limits.taskBudgetUsd, startedAtMs)[0]
254 })
255 await writeLedger($)
256 return { result: { stdout: said, stderr: '', interrupted: false }, text: said } as never
257 }
258
259 const fp = fingerprint(tool, input)
260 const base = { at: startedAtMs, tool: toolLabel(tool), summary: summarize(tool, input, cwd, home), agent: agentId }
261
262 let verdict: ReturnType<typeof check>[1] = null
263 await update($, book, b => {
264 const charged = noteAgentCall(setLimits(bookCost(normalize(b), usd, startedAtMs, agentId), usage.rateLimits, startedAtMs), agentId, startedAtMs)
265 const [checked, v] = check(charged, fp, limits, startedAtMs)
266 verdict = v
267 return checked
268 })
269
270 const stop = verdict as ReturnType<typeof check>[1]
271 if (stop !== null) {
272 await update($, book, b => {
273 const cur = normalize(b)
274 const task = cur.tasks.find(k => k.id === cur.active)
275 const blocked = record(cur, { ...base, ms: 0, outcome: 'blocked', note: stop.kind }, fp)
276 // A task that stays halted is one trip, not one per call that bumps into it.
277 const last = cur.trips[cur.trips.length - 1]
278 if (stop.kind === 'halted' && last !== undefined && last.taskId === task?.id && last.resolved === undefined) return blocked
279 return addTrip(blocked, {
280 at: startedAtMs,
281 kind: stop.kind,
282 task: task?.name ?? 'general',
283 taskId: task?.id,
284 tool: base.tool,
285 summary: base.summary,
286 reason: stop.short,
287 })
288 })
289 $.ui.toast(`⊘ TokenMunim: ${stop.short}`)
290 alarm($, `circuit tripped · ${stop.kind} · ${stop.short}`)
291 await writeLedger($)
292 return { deny: `TokenMunim circuit breaker (${stop.kind}): ${stop.reason}` }
293 }
294
295 const ran = await next(e)
296 const ms = (await $.clock.now()) - startedAtMs
297 const outcome = ran.deny !== undefined || ran.isError === true ? 'fail' : 'ok'
298 await update($, book, b => record(normalize(b), { ...base, ms, outcome }, fp))
299 if (outcome === 'ok') alarm($, undefined)
300 return ran
301 })
302
303 on('turn.step', async function* ($, e, next) {
304 const result = yield* next(e)
305 if (result.usage) await noteStep($, result.usage, e.agentId)
306 return result
307 })
308
309 on('turn.complete', async ($, e, next) => {
310 const now = await $.clock.now()
311 const usage = await $.session.usage()
312 const b = await update($, book, cur => setLimits(bookCost(normalize(cur), usage.cost?.usd, now, e.agentId), usage.rateLimits, now))
313 await refreshAgents($, now)
314 const saved = (await $.store.get(HISTORY_KEY)) as SessionSummary[] | undefined
315 const others = (saved ?? []).filter(s => s.startedAt !== usage.startedAt)
316 await $.store.set(HISTORY_KEY, [...others, summary(normalize(b), usage.startedAt)].slice(-50))
317 await writeLedger($)
318 return next(e)
319 })
320
321 on('command.run', { command: 'munim' }, async ($, e) => {
322 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
323 const now = await $.clock.now()
324
325 if (verb === '' || verb === 'bar') {
326 await update($, bar, cur => ({ ...cur, shown: true }))
327 return { text: 'TokenMunim is on for this session. Click the TokenMunim button at the end of the line under the prompt to open the dashboard, and click it again to close it.' }
328 }
329 if (verb === 'open' || verb === 'pane') {
330 await setPane($, true)
331 return { text: 'TokenMunim dashboard is open.' }
332 }
333 if (verb === 'close') {
334 await setPane($, false)
335 return { text: 'TokenMunim dashboard is closed. Click the TokenMunim button under the prompt to bring it back.' }
336 }
337 if (verb === 'off') {
338 await setPane($, false)
339 await update($, bar, () => ({ shown: false, open: false }))
340 return { text: 'TokenMunim is out of sight for this session, and still protecting it. Run /munim to bring the button back.' }
341 }
342 if (verb === 'tab') {
343 const tab = TABS.find(t => t === (rest[0] ?? '').toLowerCase())
344 if (tab === undefined) return { text: `Tabs: ${TABS.join(', ')}.` }
345 await update($, view, cur => ({ ...migrateView(cur), tab, entry: null }))
346 await setPane($, true)
347 return { text: `TokenMunim is showing ${tab}.` }
348 }
349 if (verb === 'fold' || verb === 'unfold') {
350 const id = (rest[0] ?? '').toLowerCase()
351 if (!SECTIONS.includes(id)) return { text: `Sections: ${SECTIONS.join(', ')}.` }
352 await update($, view, cur => toggleSection({ ...migrateView(cur) }, id, verb === 'unfold'))
353 await setPane($, true)
354 return { text: `${verb === 'fold' ? 'Folded' : 'Opened'} ${id}.` }
355 }
356 if (verb === 'task') {
357 const name = rest.join(' ').trim() || 'task'
358 const usd = await costNow($)
359 await update($, book, b => startTask(bookCost(normalize(b), usd, now), name, limits.taskBudgetUsd, now)[0])
360 alarm($, undefined)
361 await writeLedger($)
362 return { text: `Task "${name}" started with a budget of ${money(limits.taskBudgetUsd)}.` }
363 }
364 if (verb === 'end') {
365 await update($, book, b => endTask(normalize(b)))
366 await writeLedger($)
367 return { text: 'Task ended.' }
368 }
369 if (verb === 'reset') {
370 await update($, book, () => emptyBook())
371 await update($, view, () => DEFAULT_VIEW)
372 alarm($, undefined)
373 return { text: 'TokenMunim book reset for this session.' }
374 }
375 if (verb === 'ledger' || verb === 'export') {
376 const file = await writeLedger($)
377 if (file === undefined) return { text: 'The ledger file could not be written here.' }
378 if (verb !== 'export') await $.process.run(['open', file])
379 return { text: `Ledger written to ${file}` }
380 }
381
382 const usage = await $.session.usage()
383 const b = normalize(await update($, book, cur => bookCost(normalize(cur), usage.cost?.usd, now)))
384 const saved = ((await $.store.get(HISTORY_KEY)) as SessionSummary[] | undefined) ?? []
385 const file = await writeLedger($)
386 if (verb === 'statement') return { text: statement(b, limits, saved.filter(s => s.startedAt !== usage.startedAt), file) }
387 return { text: `Unknown option "${verb}". Try: open, close, off, statement, ledger, tab <name>, fold <section>, unfold <section>, task <name>, end, reset.` }
388 })
389
390 on('ui.close', async ($, e, next) => {
391 const done = await next(e)
392 if (e.id === PANE) await update($, bar, cur => ({ ...cur, open: false }))
393 return done
394 })
395
396 // The button lives at the end of the line under the prompt, where nothing
397 // can fold it away, and only once the person has asked for it.
398 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
399 const shown = await read($, bar)
400 if (!shown.shown) return next(e)
401 return drawBar($.ui.resolve(e), shown.open, e.props.modes, () => void togglePane($))
402 })
403
404 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
405 const kit = $.ui.resolve(e)
406 // A reload forgets where the ledger lives; find it again on the first draw.
407 if (ledgerFile === undefined) await locate($)
408 const b = normalize(await read($, book))
409 const v: View = migrateView(await read($, view))
410 const now = await $.clock.now()
411 const rows = e.props.scroll?.bodyRows ?? (e.viewport?.rows ?? 50) - 6
412 return drawPane(
413 kit,
414 { book: b, view: v, now, limits, width: e.props.bodyColumns, rows, hasFile: ledgerFile !== undefined, startedAt },
415 {
416 setTab: tab => void update($, view, cur => ({ ...migrateView(cur), tab, entry: null })),
417 toggle: (id, isFolded) => void update($, view, cur => toggleSection({ ...migrateView(cur) }, id, isFolded)),
418 drill: task => void update($, view, cur => ({ ...migrateView(cur), task, tab: task === null ? cur.tab : 'activity', entry: null })),
419 expand: entry => void update($, view, cur => ({ ...migrateView(cur), entry })),
420 openLedger: () => void openLedger($),
421 raise: (taskId, by) => void decide($, 'raise', taskId, by),
422 allow: () => void decide($, 'allow'),
423 skip: taskId => void decide($, 'skip', taskId),
424 },
425 )
426 })
427}
428hooks/ledger.ts 742 lines1// The munim's arithmetic: pure functions over a Book, so every rule is testable
2// without a session. register.tsx wires them to events, pane.tsx draws them.
3
4import type {
5 AgentLedger,
6 Book,
7 Entry,
8 Task,
9 LimitSample,
10 Outcome,
11 RateWindow,
12 Sample,
13 SessionSummary,
14 TokenMix,
15 TokenSample,
16 Trip,
17 TripKind,
18} from '../types'
19
20export type Limits = {
21 taskBudgetUsd: number
22 sessionBudgetUsd: number
23 loopLimit: number
24 burnLimitUsdPerMin: number
25}
26
27export type Verdict = { kind: TripKind; reason: string; short: string }
28
29// A reply's usage as the API reports it.
30export type Usage = {
31 input_tokens: number
32 output_tokens: number
33 cache_read_input_tokens: number
34 cache_creation_input_tokens: number
35 model?: string
36}
37
38export const GENERAL = 'general'
39export const MAIN = 'main'
40export const MAX_ENTRIES = 2000
41export const MAX_TRIPS = 100
42export const MAX_AGENTS = 50
43export const SAMPLE_KEEP_MS = 30 * 60_000
44export const BURN_WINDOW_MS = 120_000
45export const BURN_PAUSE_MS = 120_000
46export const RUNWAY_WINDOW_MS = 20 * 60_000
47
48const emptyMix = (): TokenMix => ({ input: 0, cacheRead: 0, cacheWrite: 0, output: 0 })
49
50export const emptyBook = (): Book => ({
51 v: 6,
52 tasks: [],
53 entries: [],
54 trips: [],
55 agents: [],
56 active: null,
57 lastUsd: null,
58 samples: [],
59 tokens: 0,
60 tokenSamples: [],
61 mix: emptyMix(),
62 rateLimits: [],
63 limitSamples: {},
64 streaks: {},
65 burnPausedUntil: 0,
66 passes: 0,
67})
68
69// A book as older versions saved it. Before version 5 a task was a "khata",
70// an activity entry pointed at its "khata", and a trip named its "khata".
71type LegacyTask = Omit<Task, 'tokens'> & { tokens?: number }
72type OlderBook = Partial<Omit<Book, 'v' | 'tasks' | 'entries' | 'trips'>> & {
73 v?: number
74 // An estimate of money saved, dropped so that every number shown is a fact.
75 saved?: number
76 tasks?: LegacyTask[]
77 khatas?: LegacyTask[]
78 entries?: (Omit<Entry, 'task'> & { task?: string; khata?: string })[]
79 trips?: (Omit<Trip, 'task'> & { task?: string; khata?: string; khataId?: string })[]
80}
81
82// Brings a book written by an older version up to this one. Version 2 took the
83// budget off the general task (lifting a budget halt on it), version 3 counts
84// tokens, version 4 adds agents, the token mix and the limit history, and
85// version 5 names things in plain words: tasks, activity, the ledger, and
86// version 6 carries those words into alert text saved before them.
87export const normalize = (book: OlderBook | Book | undefined | null): Book => {
88 if (book && book.v === 6) return book as Book
89 const src = (book ?? {}) as OlderBook
90 const { khatas: _oldTasks, saved: _oldEstimate, ...current } = src
91 const base = { ...emptyBook(), ...current, v: 6 as const }
92 const tasks = (src.tasks ?? src.khatas ?? []).map(t => {
93 const counted: Task = { ...t, tokens: t.tokens ?? 0 }
94 return t.id === GENERAL && (src.v ?? 1) < 2
95 ? { ...counted, budgetUsd: 0, status: src.active === GENERAL ? 'open' : 'closed', haltReason: undefined }
96 : counted
97 })
98 const entries: Entry[] = (src.entries ?? []).map(({ khata, ...e }) => ({ ...e, task: e.task ?? khata ?? GENERAL }))
99 // Alert text saved before version 5 spoke of khatas too.
100 const plain = (text: string) => text.replace(/\bKhata\b/g, 'Task').replace(/\bkhatas\b/g, 'tasks').replace(/\bkhata\b/g, 'task')
101 const trips: Trip[] = (src.trips ?? []).map(({ khata, khataId, ...t }) => ({
102 ...t,
103 task: t.task ?? khata ?? 'general',
104 taskId: t.taskId ?? khataId,
105 reason: plain(t.reason),
106 }))
107 return {
108 ...base,
109 tasks,
110 entries,
111 trips,
112 agents: src.agents ?? [],
113 tokens: src.tokens ?? 0,
114 tokenSamples: src.tokenSamples ?? [],
115 mix: src.mix ?? emptyMix(),
116 rateLimits: src.rateLimits ?? [],
117 limitSamples: src.limitSamples ?? {},
118 passes: src.passes ?? 0,
119 }
120}
121
122export const limitsFrom = (options: Readonly<Record<string, unknown>>): Limits => {
123 const num = (key: string, fallback: number) => {
124 const value = Number(options[key])
125 return Number.isFinite(value) && value > 0 ? value : fallback
126 }
127 return {
128 taskBudgetUsd: num('taskBudgetUsd', 1),
129 sessionBudgetUsd: num('sessionBudgetUsd', 20),
130 loopLimit: Math.max(2, Math.round(num('loopLimit', 3))),
131 burnLimitUsdPerMin: num('burnLimitUsdPerMin', 2),
132 }
133}
134
135export const money = (n: number) => `$${n.toFixed(2)}`
136
137// ---- tasks ----------------------------------------------------------------
138
139const newTask = (id: string, name: string, budgetUsd: number, now: number): Task => ({
140 id,
141 name,
142 status: 'open',
143 usd: 0,
144 tokens: 0,
145 budgetUsd,
146 calls: 0,
147 fails: 0,
148 blocked: 0,
149 openedAt: now,
150})
151
152const withTask = (book: Book, id: string, fn: (k: Task) => Task): Book => ({
153 ...book,
154 tasks: book.tasks.map(k => (k.id === id ? fn(k) : k)),
155})
156
157// The task new work is booked to: the open one, else the general task,
158// which has no budget of its own and only answers to the session budget.
159export const activeTask = (book: Book, now: number): [Book, Task] => {
160 const open = book.tasks.find(k => k.id === book.active)
161 if (open) return [book, open]
162 const general = book.tasks.find(k => k.id === GENERAL)
163 if (general) return [{ ...book, active: GENERAL }, general]
164 const made = newTask(GENERAL, 'general', 0, now)
165 return [{ ...book, active: GENERAL, tasks: [...book.tasks, made] }, made]
166}
167
168export const haltedNamed = (book: Book, name: string): Task | undefined =>
169 book.tasks.find(k => k.status === 'halted' && k.name.toLowerCase() === name.toLowerCase())
170
171export const startTask = (book: Book, name: string, budgetUsd: number, now: number): [Book, Task] => {
172 // Opening the task that is already open carries on with it.
173 const current = book.tasks.find(k => k.id === book.active)
174 if (current && current.status === 'open' && current.name === name) return [book, current]
175 const closed = endTask(book)
176 const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'task'
177 const id = `${slug}-${closed.tasks.length + 1}`
178 const made = newTask(id, name, budgetUsd, now)
179 return [{ ...closed, active: id, tasks: [...closed.tasks, made] }, made]
180}
181
182// Closes the open task. Only one task is ever open, so any other left open
183// (by a book from an older version) is closed with it.
184export const endTask = (book: Book): Book => {
185 if (book.active === null && !book.tasks.some(k => k.status === 'open')) return book
186 return { ...book, active: null, tasks: book.tasks.map(k => (k.status === 'open' ? { ...k, status: 'closed' } : k)) }
187}
188
189// ---- agents ----------------------------------------------------------------
190
191const newAgent = (id: string, now: number): AgentLedger => ({
192 id,
193 name: id === MAIN ? 'main' : 'subagent',
194 usd: 0,
195 tokens: 0,
196 mix: emptyMix(),
197 steps: 0,
198 calls: 0,
199 firstAt: now,
200 lastAt: now,
201})
202
203const withAgent = (book: Book, agentId: string | undefined, now: number, fn: (a: AgentLedger) => AgentLedger): Book => {
204 const id = agentId ?? MAIN
205 const found = book.agents.some(a => a.id === id)
206 const agents = found ? book.agents : [...book.agents, newAgent(id, now)].slice(-MAX_AGENTS)
207 return { ...book, agents: agents.map(a => (a.id === id ? fn(a) : a)) }
208}
209
210// Names and states from the engine's list of agents.
211export const nameAgents = (book: Book, infos: readonly { id: string; description: string; type: string; status: string }[]): Book => ({
212 ...book,
213 agents: book.agents.map(a => {
214 const info = infos.find(i => i.id === a.id)
215 return info ? { ...a, name: info.description || info.type || a.name, type: info.type, status: info.status } : a
216 }),
217})
218
219export const noteAgentCall = (book: Book, agentId: string | undefined, now: number): Book =>
220 withAgent(book, agentId, now, a => ({ ...a, calls: a.calls + 1, lastAt: now }))
221
222// ---- cost and tokens -------------------------------------------------------
223
224// Books what the session spent since the last reading to the active task and
225// to the loop whose reply or call brought the reading.
226export const bookCost = (book: Book, usd: number | undefined, now: number, agentId?: string): Book => {
227 if (usd === undefined) return book
228 const samples = [...book.samples, { at: now, usd }].filter(s => now - s.at <= SAMPLE_KEEP_MS).slice(-800)
229 if (book.lastUsd === null) return { ...book, lastUsd: usd, samples }
230 const delta = Math.max(0, usd - book.lastUsd)
231 const [withActive, task] = activeTask(book, now)
232 const charged = withTask(withActive, task.id, k => ({ ...k, usd: k.usd + delta }))
233 const byAgent = delta > 0 ? withAgent(charged, agentId, now, a => ({ ...a, usd: a.usd + delta, lastAt: now })) : charged
234 return { ...byAgent, lastUsd: usd, samples }
235}
236
237const addMix = (a: TokenMix, b: TokenMix): TokenMix => ({
238 input: a.input + b.input,
239 cacheRead: a.cacheRead + b.cacheRead,
240 cacheWrite: a.cacheWrite + b.cacheWrite,
241 output: a.output + b.output,
242})
243
244export const mixOf = (u: Usage): TokenMix => ({
245 input: u.input_tokens,
246 cacheRead: u.cache_read_input_tokens,
247 cacheWrite: u.cache_creation_input_tokens,
248 output: u.output_tokens,
249})
250
251export const mixTotal = (m: TokenMix): number => m.input + m.cacheRead + m.cacheWrite + m.output
252
253// The share of input the prompt cache served: high is cheap.
254export const cacheHit = (m: TokenMix): number => {
255 const input = m.input + m.cacheRead + m.cacheWrite
256 return input > 0 ? m.cacheRead / input : 0
257}
258
259// Books one reply: its tokens to the session, the active task and its loop.
260export const bookTokens = (book: Book, usage: Usage, now: number, agentId?: string): Book => {
261 const mix = mixOf(usage)
262 const tokens = mixTotal(mix)
263 if (!(tokens > 0)) return book
264 const [b, task] = activeTask(book, now)
265 const total = b.tokens + tokens
266 const first: TokenSample[] = b.tokenSamples.length === 0 ? [{ at: now - 1, n: b.tokens }] : []
267 const tokenSamples = [...b.tokenSamples, ...first, { at: now, n: total }].filter(s => now - s.at <= SAMPLE_KEEP_MS).slice(-800)
268 const counted = withTask(b, task.id, k => ({ ...k, tokens: k.tokens + tokens }))
269 const byAgent = withAgent(counted, agentId, now, a => ({
270 ...a,
271 tokens: a.tokens + tokens,
272 mix: addMix(a.mix, mix),
273 steps: a.steps + 1,
274 model: usage.model ?? a.model,
275 lastAt: now,
276 }))
277 return { ...byAgent, tokens: total, tokenSamples, mix: addMix(b.mix, mix) }
278}
279
280// What was spent between t0 and t1. A cost reading only arrives with the next
281// tool call or reply, so each delta is spread over the time it was really
282// spent in; a long think followed by one call is not a spike.
283export const spendBetween = (samples: readonly Sample[], t0: number, t1: number): number => {
284 let spent = 0
285 for (let i = 1; i < samples.length; i++) {
286 const prev = samples[i - 1]
287 const cur = samples[i]
288 if (!prev || !cur) continue
289 const delta = Math.max(0, cur.usd - prev.usd)
290 if (delta === 0) continue
291 const start = prev.at
292 const end = Math.max(cur.at, prev.at + 1)
293 const overlap = Math.min(end, t1) - Math.max(start, t0)
294 if (overlap > 0) spent += (delta * overlap) / (end - start)
295 }
296 return spent
297}
298
299export const burnRate = (book: Book, now: number): number =>
300 (spendBetween(book.samples, now - BURN_WINDOW_MS, now) / BURN_WINDOW_MS) * 60_000
301
302// The burn chart's columns, oldest first, each the spend rate in USD per minute.
303export const burnSeries = (book: Book, now: number, columns: number, bucketMs: number): number[] =>
304 Array.from({ length: columns }, (_, i) => {
305 const t1 = now - (columns - 1 - i) * bucketMs
306 return (spendBetween(book.samples, t1 - bucketMs, t1) / bucketMs) * 60_000
307 })
308
309// Tokens per minute, column by column, for the token sparkline.
310export const tokenSeries = (book: Book, now: number, columns: number, bucketMs: number): number[] => {
311 const asSamples: Sample[] = book.tokenSamples.map(s => ({ at: s.at, usd: s.n }))
312 return Array.from({ length: columns }, (_, i) => {
313 const t1 = now - (columns - 1 - i) * bucketMs
314 return (spendBetween(asSamples, t1 - bucketMs, t1) / bucketMs) * 60_000
315 })
316}
317
318export const tokenRate = (book: Book, now: number): number => {
319 const asSamples: Sample[] = book.tokenSamples.map(s => ({ at: s.at, usd: s.n }))
320 return (spendBetween(asSamples, now - BURN_WINDOW_MS, now) / BURN_WINDOW_MS) * 60_000
321}
322
323// ---- plan limits -----------------------------------------------------------
324
325export const setLimits = (book: Book, windows: readonly RateWindow[] | undefined, now?: number): Book => {
326 if (windows === undefined || windows.length === 0) return book
327 const rateLimits = windows.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt }))
328 if (now === undefined) return { ...book, rateLimits }
329 const limitSamples: Record<string, LimitSample[]> = { ...book.limitSamples }
330 for (const w of rateLimits) {
331 const kept = (limitSamples[w.kind] ?? []).filter(s => now - s.at <= RUNWAY_WINDOW_MS)
332 const last = kept[kept.length - 1]
333 // A window that reset starts its history over.
334 const fresh = last !== undefined && w.percentUsed < last.pct ? [] : kept
335 limitSamples[w.kind] = [...fresh, { at: now, pct: w.percentUsed }].slice(-200)
336 }
337 return { ...book, rateLimits, limitSamples }
338}
339
340export type Runway =
341 | { state: 'measuring' }
342 | { state: 'steady' }
343 | { state: 'lasts'; minutes: number }
344 | { state: 'runs-out'; minutes: number }
345
346// How long a plan window lasts at the pace of the last twenty minutes, set
347// against when it resets: whether this pace makes it to the reset or not.
348export const runway = (book: Book, kind: string, now: number): Runway => {
349 const window = book.rateLimits.find(w => w.kind === kind)
350 const samples = (book.limitSamples[kind] ?? []).filter(s => now - s.at <= RUNWAY_WINDOW_MS)
351 const first = samples[0]
352 const last = samples[samples.length - 1]
353 if (!window || !first || !last || last.at - first.at < 120_000) return { state: 'measuring' }
354 const perMin = ((last.pct - first.pct) / (last.at - first.at)) * 60_000
355 if (!(perMin > 0)) return { state: 'steady' }
356 const minutes = Math.max(0, (100 - window.percentUsed) / perMin)
357 const resetIn = window.resetsAt ? (Date.parse(window.resetsAt) - now) / 60_000 : Number.POSITIVE_INFINITY
358 return minutes < resetIn ? { state: 'runs-out', minutes } : { state: 'lasts', minutes }
359}
360
361// ---- describing calls ------------------------------------------------------
362
363// What identifies "the same action": the tool and its main argument, with
364// long digit runs (timestamps, ports, ids) folded so they don't hide a loop.
365export const mainArg = (input: Readonly<Record<string, unknown>>): string => {
366 for (const key of ['command', 'file_path', 'notebook_path', 'url', 'pattern', 'query', 'path', 'prompt', 'description', 'skill']) {
367 const value = input[key]
368 if (typeof value === 'string' && value.trim() !== '') return value
369 }
370 return ''
371}
372
373export const fingerprint = (tool: string, input: Readonly<Record<string, unknown>>): string =>
374 `${tool}:${mainArg(input).trim().replace(/\s+/g, ' ').replace(/\d{4,}/g, '#').slice(0, 240)}`
375
376export const shortPath = (path: string, cwd: string | undefined, home: string | undefined): string => {
377 let p = path
378 if (cwd && (p === cwd || p.startsWith(`${cwd}/`))) p = p.slice(cwd.length + 1) || '.'
379 else if (home && p.startsWith(`${home}/`)) p = `~/${p.slice(home.length + 1)}`
380 const parts = p.split('/')
381 return parts.length > 3 ? `…/${parts.slice(-2).join('/')}` : p
382}
383
384// The activity's words for a call: what it was for, not its raw arguments.
385export const summarize = (
386 tool: string,
387 input: Readonly<Record<string, unknown>>,
388 cwd?: string,
389 home?: string,
390): string => {
391 const str = (key: string) => (typeof input[key] === 'string' ? (input[key] as string).trim() : '')
392 const oneLine = (s: string) => s.replace(/\s+/g, ' ')
393 if (tool === 'Bash') return oneLine(str('description') || str('command'))
394 if (['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit'].includes(tool)) {
395 const path = str('file_path') || str('notebook_path')
396 return path ? shortPath(path, cwd, home) : ''
397 }
398 if (tool === 'Grep' || tool === 'Glob') return oneLine(`${str('pattern')}${str('path') ? ` in ${shortPath(str('path'), cwd, home)}` : ''}`)
399 if (tool === 'WebFetch') return str('url').replace(/^https?:\/\//, '')
400 if (tool === 'Agent' || tool === 'Task') return oneLine(str('description') || str('prompt'))
401 return oneLine(mainArg(input))
402}
403
404export const toolLabel = (tool: string): string => {
405 const names: Record<string, string> = {
406 MultiEdit: 'Edit', WebFetch: 'Fetch', WebSearch: 'Web', ToolSearch: 'Tools',
407 TodoWrite: 'Todo', NotebookEdit: 'Note', Task: 'Agent',
408 }
409 const mcp = /^mcp__[^_]+(?:_[^_]+)*__(.+)$/.exec(tool)
410 return (names[tool] ?? (mcp?.[1] ?? tool)).slice(0, 6)
411}
412
413// ---- the circuit breaker ---------------------------------------------------
414
415// Decides whether a call may run. Returns the book (a task may be halted, a
416// burn pause started, a pass used up) and a verdict when the call must not run.
417export const check = (book: Book, fp: string, limits: Limits, now: number): [Book, Verdict | null] => {
418 const [b, task] = activeTask(book, now)
419 const [decided, verdict] = decide(b, task, fp, limits, now)
420 if (verdict === null || b.passes <= 0) return [decided, verdict]
421 // The person let one call through from the pane: this is it.
422 const streaks = { ...b.streaks }
423 delete streaks[task.id]
424 return [{ ...b, passes: b.passes - 1, streaks, burnPausedUntil: now + BURN_PAUSE_MS }, null]
425}
426
427const decide = (b: Book, task: Task, fp: string, limits: Limits, now: number): [Book, Verdict | null] => {
428 const leave = 'Leave this task: do not reopen it under another task. Tell the user it hit its budget, then open a task for the next task with the start_task tool (load it with ToolSearch if it is not listed), or stop.'
429
430 const spent = sessionUsd(b)
431 if (spent >= limits.sessionBudgetUsd) {
432 return [b, {
433 kind: 'session',
434 short: `Session budget of ${money(limits.sessionBudgetUsd)} is spent. Agent stopped.`,
435 reason: `The session budget of ${money(limits.sessionBudgetUsd)} is spent (${money(spent)}). Stop and report to the user.`,
436 }]
437 }
438 if (task.status === 'halted') {
439 return [b, {
440 kind: 'halted',
441 short: `Task "${task.name}" is halted. The agent has to move on.`,
442 reason: `Task "${task.name}" is halted: ${task.haltReason ?? 'over budget'}. ${leave}`,
443 }]
444 }
445 if (task.budgetUsd > 0 && task.usd >= task.budgetUsd) {
446 const why = `${money(task.usd)} of its ${money(task.budgetUsd)} budget`
447 const halted = withTask(b, task.id, k => ({ ...k, status: 'halted', haltReason: `spent ${why}` }))
448 return [halted, {
449 kind: 'budget',
450 short: `"${task.name}" spent ${why}. Halted.`,
451 reason: `Task "${task.name}" spent ${why} and is now halted. ${leave}`,
452 }]
453 }
454 const streak = b.streaks[task.id]
455 if (streak && streak.fp === fp && streak.n >= limits.loopLimit) {
456 return [b, {
457 kind: 'loop',
458 short: `Same failing action ${streak.n} times in a row. Retry blocked.`,
459 reason: `This exact action has failed ${streak.n} times in a row, so retrying it is blocked. Read the error, change the approach, or skip this step.`,
460 }]
461 }
462 const rate = burnRate(b, now)
463 if (rate > limits.burnLimitUsdPerMin && now >= b.burnPausedUntil) {
464 return [{ ...b, burnPausedUntil: now + BURN_PAUSE_MS }, {
465 kind: 'burn',
466 short: `Burning ${money(rate)}/min, over the ${money(limits.burnLimitUsdPerMin)}/min limit. Paused.`,
467 reason: `Spending ${money(rate)}/min, over the ${money(limits.burnLimitUsdPerMin)}/min limit. Work leaner before continuing: sample big files and outputs instead of reading them whole.`,
468 }]
469 }
470 return [b, null]
471}
472
473export const addTrip = (book: Book, trip: Trip): Book => ({ ...book, trips: [...book.trips, trip].slice(-MAX_TRIPS) })
474
475// An alert still worth the person's attention: unresolved, and either recent
476// or about a task that is still halted.
477export const isPending = (book: Book, trip: Trip, now: number): boolean => {
478 if (trip.resolved !== undefined) return false
479 const task = book.tasks.find(t => t.id === trip.taskId) ?? book.tasks.find(t => t.name === trip.task)
480 return now - trip.at < 10 * 60_000 || task?.status === 'halted'
481}
482
483// What needs the person, newest first: each halted task once, with its latest
484// alert, and each recent loop or burn. The badge counts exactly these.
485export const pendingTrips = (book: Book, now: number): Trip[] => {
486 const seen = new Set<string>()
487 const out: Trip[] = []
488 for (let i = book.trips.length - 1; i >= 0; i--) {
489 const t = book.trips[i]
490 if (t === undefined || !isPending(book, t, now)) continue
491 const key = t.kind === 'budget' || t.kind === 'halted' ? `task:${t.taskId ?? t.task}` : `alert:${i}`
492 if (seen.has(key)) continue
493 seen.add(key)
494 out.push(t)
495 }
496 return out
497}
498
499export const pendingCount = (book: Book, now: number): number => pendingTrips(book, now).length
500
501// A reply can carry a task past its budget between two tool calls. Halt it
502// then, so the very next call is stopped and the overspend shows as a trip.
503export const haltIfOver = (book: Book, now: number): Book => {
504 const task = book.tasks.find(k => k.id === book.active)
505 if (!task || task.status !== 'open' || !(task.budgetUsd > 0) || task.usd < task.budgetUsd) return book
506 const why = `${money(task.usd)} of its ${money(task.budgetUsd)} budget`
507 const halted = withTask(book, task.id, k => ({ ...k, status: 'halted', haltReason: `spent ${why}` }))
508 return addTrip(halted, {
509 at: now,
510 kind: 'budget',
511 task: task.name,
512 taskId: task.id,
513 tool: 'reply',
514 summary: 'the reply that crossed the budget',
515 reason: `"${task.name}" spent ${why}. Halted.`,
516 })
517}
518
519// ---- what the person can do from the pane ----------------------------------
520
521const resolveLatest = (book: Book, taskId: string | undefined, text: string): Book => {
522 const index = [...book.trips].reverse().findIndex(t => t.resolved === undefined && (taskId === undefined || t.taskId === taskId))
523 if (index < 0) return book
524 const at = book.trips.length - 1 - index
525 return { ...book, trips: book.trips.map((t, i) => (i === at ? { ...t, resolved: text } : t)) }
526}
527
528// Gives a task more budget; a task halted for its budget can carry on.
529export const raiseBudget = (book: Book, taskId: string, by: number): Book => {
530 const task = book.tasks.find(k => k.id === taskId)
531 if (!task) return book
532 const budgetUsd = Math.round((task.budgetUsd + by) * 100) / 100
533 const lifted = withTask(book, taskId, k => ({
534 ...k,
535 budgetUsd,
536 status: k.status === 'halted' ? (book.active === k.id ? 'open' : 'closed') : k.status,
537 haltReason: k.status === 'halted' ? undefined : k.haltReason,
538 }))
539 return resolveLatest(lifted, taskId, `budget raised to ${money(budgetUsd)} by you`)
540}
541
542// Lets the next call through whatever circuit stands in its way, once.
543export const allowOnce = (book: Book): Book =>
544 resolveLatest({ ...book, passes: book.passes + 1, burnPausedUntil: 0 }, undefined, 'one call allowed by you')
545
546// Stops the agent's work on a task: its next call is told to move on.
547export const skipTask = (book: Book, taskId: string): Book => {
548 const task = book.tasks.find(k => k.id === taskId)
549 if (!task || task.id === GENERAL) return book
550 const halted = withTask(book, taskId, k => ({ ...k, status: 'halted', haltReason: 'skipped by you' }))
551 return resolveLatest(halted, taskId, 'task skipped by you')
552}
553
554// ---- the activity --------------------------------------------------------------
555
556// Writes one entry into the ledger and keeps the task's counters and loop streak.
557export const record = (book: Book, entry: Omit<Entry, 'task'>, fp: string): Book => {
558 const [b, task] = activeTask(book, entry.at)
559 const outcome: Outcome = entry.outcome
560 const prior = b.streaks[task.id]
561 const streak =
562 outcome === 'ok' ? undefined
563 : outcome === 'fail' ? (prior && prior.fp === fp ? { fp, n: prior.n + 1 } : { fp, n: 1 })
564 : prior
565 const streaks = { ...b.streaks }
566 if (streak) streaks[task.id] = streak
567 else delete streaks[task.id]
568
569 const counted = withTask(b, task.id, k => ({
570 ...k,
571 calls: outcome === 'blocked' ? k.calls : k.calls + 1,
572 fails: outcome === 'fail' ? k.fails + 1 : k.fails,
573 blocked: outcome === 'blocked' ? k.blocked + 1 : k.blocked,
574 }))
575 const entries = [...counted.entries, { ...entry, task: task.id }].slice(-MAX_ENTRIES)
576 return { ...counted, entries, streaks }
577}
578
579export const sessionUsd = (book: Book): number => book.tasks.reduce((sum, k) => sum + k.usd, 0)
580
581export const summary = (book: Book, startedAt: number): SessionSummary => ({
582 startedAt,
583 usd: sessionUsd(book),
584 tokens: book.tokens,
585 tasks: book.tasks.map(k => ({ name: k.name, status: k.status, usd: k.usd, calls: k.calls })),
586})
587
588// ---- words and numbers -----------------------------------------------------
589
590export const duration = (ms: number): string => {
591 if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`
592 if (ms < 60_000) return `${Math.round(ms / 1000)}s`
593 const m = Math.floor(ms / 60_000)
594 return m < 60 ? `${m}m${String(Math.round((ms % 60_000) / 1000)).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
595}
596
597export const span = (minutes: number): string => {
598 if (!Number.isFinite(minutes)) return 'never'
599 const m = Math.max(0, Math.round(minutes))
600 if (m < 60) return `${m}m`
601 const h = Math.floor(m / 60)
602 if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}m`
603 return `${Math.floor(h / 24)}d${h % 24}h`
604}
605
606export const compactTokens = (n: number): string => {
607 if (n < 1_000) return String(Math.round(n))
608 if (n < 100_000) return `${(n / 1_000).toFixed(1)}k`
609 if (n < 1_000_000) return `${Math.round(n / 1_000)}k`
610 if (n < 100_000_000) return `${(n / 1_000_000).toFixed(2)}M`
611 return `${Math.round(n / 1_000_000)}M`
612}
613
614const stamp = (ms: number) => {
615 const d = new Date(ms)
616 const pad = (n: number) => String(n).padStart(2, '0')
617 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
618}
619const clock = (ms: number) => new Date(ms).toTimeString().slice(0, 8)
620const cell = (s: string) => s.replace(/\|/g, '\\|').replace(/\n/g, ' ')
621const budgetText = (k: Task) => (k.budgetUsd > 0 ? money(k.budgetUsd) : 'none')
622const taskName = (book: Book, id: string) => book.tasks.find(k => k.id === id)?.name ?? id
623const agentName = (book: Book, id: string | undefined) => book.agents.find(a => a.id === (id ?? MAIN))?.name ?? 'main'
624const pct = (x: number) => `${Math.round(x * 100)}%`
625
626const taskTable = (book: Book): string[] => [
627 '| Task | Status | Spent | Tokens | Budget | Calls | Fails | Blocked |',
628 '|---|---|--:|--:|--:|--:|--:|--:|',
629 ...book.tasks.map(k => `| ${cell(k.name)} | ${k.status} | ${money(k.usd)} | ${compactTokens(k.tokens)} | ${budgetText(k)} | ${k.calls} | ${k.fails} | ${k.blocked} |`),
630]
631
632const agentTable = (book: Book): string[] => [
633 '| Agent | Model | Steps | Calls | Tokens | Cost (est.) | Cache hit |',
634 '|---|---|--:|--:|--:|--:|--:|',
635 ...book.agents.map(a => `| ${cell(a.name)} | ${a.model ?? ''} | ${a.steps} | ${a.calls} | ${compactTokens(a.tokens)} | ${money(a.usd)} | ${pct(cacheHit(a.mix))} |`),
636]
637
638const windowName = (kind: string) =>
639 kind === 'five_hour' ? '5 hour window' : kind === 'seven_day' ? 'Weekly window' : kind === 'spend_limit' ? 'Spend limit' : kind
640
641const limitLines = (book: Book, now: number): string[] =>
642 book.rateLimits.map(w => {
643 const r = runway(book, w.kind, now)
644 const pace = r.state === 'runs-out' ? `, runs out in ${span(r.minutes)} at this pace` : r.state === 'lasts' ? ', lasts to its reset at this pace' : ''
645 return `| ${windowName(w.kind)} | ${Math.max(0, 100 - w.percentUsed).toFixed(0)}% left${w.resetsAt ? `, resets ${stamp(Date.parse(w.resetsAt))}` : ''}${pace} |`
646 })
647
648const tripTable = (book: Book): string[] =>
649 book.trips.length === 0
650 ? ['No circuit trips.']
651 : [
652 '| Time | Circuit | Task | Action | What happened | Resolved |',
653 '|---|---|---|---|---|---|',
654 ...[...book.trips].reverse().map(t => `| ${clock(t.at)} | ${t.kind} | ${cell(t.task)} | ${cell(`${t.tool}: ${t.summary}`)} | ${cell(t.reason)} | ${cell(t.resolved ?? '')} |`),
655 ]
656
657// The short report /munim statement prints.
658export const statement = (book: Book, limits: Limits, past: readonly SessionSummary[], file?: string): string => {
659 const lines = ['## TokenMunim statement', '']
660 if (book.tasks.length === 0) {
661 lines.push('Nothing booked yet in this session.')
662 } else {
663 lines.push(
664 `**Spent** ${money(sessionUsd(book))} of ${money(limits.sessionBudgetUsd)} · **Tokens** ${compactTokens(book.tokens)} (cache hit ${pct(cacheHit(book.mix))}) · **Calls stopped** ${book.entries.filter(e => e.outcome === 'blocked').length} · **Circuit trips** ${book.trips.length}`,
665 '',
666 ...taskTable(book),
667 )
668 if (book.agents.length > 1) lines.push('', '### Agents', '', ...agentTable(book))
669 if (book.trips.length > 0) lines.push('', '### Circuit trips', '', ...tripTable(book).slice(0, 12))
670 }
671 if (past.length > 0) {
672 lines.push('', '### Earlier sessions', '', '| Started | Spent | Tasks |', '|---|--:|--:|')
673 for (const s of past.slice(-5)) lines.push(`| ${stamp(s.startedAt)} | ${money(s.usd)} | ${s.tasks.length} |`)
674 }
675 if (file) lines.push('', `Full ledger: \`${file}\``)
676 return lines.join('\n')
677}
678
679// The ledger file: the whole session's ledger, every entry, newest first.
680export const ledgerMarkdown = (
681 book: Book,
682 meta: { startedAt: number; now: number; cwd?: string; limits: Limits },
683): string => {
684 const m = book.mix
685 const lines = [
686 '# TokenMunim ledger',
687 '',
688 `Session started ${stamp(meta.startedAt)} · updated ${stamp(meta.now)}${meta.cwd ? ` · ${meta.cwd}` : ''}`,
689 '',
690 '## Summary',
691 '',
692 '| | |',
693 '|---|--:|',
694 `| Spent | ${money(sessionUsd(book))} of ${money(meta.limits.sessionBudgetUsd)} |`,
695 `| Tokens | ${compactTokens(book.tokens)} |`,
696 `| Token mix | input ${compactTokens(m.input)}, cache read ${compactTokens(m.cacheRead)}, cache write ${compactTokens(m.cacheWrite)}, output ${compactTokens(m.output)} |`,
697 `| Cache hit | ${pct(cacheHit(m))} |`,
698 ...limitLines(book, meta.now),
699 `| Calls stopped by the circuit breaker | ${book.entries.filter(e => e.outcome === 'blocked').length} |`,
700 `| Tasks | ${book.tasks.length} |`,
701 `| Agents | ${book.agents.length} |`,
702 `| Tool calls | ${book.entries.filter(e => e.outcome !== 'blocked').length} |`,
703 `| Circuit trips | ${book.trips.length} |`,
704 '',
705 '## Tasks',
706 '',
707 ...taskTable(book),
708 '',
709 '## Agents',
710 '',
711 ...(book.agents.length > 0 ? agentTable(book) : ['No replies booked yet.']),
712 '',
713 '## Circuit trips',
714 '',
715 ...tripTable(book),
716 '',
717 '## Activity',
718 '',
719 '| Time | Task | Agent | Tool | Action | Result | Took |',
720 '|---|---|---|---|---|---|--:|',
721 ...[...book.entries].reverse().map(e =>
722 `| ${clock(e.at)} | ${cell(taskName(book, e.task))} | ${cell(agentName(book, e.agent))} | ${e.tool} | ${cell(e.summary)} | ${e.outcome === 'blocked' ? `blocked (${e.note ?? 'circuit'})` : e.outcome} | ${e.outcome === 'blocked' ? '' : duration(e.ms)} |`,
723 ),
724 '',
725 ]
726 return lines.join('\n')
727}
728
729// A task marker: any agent or script can open or close a task through a
730// shell line, `munim:task <name> [budget]` or `munim:end`, even where the
731// start_task tool is not listed. TokenMunim answers it; the shell never runs it.
732export type Marker = { verb: 'task'; name: string; budgetUsd?: number } | { verb: 'end' }
733
734export const parseMarker = (command: string): Marker | null => {
735 const line = command.trim().replace(/^echo\s+/, '').replace(/^['"]|['"]$/g, '').trim()
736 if (/^munim:end$/.test(line)) return { verb: 'end' }
737 const m = /^munim:task\s+(.+?)(?:\s+\$?(\d+(?:\.\d+)?))?$/.exec(line)
738 if (!m || !m[1]) return null
739 const budget = m[2] === undefined ? undefined : Number(m[2])
740 return budget === undefined ? { verb: 'task', name: m[1] } : { verb: 'task', name: m[1], budgetUsd: budget }
741}
742hooks/pane.tsx 963 lines1// The pane: pure drawing from a book and a view. Every handler it wires comes
2// in through `act`, so this file never touches the engine itself.
3//
4// The look: filled panels instead of borders, one gold accent, pills for state
5// and actions, braille charts for density, and color only where it carries
6// meaning. Every row is laid out for the width it actually has, and nothing is
7// ever padded past its panel.
8
9import type { AgentLedger, Book, Entry, Task, Tab, Trip, View } from '../types'
10import { brailleArea, sparkline } from './chart'
11import {
12 burnRate,
13 burnSeries,
14 cacheHit,
15 compactTokens,
16 duration,
17 GENERAL,
18 MAIN,
19 isPending,
20 mixTotal,
21 money,
22 pendingCount,
23 pendingTrips,
24 runway,
25 sessionUsd,
26 span,
27 tokenRate,
28 tokenSeries,
29} from './ledger'
30import type { Limits } from './ledger'
31
32// The element constructors of the surface being drawn on.
33// eslint-disable-next-line @typescript-eslint/no-explicit-any
34export type Kit = { Box: any; Text: any; Button: any }
35
36export type PaneActions = {
37 setTab: (tab: Tab) => void
38 // `isFolded` is how the section is drawn now, which may be the pane's own doing.
39 toggle: (section: string, isFolded: boolean) => void
40 drill: (taskId: string | null) => void
41 expand: (index: number | null) => void
42 openLedger: () => void
43 raise: (taskId: string, by: number) => void
44 allow: () => void
45 skip: (taskId: string) => void
46}
47
48export type PaneData = {
49 book: Book
50 view: View
51 now: number
52 limits: Limits
53 width: number
54 rows: number
55 hasFile: boolean
56 startedAt?: number
57}
58
59export const DEFAULT_VIEW: View = { tab: 'overview', folded: ['activity', 'alerts'], opened: [], task: null, entry: null }
60
61// ---- the palette -------------------------------------------------------------
62
63const GOLD = '#E8B04B'
64const GOLD_DEEP = '#5E4719'
65const INK = '#17171B'
66const MUTED = '#9C9CA8'
67const FAINT = '#666672'
68const LINE = '#3B3B45'
69const PANEL = '#2D2D35'
70const PANEL_HI = '#383843'
71const CHIP = '#41414C'
72const GREEN = '#4ADE80'
73const GREEN_BG = '#1C3626'
74const AMBER = '#FBBF24'
75const RED = '#F87171'
76const RED_DIM = '#A04C4C'
77const RED_PANEL = '#3A2326'
78const BLUE = '#60A5FA'
79const TEAL = '#2DD4BF'
80const VIOLET = '#A78BFA'
81
82const STATUS: Record<Task['status'], { glyph: string; color: string }> = {
83 open: { glyph: '●', color: GREEN },
84 closed: { glyph: '○', color: FAINT },
85 halted: { glyph: '■', color: RED },
86}
87
88const OUTCOME: Record<Entry['outcome'], { glyph: string; color: string }> = {
89 ok: { glyph: '✓', color: GREEN },
90 fail: { glyph: '✗', color: AMBER },
91 blocked: { glyph: '⊘', color: RED },
92}
93
94const TOOL_COLOR: Record<string, string> = {
95 Bash: TEAL,
96 Read: BLUE,
97 Grep: BLUE,
98 Glob: BLUE,
99 Fetch: BLUE,
100 Web: BLUE,
101 Write: VIOLET,
102 Edit: VIOLET,
103 Note: VIOLET,
104 Agent: GOLD,
105 Tools: FAINT,
106 Todo: FAINT,
107}
108
109const WINDOW_LABEL: Record<string, string> = { five_hour: '5h', seven_day: 'week', spend_limit: 'spend' }
110
111// ---- text helpers ------------------------------------------------------------
112
113// `cut` shortens text to fit; `pad` also fills a column to its width.
114const cut = (text: string, width: number) =>
115 width <= 0 ? '' : text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
116const pad = (text: string, width: number) => cut(text, width).padEnd(Math.max(0, width))
117const lpad = (text: string, width: number) => cut(text, width).padStart(Math.max(0, width))
118const clockTime = (ms: number, short = false) => new Date(ms).toTimeString().slice(0, short ? 5 : 8)
119const dollars = (n: number) => (Number.isInteger(n) ? `$${n}` : money(n))
120const pct = (x: number) => `${Math.round(x * 100)}%`
121const hit = (x: number) => (x >= 0.995 && x < 1 ? `${(x * 100).toFixed(1)}%` : pct(x))
122const countdown = (ms: number) => (ms > 0 ? span(ms / 60_000) : 'now')
123const fill = (ratio: number, width: number) => Math.max(0, Math.min(width, Math.round(Math.max(0, Math.min(1, ratio)) * width)))
124// Budget use: a percentage up to the budget, then times over it.
125const used = (ratio: number) => (ratio < 1 ? `${Math.round(ratio * 100)}%` : `${ratio < 10 ? ratio.toFixed(1) : Math.round(ratio)}×`)
126
127// Words wrapped to `width`, at most `lines` lines; only the last line is cut.
128const wrap = (text: string, width: number, lines: number): string[] => {
129 const out: string[] = []
130 let line = ''
131 for (const word of text.split(/\s+/)) {
132 if (line === '') line = word
133 else if (line.length + 1 + word.length <= width) line = `${line} ${word}`
134 else {
135 out.push(line)
136 line = word
137 }
138 }
139 if (line !== '') out.push(line)
140 if (out.length <= lines) return out.map(l => cut(l, width))
141 const kept = out.slice(0, lines)
142 kept[lines - 1] = cut(`${kept[lines - 1]} ${out.slice(lines).join(' ')}`, width)
143 return kept
144}
145
146// A step to raise a halted task by: half its budget, at least five cents.
147export const raiseStep = (k: Task) => Math.max(0.05, Math.round(k.budgetUsd * 50) / 100)
148
149// Folds or opens a section from how it is drawn now: a section the pane folded
150// to fit opens on the first press, and stays open.
151export const toggleSection = (view: View, id: string, isFolded: boolean): View => {
152 const folded = view.folded.filter(x => x !== id)
153 const opened = (view.opened ?? []).filter(x => x !== id)
154 return isFolded ? { ...view, folded, opened: [...opened, id] } : { ...view, folded: [...folded, id], opened }
155}
156
157export function drawPane(kit: Kit, d: PaneData, act: PaneActions) {
158 const { Box, Text, Button } = kit
159 const { book: b, view, now, limits } = d
160 const W = Math.max(30, d.width)
161 const inner = W - 2
162 // Inside a panel: one cell of padding a side.
163 const C = inner - 2
164 const wide = C >= 58
165 const narrow = C < 46
166 const R = Math.max(24, d.rows)
167
168 const trip = b.trips[b.trips.length - 1]
169 const active = b.tasks.find(k => k.id === b.active)
170 const lastEntry = b.entries[b.entries.length - 1]
171 const isLive = lastEntry !== undefined && now - lastEntry.at < 90_000
172
173 // ---- building blocks -----------------------------------------------------
174
175 const pill = (text: string, color: string, bg?: string) => (
176 <Text bold color={color} backgroundColor={bg}>{` ${text} `}</Text>
177 )
178
179 const link = (key: string, label: string, onPress: () => void, dim = false) => (
180 <Box key={`${key}-box`}>
181 <Button key={key} plain dimColor={dim} label={label} hover={{ color: GOLD, underline: true }} onPress={onPress} />
182 </Box>
183 )
184
185 const action = (key: string, label: string, onPress: () => void, primary: boolean) => (
186 <Box key={`${key}-box`} backgroundColor={primary ? GOLD_DEEP : CHIP} marginRight={1}>
187 <Button key={key} plain label={` ${label} `} hover={{ backgroundColor: primary ? GOLD : MUTED, color: INK }} onPress={onPress} />
188 </Box>
189 )
190
191 const panel = (key: string, children: unknown, marginTop: number, bg: string = PANEL) => (
192 <Box key={key} flexDirection="column" marginTop={marginTop} backgroundColor={bg} paddingX={1}>
193 {children}
194 </Box>
195 )
196
197 const section = (id: string, title: string, right: unknown, isFolded: boolean, body: unknown, marginTop: number) =>
198 panel(
199 `sec-${id}`,
200 [
201 <Box key={`sec-${id}-head`} justifyContent="space-between">
202 <Box key={`fold-${id}-box`}>
203 <Button key={`fold-${id}`} plain label={`${isFolded ? '▸' : '▾'} ${title}`} hover={{ color: GOLD, underline: true }} onPress={() => act.toggle(id, isFolded)} />
204 </Box>
205 {right}
206 </Box>,
207 isFolded ? null : body,
208 ],
209 marginTop,
210 )
211
212 const muted = (text: string, room: number, color: string = MUTED) => <Text color={color}>{cut(text, Math.max(0, room))}</Text>
213
214 // ---- header and tabs -----------------------------------------------------
215
216 // The header is one row, like an app's title bar: the wordmark in two
217 // tones, the tagline when there is room, and status on the right. Nothing
218 // clickable sits up here, beside the close mark of the pane's frame: the
219 // ledger link lives in the footer.
220 const elapsed = d.startedAt && d.startedAt > 0 ? `session ${span((now - d.startedAt) / 60_000)}` : ''
221 const TAGLINE = ' cost control for AI agents'
222 const statusWidth = 8
223 const showTagline = 10 + TAGLINE.length + 1 + statusWidth <= inner
224 const header = (
225 <Box key="header" justifyContent="space-between">
226 <Box>
227 <Text bold>Token</Text>
228 <Text bold color={GOLD}>Munim</Text>
229 {showTagline ? <Text color={FAINT}>{TAGLINE}</Text> : null}
230 </Box>
231 <Box>
232 {isLive ? pill('● LIVE', GREEN, GREEN_BG) : pill('○ IDLE', FAINT)}
233 </Box>
234 </Box>
235 )
236
237 const agentCount = Math.max(1, b.agents.length)
238 const tabDefs: { id: Tab; long: string; short: string; n?: number }[] = [
239 { id: 'overview', long: 'Overview', short: 'Home' },
240 { id: 'tasks', long: 'Tasks', short: 'Tasks', n: b.tasks.length },
241 { id: 'activity', long: 'Activity', short: 'Activity', n: b.entries.length },
242 { id: 'alerts', long: 'Alerts', short: 'Alerts', n: b.trips.length },
243 { id: 'agents', long: 'Agents', short: 'Agents', n: agentCount },
244 ]
245 // The richest tab labels that fit on one line; each label carries a space a side.
246 const variants = [
247 { counts: true, short: false, gap: 1 },
248 { counts: false, short: false, gap: 1 },
249 { counts: false, short: true, gap: 0 },
250 ]
251 const labelOf = (t: (typeof tabDefs)[number], v: (typeof variants)[number]) =>
252 `${v.short ? t.short : t.long}${v.counts && t.n !== undefined ? ` ${t.n}` : ''}`
253 const chosen =
254 variants.find(v => tabDefs.reduce((w, t) => w + labelOf(t, v).length + 2, 0) + v.gap * (tabDefs.length - 1) <= inner) ??
255 variants[variants.length - 1]!
256 const tabBar = (
257 <Box key="tabs" flexDirection="column" marginTop={1}>
258 <Box>
259 {tabDefs.map((t, i) => (
260 <Box key={`tab-${t.id}`} marginRight={i < tabDefs.length - 1 ? chosen.gap : 0}>
261 {t.id === view.tab ? (
262 <Text bold color={INK} backgroundColor={GOLD}>{` ${labelOf(t, chosen)} `}</Text>
263 ) : (
264 <Button key={`tab-btn-${t.id}`} plain dimColor label={` ${labelOf(t, chosen)} `} hover={{ color: GOLD }} onPress={() => act.setTab(t.id)} />
265 )}
266 </Box>
267 ))}
268 </Box>
269 <Text color={LINE}>{'─'.repeat(inner)}</Text>
270 </Box>
271 )
272
273 // The footer: how long the session has run, the ledger file, the hint, and
274 // the open task. The hint gives way first when the row runs short.
275 const LEDGER = 'ledger ↗'
276 const activeText = active !== undefined ? `▸ ${active.name}` : ''
277 const footerBase = elapsed !== '' ? `${elapsed} · ` : ''
278 const HINT = ' · /munim for commands'
279 const footerUsed = footerBase.length + (d.hasFile ? LEDGER.length : 0) + Math.min(activeText.length, 16) + 2
280 const footerHint = footerUsed + HINT.length <= inner ? HINT : ''
281 const footer = (gap: number) => (
282 <Box key="footer" marginTop={gap} justifyContent="space-between">
283 <Box>
284 {footerBase !== '' ? <Text color={FAINT}>{footerBase}</Text> : null}
285 {d.hasFile ? link('open-ledger', LEDGER, act.openLedger) : null}
286 {footerHint !== '' || !d.hasFile ? <Text color={FAINT}>{d.hasFile ? footerHint : '/munim for commands'}</Text> : null}
287 </Box>
288 {active !== undefined ? (
289 <Text color={MUTED}>{cut(activeText, Math.max(0, inner - footerBase.length - (d.hasFile ? LEDGER.length : 19) - footerHint.length - 2))}</Text>
290 ) : null}
291 </Box>
292 )
293
294 // ---- plan limits ---------------------------------------------------------
295
296 const plan = b.rateLimits.filter(w => w.kind === 'five_hour' || w.kind === 'seven_day')
297 const windows = plan.length > 0 ? plan : b.rateLimits.filter(w => w.kind === 'spend_limit').slice(0, 1)
298 const showPace = inner >= 50
299 const limitBarW = Math.max(6, inner - 6 - 10 - 10 - (showPace ? 13 : 0))
300 const limitsBlock = (marginTop: number) => (
301 <Box key="limits" flexDirection="column" marginTop={marginTop}>
302 {windows.length === 0 ? (
303 <Box>
304 <Text color={MUTED}>{'limits '}</Text>
305 <Text color={FAINT}>{cut('plan limits show after the next reply', inner - 8)}</Text>
306 </Box>
307 ) : null}
308 {windows.map(w => {
309 const left = Math.max(0, Math.min(100, 100 - w.percentUsed))
310 const color = left >= 50 ? GREEN : left >= 20 ? AMBER : RED
311 const resetMs = w.resetsAt ? Date.parse(w.resetsAt) - now : NaN
312 const r = runway(b, w.kind, now)
313 const filled = fill(left / 100, limitBarW)
314 const pace =
315 r.state === 'runs-out' ? { text: `⚠ out in ${span(r.minutes)}`, color: r.minutes < 30 ? RED : AMBER }
316 : r.state === 'lasts' ? { text: '✓ lasts', color: GREEN }
317 : r.state === 'steady' ? { text: '✓ steady', color: GREEN }
318 : { text: '· measuring', color: FAINT }
319 return (
320 <Box key={`limit-${w.kind}`}>
321 <Text color={MUTED}>{pad(WINDOW_LABEL[w.kind] ?? w.kind, 6)}</Text>
322 <Text color={color}>{'━'.repeat(filled)}</Text>
323 <Text color={LINE}>{'━'.repeat(limitBarW - filled)}</Text>
324 <Text bold color={color}>{pad(` ${String(Math.round(left)).padStart(3)}% left`, 10)}</Text>
325 <Text color={FAINT}>{pad(` ↻ ${Number.isNaN(resetMs) ? '?' : countdown(resetMs)}`, 10)}</Text>
326 {showPace ? <Text color={pace.color}>{cut(pace.text, 13)}</Text> : null}
327 </Box>
328 )
329 })}
330 </Box>
331 )
332
333 // ---- the numbers ---------------------------------------------------------
334
335 const spent = sessionUsd(b)
336 const rate = burnRate(b, now)
337 const tokensPerMin = tokenRate(b, now)
338 const limit = limits.burnLimitUsdPerMin
339 const rateColor = rate > limit ? RED : rate > limit * 0.75 ? AMBER : undefined
340 const blockedCount = b.entries.filter(en => en.outcome === 'blocked').length
341 const perRow = inner >= 60 ? 4 : 2
342 const cw = Math.floor((inner - (perRow - 1)) / perRow)
343 const liveSpark = (series: number[]) => (series.some(v => v > 0) ? sparkline(series) : undefined)
344 const tiles = [
345 { key: 'spent', label: 'SPENT', value: money(spent), color: undefined as string | undefined, caption: `${Math.round((spent / limits.sessionBudgetUsd) * 100)}% of ${dollars(limits.sessionBudgetUsd)}`, spark: undefined as string | undefined, sparkColor: GOLD },
346 { key: 'cost', label: 'COST/MIN', value: money(rate), color: rateColor, caption: `limit ${money(limit)}`, spark: liveSpark(burnSeries(b, now, 16, 45_000)), sparkColor: rate > limit ? RED : GOLD },
347 { key: 'tokens', label: 'TOKENS/MIN', value: compactTokens(tokensPerMin), color: undefined, caption: `${compactTokens(b.tokens)} total`, spark: liveSpark(tokenSeries(b, now, 16, 45_000)), sparkColor: TEAL },
348 { key: 'stopped', label: 'STOPPED', value: String(blockedCount), color: (blockedCount > 0 ? RED : undefined) as string | undefined, caption: 'calls blocked', spark: undefined as string | undefined, sparkColor: GREEN },
349 ]
350 const tileRows = [tiles.slice(0, perRow), tiles.slice(perRow)].filter(row => row.length > 0)
351 const tilesBlock = (marginTop: number) => (
352 <Box key="tiles" flexDirection="column" marginTop={marginTop}>
353 {tileRows.map((row, r) => (
354 <Box key={`tiles-${r}`} gap={1} marginTop={r > 0 ? 1 : 0}>
355 {row.map((t, i) => {
356 const w = i === row.length - 1 ? inner - (perRow - 1) - cw * (perRow - 1) : cw
357 const room = w - 2
358 const sparkRoom = t.spark ? Math.max(0, Math.min(8, room - t.value.length - 1)) : 0
359 return (
360 <Box key={`tile-${t.key}`} width={w} flexDirection="column" backgroundColor={PANEL} paddingX={1}>
361 <Text color={MUTED}>{cut(t.label, room)}</Text>
362 <Box justifyContent="space-between">
363 <Text bold color={t.color}>{cut(t.value, room)}</Text>
364 {sparkRoom >= 3 && t.spark ? <Text color={t.sparkColor}>{t.spark.slice(-sparkRoom)}</Text> : null}
365 </Box>
366 <Text color={FAINT}>{cut(t.caption, room)}</Text>
367 </Box>
368 )
369 })}
370 </Box>
371 ))}
372 </Box>
373 )
374
375 // ---- the burn chart ------------------------------------------------------
376
377 const chartH = 4
378 const gutter = 6
379 const cols = Math.max(10, C - gutter - 1)
380 const bucketMs = 12_000
381 const series = burnSeries(b, now, cols * 2, bucketMs)
382 const peak = Math.max(0, ...series)
383 const scale = Math.max(peak * 1.15, limit * 1.25, 0.01)
384 const cells = brailleArea(series, chartH, scale)
385 const limitLevel = Math.min(chartH * 4, Math.max(1, Math.round((limit / scale) * chartH * 4)))
386 const limitRow = chartH - 1 - Math.floor((limitLevel - 1) / 4)
387 const overs = series.filter(v => v > limit).length
388 const toneOf = (v: number) => (v > limit ? RED : v > limit * 0.75 ? AMBER : v > limit * 0.4 ? GOLD : TEAL)
389 const chartLine = (row: (typeof cells)[number], r: number) => {
390 const runs: { text: string; color: string | undefined }[] = []
391 for (const cell of row) {
392 const [ch, color] =
393 cell.char !== null ? [cell.char, toneOf(cell.value)]
394 : r === limitRow ? ['┈', RED_DIM]
395 : r === chartH - 1 ? ['⣀', LINE]
396 : [' ', undefined]
397 const last = runs[runs.length - 1]
398 if (last && last.color === color) last.text += ch
399 else runs.push({ text: ch, color })
400 }
401 return runs
402 }
403 const gutterLabel = (r: number) => pad(r === limitRow ? 'limit' : r === 0 ? money(scale) : r === chartH - 1 ? '$0' : '', gutter)
404 const burnBody = (
405 <Box key="burn-body" flexDirection="column">
406 {cells.map((row, r) => (
407 <Box key={`chart-${r}`}>
408 {chartLine(row, r).map(run => (
409 <Text color={run.color}>{run.text}</Text>
410 ))}
411 <Text color={r === limitRow ? RED_DIM : FAINT}>{` ${gutterLabel(r)}`}</Text>
412 </Box>
413 ))}
414 <Box width={cols} justifyContent="space-between">
415 <Text color={FAINT}>{`${Math.round((cols * 2 * bucketMs) / 60_000)}m ago`}</Text>
416 <Text color={FAINT}>now</Text>
417 </Box>
418 </Box>
419 )
420 const burnRight = (
421 <Box key="burn-right">
422 <Text color={rateColor ?? MUTED}>{`${money(rate)}/min`}</Text>
423 {!narrow ? <Text color={FAINT}>{` · peak ${money(peak)}`}</Text> : null}
424 {!narrow && overs > 0 ? <Text color={RED}>{' · over limit'}</Text> : null}
425 </Box>
426 )
427
428 // ---- the token mix -------------------------------------------------------
429
430 const m = b.mix
431 const total = mixTotal(m)
432 const parts = [
433 { key: 'read', label: 'cache read', short: 'read', n: m.cacheRead, color: TEAL },
434 { key: 'input', label: 'input', short: 'in', n: m.input, color: BLUE },
435 { key: 'write', label: 'cache write', short: 'write', n: m.cacheWrite, color: AMBER },
436 { key: 'output', label: 'output', short: 'out', n: m.output, color: VIOLET },
437 ]
438 let usedW = 0
439 const widths = parts.map((p, i) => {
440 const raw = i === parts.length - 1 ? C - usedW : Math.round((p.n / Math.max(1, total)) * C)
441 const w = Math.max(0, Math.min(C - usedW, p.n > 0 ? Math.max(1, raw) : 0))
442 usedW += w
443 return w
444 })
445 const legendLong = parts.reduce((w, p) => w + p.label.length + compactTokens(p.n).length + 5, 0) <= C
446 const legendItem = (p: (typeof parts)[number], longLabel: boolean) => (
447 <Box key={`legend-${p.key}`} marginRight={2}>
448 <Text color={p.color}>{'● '}</Text>
449 <Text color={MUTED}>{`${longLabel ? p.label : p.short} `}</Text>
450 <Text>{compactTokens(p.n)}</Text>
451 </Box>
452 )
453 const subagents = b.agents.filter(a => a.id !== MAIN)
454 const subShare = total > 0 ? subagents.reduce((s, a) => s + a.tokens, 0) / total : 0
455 const mixBody = (
456 <Box key="mix-body" flexDirection="column">
457 {total === 0 ? (
458 <Text color={FAINT}>No replies booked yet.</Text>
459 ) : (
460 <Box>
461 {parts.map((p, i) => ((widths[i] ?? 0) > 0 ? <Text color={p.color}>{'█'.repeat(widths[i] ?? 0)}</Text> : null))}
462 </Box>
463 )}
464 {total === 0 ? null : legendLong ? (
465 <Box>{parts.map(p => legendItem(p, true))}</Box>
466 ) : (
467 <Box flexDirection="column">
468 <Box>{parts.slice(0, 2).map(p => legendItem(p, !narrow))}</Box>
469 <Box>{parts.slice(2).map(p => legendItem(p, !narrow))}</Box>
470 </Box>
471 )}
472 {subagents.length > 0 ? (
473 <Text color={FAINT}>{cut(`main ${pct(1 - subShare)} · ${subagents.length} subagent${subagents.length === 1 ? '' : 's'} ${pct(subShare)}`, C)}</Text>
474 ) : null}
475 </Box>
476 )
477 const mixRight =
478 total > 0 ? (
479 <Box key="mix-right">
480 {pill(`${hit(cacheHit(m))} cache hit`, GREEN, GREEN_BG)}
481 {!narrow ? <Text color={FAINT}>{` ${compactTokens(total)}`}</Text> : null}
482 </Box>
483 ) : (
484 muted('waiting for a reply', C - 14)
485 )
486
487 // ---- the circuit callout -------------------------------------------------
488
489 const actionable = (t: Trip) => isPending(b, t, now)
490 const reasonLines = (t: Trip) => wrap(t.reason, C - 2, 2)
491 const tripRowsOf = (t: Trip, withActions: boolean) =>
492 1 + reasonLines(t).length + 1 + (t.resolved !== undefined ? 1 : withActions && actionable(t) ? 2 : 0)
493 const tripCard = (t: Trip, key: string, withActions: boolean, marginTop: number, inline = false) => {
494 const isFresh = now - t.at < 10 * 60_000
495 const isLatest = key.startsWith('trip-latest')
496 const task = b.tasks.find(k => k.id === t.taskId) ?? b.tasks.find(k => k.name === t.task)
497 const live = withActions && actionable(t)
498 const bar = <Text color={t.resolved ? FAINT : RED}>{'▎'}</Text>
499 const buttons: unknown[] = []
500 if (live) {
501 if ((t.kind === 'budget' || t.kind === 'halted') && task && task.budgetUsd > 0) {
502 const by = raiseStep(task)
503 buttons.push(action(`${key}-raise-btn`, `+${money(by)} budget`, () => act.raise(task.id, by), true))
504 }
505 buttons.push(action(`${key}-allow-btn`, 'Allow once', () => act.allow(), false))
506 // Only a task still running can be skipped; a finished one has nothing left to skip.
507 if ((t.kind === 'loop' || t.kind === 'burn') && task && task.id !== GENERAL && task.status === 'open') {
508 buttons.push(action(`${key}-skip-btn`, narrow ? 'Skip' : 'Skip task', () => act.skip(task.id), false))
509 }
510 }
511 const title = t.resolved || !isLatest ? 'CIRCUIT TRIP' : isFresh ? 'CIRCUIT TRIPPED' : 'LAST CIRCUIT TRIP'
512 const when = `${!narrow && !inline && b.trips.length > 1 && isLatest ? `${b.trips.length} alerts · ` : ''}${clockTime(t.at, narrow)}`
513 const lines = [
514 <Box key={`${key}-head`} justifyContent="space-between">
515 <Box>
516 {bar}
517 <Text bold color={t.resolved ? MUTED : RED}>{`⊘ ${title} `}</Text>
518 {pill(t.kind.toUpperCase(), INK, t.resolved ? FAINT : RED)}
519 </Box>
520 <Text color={FAINT}>{cut(when, Math.max(0, C - title.length - t.kind.length - 8))}</Text>
521 </Box>,
522 ...reasonLines(t).map((line, i) => (
523 <Box key={`${key}-reason-${i}`}>
524 {bar}
525 <Text>{` ${line}`}</Text>
526 </Box>
527 )),
528 <Box key={`${key}-where`}>
529 {bar}
530 <Text color={FAINT}>{` ${cut(`${t.task} · ${t.tool} · ${t.summary}`, C - 2)}`}</Text>
531 </Box>,
532 t.resolved !== undefined ? (
533 <Box key={`${key}-done`}>
534 {bar}
535 <Text color={GREEN}>{` ✓ ${cut(t.resolved, C - 4)}`}</Text>
536 </Box>
537 ) : null,
538 buttons.length > 0 ? (
539 <Box key={`${key}-actions`} marginTop={1}>
540 <Text> </Text>
541 {buttons}
542 </Box>
543 ) : null,
544 ]
545 if (inline) {
546 return (
547 <Box key={key} flexDirection="column" backgroundColor={t.resolved ? undefined : RED_PANEL}>
548 {lines}
549 </Box>
550 )
551 }
552 return panel(key, lines, marginTop, t.resolved ? PANEL : RED_PANEL)
553 }
554
555 // ---- task rows ----------------------------------------------------------
556
557 // Columns drop in this order as the pane narrows: tokens, then calls.
558 const showTokens = C >= 58
559 const showCalls = C >= 46
560 const BAR = C >= 46 ? 10 : 6
561 const N = Math.max(6, C - 2 - 8 - (showTokens ? 7 : 0) - 1 - BAR - 6 - (showCalls ? 6 : 0) - 2)
562 const taskHead = (
563 <Text key="task-head" color={FAINT}>
564 {` ${pad('TASK', N)}${lpad('SPENT', 8)}${showTokens ? lpad('TOKENS', 7) : ''} ${pad('BUDGET', BAR)}${lpad('USED', 6)}${showCalls ? lpad('CALLS', 6) : ''}`}
565 </Text>
566 )
567 const taskRow = (k: Task, drill: boolean) => {
568 const s = STATUS[k.status]
569 const hasBudget = k.budgetUsd > 0
570 const ratio = hasBudget ? k.usd / k.budgetUsd : 0
571 const over = ratio >= 1
572 const filled = fill(ratio, BAR)
573 const barColor = k.status === 'halted' || over ? RED : ratio >= 0.75 ? AMBER : GOLD
574 const nameColor = k.status === 'closed' ? MUTED : k.status === 'halted' ? RED : undefined
575 const isActive = k.id === b.active
576 return (
577 <Box key={`k-${k.id}`} flexDirection="column">
578 <Box backgroundColor={isActive ? PANEL_HI : undefined}>
579 <Text color={s.color}>{`${s.glyph} `}</Text>
580 <Text bold={isActive} color={nameColor}>{pad(k.name, N)}</Text>
581 <Text bold={over} color={over ? RED : undefined}>{lpad(money(k.usd), 8)}</Text>
582 {showTokens ? <Text color={MUTED}>{lpad(compactTokens(k.tokens), 7)}</Text> : null}
583 <Text> </Text>
584 {hasBudget ? <Text color={barColor}>{'━'.repeat(filled)}</Text> : null}
585 {hasBudget ? <Text color={LINE}>{'━'.repeat(BAR - filled)}</Text> : null}
586 {hasBudget ? <Text bold={over} color={over ? RED : MUTED}>{lpad(used(ratio), 6)}</Text> : null}
587 {!hasBudget ? <Text color={FAINT}>{pad('no limit', BAR + 6)}</Text> : null}
588 {showCalls ? <Text color={MUTED}>{lpad(String(k.calls), 6)}</Text> : null}
589 <Text> </Text>
590 {drill ? link(`drill-${k.id}`, '›', () => act.drill(k.id)) : <Text> </Text>}
591 </Box>
592 {k.status === 'halted' ? <Text color={RED_DIM}>{cut(` └ halted · ${k.haltReason ?? 'over budget'}`, C)}</Text> : null}
593 </Box>
594 )
595 }
596
597 // ---- activity rows -----------------------------------------------------------
598
599 // Narrow panes drop the duration so what a call was for keeps its room.
600 const TIME = narrow ? 6 : 9
601 const DUR = narrow ? 0 : 7
602 const S = Math.max(6, C - TIME - 2 - 7 - DUR - 2)
603 const agentName = (id: string | undefined) => b.agents.find(a => a.id === (id ?? MAIN))?.name ?? 'main'
604 const entryDetails = (en: Entry) => {
605 const taskName = b.tasks.find(k => k.id === en.task)?.name ?? en.task
606 const result = en.outcome === 'blocked' ? `blocked by the ${en.note ?? ''} circuit` : en.outcome === 'fail' ? 'failed' : 'worked'
607 const TEXT = Math.max(8, C - 12)
608 const row = (label: string, value: string, color?: string) => (
609 <Box key={`detail-${label}`}>
610 <Text color={FAINT}>{pad(label, 8)}</Text>
611 <Text color={color}>{cut(value, TEXT)}</Text>
612 </Box>
613 )
614 return (
615 <Box key="entry-details" flexDirection="column" marginLeft={2} backgroundColor={PANEL_HI} paddingX={1}>
616 {row('task', taskName)}
617 {row('agent', agentName(en.agent))}
618 {row('result', en.outcome === 'blocked' ? result : `${result} · took ${duration(en.ms)}`, OUTCOME[en.outcome].color)}
619 {wrap(en.summary, TEXT, 3).map((line, i) => (
620 <Box key={`words-${i}`}>
621 <Text color={FAINT}>{i === 0 ? 'action ' : ' '}</Text>
622 <Text color={MUTED}>{line}</Text>
623 </Box>
624 ))}
625 </Box>
626 )
627 }
628 const activityRow = (en: Entry, index: number, expandable: boolean) => {
629 const o = OUTCOME[en.outcome]
630 const isBlocked = en.outcome === 'blocked'
631 const isOpen = view.entry === index
632 return (
633 <Box key={`t-${index}`} flexDirection="column">
634 <Box backgroundColor={isOpen ? PANEL_HI : undefined}>
635 <Text color={FAINT}>{pad(clockTime(en.at, narrow), TIME)}</Text>
636 <Text color={o.color}>{`${o.glyph} `}</Text>
637 <Text bold color={isBlocked ? RED : TOOL_COLOR[en.tool] ?? MUTED}>{pad(en.tool, 6)}</Text>
638 <Text color={isBlocked ? RED : undefined}>{` ${pad(isBlocked ? `circuit · ${en.note ?? ''} · ${en.summary}` : en.summary, S)}`}</Text>
639 {DUR > 0 ? <Text color={FAINT}>{lpad(isBlocked ? '' : duration(en.ms), DUR)}</Text> : null}
640 <Text> </Text>
641 {expandable ? link(`open-${index}`, isOpen ? '⌄' : '›', () => act.expand(isOpen ? null : index)) : <Text> </Text>}
642 </Box>
643 {isOpen ? entryDetails(en) : null}
644 </Box>
645 )
646 }
647
648 // ---- the tabs ------------------------------------------------------------
649
650 const overview = () => {
651 // Every alert the badge counts, newest first; else the last one, for the record.
652 const waiting = pendingTrips(b, now)
653 const shownAlerts = (waiting.length > 0 ? waiting : trip ? [trip] : []).slice(0, 3)
654 const moreAlerts = Math.max(0, waiting.length - shownAlerts.length)
655 const alertRows = shownAlerts.length === 0 ? 1 : shownAlerts.reduce((n, t, i) => n + tripRowsOf(t, true) + (i > 0 ? 1 : 0), 0) + (moreAlerts > 0 ? 1 : 0)
656 const fresh = pendingCount(b, now)
657 const limitRows = Math.max(1, windows.length)
658 const tilesRows = 3 * tileRows.length + (tileRows.length - 1)
659 const burnOpen = 1 + chartH + 1
660 const mixOpen = 1 + 1 + (total === 0 ? 0 : legendLong ? 1 : 2) + (subagents.length > 0 ? 1 : 0)
661 const haltedRows = (count: number) => b.tasks.slice(-count).filter(k => k.status === 'halted').length
662 const tasksOpen = (count: number) => 2 + Math.max(1, count) + haltedRows(count)
663 const activityOpen = (count: number) => 1 + Math.max(1, count)
664
665 // Fit: shrink the lists, then fold what the person can unfold again, but
666 // never a section the person opened: their choice wins, and the pane
667 // scrolls if they open more than fits.
668 const opened = new Set(view.opened ?? [])
669 const shut = new Set(view.folded.filter(id => !opened.has(id)))
670 // Alerts stay folded behind their notification pill until opened.
671 if (!opened.has('alerts')) shut.add('alerts')
672 const autoFold = (id: string) => {
673 if (!opened.has(id)) shut.add(id)
674 }
675 let taskCount = Math.min(b.tasks.length, 8)
676 let activityCount = 5
677 const panels = () =>
678 limitRows + tilesRows + (shut.has('burn') ? 1 : burnOpen) + (shut.has('mix') ? 1 : mixOpen) + (shut.has('alerts') ? 1 : 1 + alertRows) +
679 (shut.has('tasks') ? 1 : tasksOpen(taskCount)) + (shut.has('activity') ? 1 : activityOpen(activityCount))
680 const chrome = 1 + 3 + 1 // header, tabs, footer
681 const gaps = 8
682 const fits = () => chrome + panels() + gaps <= R
683 while (!fits() && taskCount > 5) taskCount -= 1
684 while (!fits() && activityCount > 2 && !shut.has('activity')) activityCount -= 1
685 for (const id of ['activity', 'mix']) if (!fits()) autoFold(id)
686 while (!fits() && taskCount > 3) taskCount -= 1
687 for (const id of ['burn', 'tasks']) if (!fits()) autoFold(id)
688
689 const shownTasks = b.tasks.slice(-taskCount)
690 const overBudget = b.tasks.filter(k => k.budgetUsd > 0 && k.usd > k.budgetUsd).length
691 const taskRight = (
692 <Box key="tasks-right">
693 <Text color={MUTED}>{`${b.tasks.length} · ${money(spent)}`}</Text>
694 {overBudget > 0 && !narrow ? <Text color={RED}>{` · ${overBudget} over`}</Text> : null}
695 {b.tasks.length > shownTasks.length && !narrow ? <Text color={FAINT}>{` · latest ${shownTasks.length}`}</Text> : null}
696 </Box>
697 )
698 const activityRight = lastEntry ? (
699 <Box key="activity-right">
700 <Text color={MUTED}>{`${b.entries.length} · last `}</Text>
701 <Text color={TOOL_COLOR[lastEntry.tool] ?? MUTED}>{lastEntry.tool}</Text>
702 <Text color={OUTCOME[lastEntry.outcome].color}>{` ${OUTCOME[lastEntry.outcome].glyph}`}</Text>
703 </Box>
704 ) : (
705 muted('empty', 8)
706 )
707 const activity = b.entries.slice(-activityCount).reverse()
708 const newest = b.entries.length - 1
709
710 return (
711 <Box key="overview" flexDirection="column">
712 {limitsBlock(1)}
713 {tilesBlock(1)}
714 {section('burn', 'BURN RATE', burnRight, shut.has('burn'), burnBody, 1)}
715 {section('mix', 'TOKEN MIX', mixRight, shut.has('mix'), mixBody, 1)}
716 {section(
717 'tasks',
718 'TASKS',
719 taskRight,
720 shut.has('tasks'),
721 <Box key="tasks-body" flexDirection="column">
722 {taskHead}
723 {b.tasks.length === 0 ? <Text color={FAINT}>{cut(' No task yet. Work counts toward "general".', C)}</Text> : null}
724 {shownTasks.map(k => taskRow(k, true))}
725 </Box>,
726 1,
727 )}
728 {section(
729 'activity',
730 'ACTIVITY',
731 activityRight,
732 shut.has('activity'),
733 <Box key="activity-body" flexDirection="column">
734 {activity.length === 0 ? <Text color={FAINT}>Waiting for the first step…</Text> : null}
735 {activity.map((en, i) => activityRow(en, newest - i, false))}
736 </Box>,
737 1,
738 )}
739 {section(
740 'alerts',
741 'ALERTS',
742 <Box key="alerts-right">
743 {fresh > 0 ? pill(`● ${fresh} new`, INK, RED) : null}
744 <Text color={fresh > 0 ? FAINT : MUTED}>
745 {b.trips.length === 0 ? 'none' : `${fresh > 0 ? ' ' : ''}${b.trips.length} total${trip && fresh === 0 ? ` · last ${clockTime(trip.at, true)}` : ''}`}
746 </Text>
747 </Box>,
748 shut.has('alerts'),
749 shownAlerts.length > 0 ? (
750 <Box key="alerts-body" flexDirection="column">
751 {shownAlerts.map((t, i) => (
752 <Box key={`alert-${i}`} flexDirection="column" marginTop={i > 0 ? 1 : 0}>
753 {tripCard(t, i === 0 ? 'trip-latest' : `trip-new-${i}`, true, 0, true)}
754 </Box>
755 ))}
756 {moreAlerts > 0 ? <Text key="alerts-more" color={FAINT}>{cut(`+ ${moreAlerts} more on the Alerts tab`, C)}</Text> : null}
757 </Box>
758 ) : (
759 <Text key="no-alerts" color={FAINT}>{cut('The circuit breaker has not stopped anything yet.', C)}</Text>
760 ),
761 1,
762 )}
763 {footer(1)}
764 </Box>
765 )
766 }
767
768 const listHead = (title: string, right: unknown) => (
769 <Box key={`${title}-head`} justifyContent="space-between">
770 <Text bold>{title}</Text>
771 {right}
772 </Box>
773 )
774
775 const tasksTab = () => {
776 const room = Math.max(3, R - 2 - 3 - 6)
777 const shown = b.tasks.slice(-room)
778 const open = b.tasks.filter(k => k.status === 'open').length
779 const closed = b.tasks.filter(k => k.status === 'closed').length
780 const halted = b.tasks.filter(k => k.status === 'halted').length
781 return (
782 <Box key="tasks-tab" flexDirection="column">
783 {panel(
784 'tasks-card',
785 [
786 listHead(
787 'TASKS',
788 <Box key="tasks-tab-right">
789 <Text color={MUTED}>{`${open} open · ${closed} closed`}</Text>
790 {halted > 0 ? <Text color={RED}>{` · ${halted} halted`}</Text> : null}
791 {wide ? <Text color={FAINT}>{' · › its calls'}</Text> : null}
792 </Box>,
793 ),
794 taskHead,
795 b.tasks.length === 0 ? <Text key="none" color={FAINT}> No task yet.</Text> : null,
796 b.tasks.length > shown.length ? <Text key="earlier" color={FAINT}>{cut(` + ${b.tasks.length - shown.length} earlier in ledger.md`, C)}</Text> : null,
797 ...shown.map(k => taskRow(k, true)),
798 <Box key="tasks-total" marginTop={1} justifyContent="space-between">
799 <Text color={FAINT}>total</Text>
800 <Text color={MUTED}>{cut(`${money(spent)} · ${compactTokens(b.tokens)} tokens · ${b.tasks.reduce((s, k) => s + k.calls, 0)} calls`, C - 7)}</Text>
801 </Box>,
802 ],
803 1,
804 )}
805 {footer(1)}
806 </Box>
807 )
808 }
809
810 const activityTab = () => {
811 const filter = view.task
812 const indexed = b.entries.map((en, i) => ({ en, i })).filter(e => filter === null || e.en.task === filter)
813 const expandedRows = view.entry !== null ? 7 : 0
814 const room = Math.max(3, R - 2 - 3 - 5 - (filter ? 1 : 0) - expandedRows)
815 const shown = indexed.slice(-room).reverse()
816 const filterName = filter ? b.tasks.find(k => k.id === filter)?.name ?? filter : null
817 return (
818 <Box key="activity-tab" flexDirection="column">
819 {panel(
820 'activity-card',
821 [
822 listHead('ACTIVITY', muted(`${indexed.length} entries · newest first${wide ? ' · › details' : ''}`, C - 6)),
823 filterName !== null ? (
824 <Box key="activity-filter">
825 <Text color={MUTED}>{'task '}</Text>
826 {pill(cut(filterName, Math.max(4, C - 22)), INK, GOLD)}
827 <Text>{' '}</Text>
828 {link('clear-filter', 'show all ✕', () => act.drill(null))}
829 </Box>
830 ) : null,
831 shown.length === 0 ? <Text key="none" color={FAINT}>No activity yet.</Text> : null,
832 ...shown.map(e => activityRow(e.en, e.i, true)),
833 indexed.length > shown.length ? <Text key="earlier" color={FAINT}>{cut(`+ ${indexed.length - shown.length} earlier entries in ledger.md`, C)}</Text> : null,
834 ],
835 1,
836 )}
837 {footer(1)}
838 </Box>
839 )
840 }
841
842 const alertsTab = () => {
843 const newest = [...b.trips].reverse()
844 const shown: Trip[] = []
845 let rows = 2 + 3 + 2
846 for (const t of newest) {
847 const need = tripRowsOf(t, shown.length === 0) + 1
848 if (shown.length > 0 && rows + need > R) break
849 shown.push(t)
850 rows += need
851 }
852 return (
853 <Box key="trips-tab" flexDirection="column">
854 {shown.length === 0
855 ? panel(
856 'no-trips',
857 [
858 <Text key="no-trips-title" bold color={GREEN}>✓ NO ALERTS</Text>,
859 <Text key="no-trips-text" color={FAINT}>{cut('The circuit breaker has not stopped anything yet.', C)}</Text>,
860 ],
861 1,
862 )
863 : shown.map((t, i) => tripCard(t, i === 0 ? 'trip-latest-tab' : `trip-${i}`, i === 0, 1))}
864 {newest.length > shown.length ? <Text color={FAINT}>{cut(`+ ${newest.length - shown.length} earlier alerts in ledger.md`, inner)}</Text> : null}
865 {footer(1)}
866 </Box>
867 )
868 }
869
870 const agentsTab = () => {
871 const list: AgentLedger[] = b.agents
872 // dot 2, then steps and calls (6 each) on wide panes, then tokens 8, cost 8, cache 7.
873 const NAME = Math.max(6, C - (wide ? 37 : 25))
874 return (
875 <Box key="agents-tab" flexDirection="column">
876 {panel(
877 'agents-card',
878 [
879 listHead('AGENTS', muted('tokens exact · cost by reply', C - 8)),
880 <Text key="agents-cols" color={FAINT}>
881 {` ${pad('AGENT', NAME)}${wide ? `${lpad('STEPS', 6)}${lpad('CALLS', 6)}` : ''}${lpad('TOKENS', 8)}${lpad('COST', 8)}${lpad('CACHE', 7)}`}
882 </Text>,
883 list.length === 0 ? <Text key="none" color={FAINT}> No replies booked yet.</Text> : null,
884 ...list.map(a => {
885 const isRunning = a.status === 'running' || a.status === 'pending' || a.status === 'waiting'
886 const dot =
887 a.id === MAIN ? { glyph: '◆', color: GOLD }
888 : isRunning ? { glyph: '●', color: GREEN }
889 : a.status === 'failed' || a.status === 'killed' ? { glyph: '■', color: RED }
890 : { glyph: '○', color: FAINT }
891 return (
892 <Box key={`agent-${a.id}`} flexDirection="column">
893 <Box>
894 <Text color={dot.color}>{`${dot.glyph} `}</Text>
895 <Text bold={a.id === MAIN}>{pad(a.name, NAME)}</Text>
896 <Text color={MUTED}>
897 {`${wide ? `${lpad(String(a.steps), 6)}${lpad(String(a.calls), 6)}` : ''}${lpad(compactTokens(a.tokens), 8)}${lpad(money(a.usd), 8)}`}
898 </Text>
899 <Text color={GREEN}>{lpad(hit(cacheHit(a.mix)), 7)}</Text>
900 </Box>
901 {a.model !== undefined ? (
902 <Text color={FAINT}>{cut(` ${a.model}${a.type ? ` · ${a.type}` : ''}${a.status && a.id !== MAIN ? ` · ${a.status}` : ''}`, C)}</Text>
903 ) : null}
904 </Box>
905 )
906 }),
907 ],
908 1,
909 )}
910 {footer(1)}
911 </Box>
912 )
913 }
914
915 const body =
916 view.tab === 'tasks' ? tasksTab()
917 : view.tab === 'activity' ? activityTab()
918 : view.tab === 'alerts' ? alertsTab()
919 : view.tab === 'agents' ? agentsTab()
920 : overview()
921
922 return (
923 <Box flexDirection="column" paddingX={1}>
924 {header}
925 {tabBar}
926 {body}
927 </Box>
928 )
929}
930
931// A view as older versions saved it, with the old names for its tabs and sections.
932const RENAMED: Record<string, string> = { khatas: 'tasks', tape: 'activity', trips: 'alerts' }
933const TAB_IDS: readonly Tab[] = ['overview', 'tasks', 'activity', 'alerts', 'agents']
934
935export const migrateView = (saved: unknown): View => {
936 const v = (saved ?? {}) as Partial<View> & { khata?: string | null; tab?: string }
937 const rename = (id: string) => RENAMED[id] ?? id
938 const tab = rename(v.tab ?? 'overview') as Tab
939 return {
940 tab: TAB_IDS.includes(tab) ? tab : 'overview',
941 folded: (v.folded ?? DEFAULT_VIEW.folded).map(rename),
942 opened: (v.opened ?? []).map(rename),
943 task: v.task ?? v.khata ?? null,
944 entry: v.entry ?? null,
945 }
946}
947
948// At the end of the line under the prompt: one button, after whatever mode
949// labels the line already shows. It opens the dashboard, and while the
950// dashboard is open it is lit gold, like the active tab, and carries the cross
951// that closes it.
952export function drawBar(kit: Kit, isOpen: boolean, modes: readonly string[], toggle: () => void) {
953 const { Box, Text, Button } = kit
954 return (
955 <Box key="bar">
956 {modes.length > 0 ? <Text dimColor>{`${modes.join(' & ')} `}</Text> : null}
957 <Box key="bar-toggle-box" backgroundColor={isOpen ? GOLD_DEEP : CHIP}>
958 <Button key="bar-toggle" plain label={isOpen ? ' TokenMunim ✕ ' : ' TokenMunim '} hover={{ backgroundColor: GOLD, color: INK }} onPress={toggle} />
959 </Box>
960 </Box>
961 )
962}
963hooks/chart.ts 87 lines1// The burn chart as rows of colored runs: bars in eighth blocks, the burn
2// limit as a dotted line behind them, a baseline under everything.
3
4export type Tone = 'calm' | 'warm' | 'hot' | 'axis' | 'limit' | 'blank'
5export type Run = { text: string; tone: Tone }
6
7const EIGHTHS = ['', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
8
9export const chartRows = (
10 series: readonly number[],
11 limit: number,
12 height: number,
13): { rows: Run[][]; scale: number; limitRow: number } => {
14 const peak = Math.max(0, ...series)
15 const scale = Math.max(peak * 1.1, limit * 1.25, 0.01)
16 const levels = height * 8
17 const limitLevel = Math.min(levels, Math.max(1, Math.round((limit / scale) * levels)))
18 const limitRow = height - 1 - Math.floor((limitLevel - 1) / 8)
19
20 const columns = series.map(v => ({
21 level: v > 0 ? Math.max(1, Math.round((v / scale) * levels)) : 0,
22 tone: (v > limit ? 'hot' : v > limit * 0.6 ? 'warm' : 'calm') as Tone,
23 }))
24
25 const rows: Run[][] = []
26 for (let r = 0; r < height; r++) {
27 const floor = (height - 1 - r) * 8
28 const runs: Run[] = []
29 const put = (text: string, tone: Tone) => {
30 const last = runs[runs.length - 1]
31 if (last && last.tone === tone) runs[runs.length - 1] = { text: last.text + text, tone }
32 else runs.push({ text, tone })
33 }
34 for (const c of columns) {
35 const fill = Math.min(8, Math.max(0, c.level - floor))
36 if (fill > 0) put(EIGHTHS[fill] ?? '█', c.tone)
37 else if (r === limitRow) put('┈', 'limit')
38 else if (r === height - 1) put('▁', 'axis')
39 else put(' ', 'blank')
40 }
41 rows.push(runs)
42 }
43 return { rows, scale, limitRow }
44}
45
46// Braille charts: each cell is two dots wide and four tall, so a chart gets
47// twice the columns and four times the rows of a block chart in the same
48// space. A cell is filled from the bottom up to each of its two samples.
49const DOTS = [
50 [0x01, 0x02, 0x04, 0x40],
51 [0x08, 0x10, 0x20, 0x80],
52] as const
53
54export type Cell = { char: string | null; value: number }
55
56export const brailleArea = (series: readonly number[], height: number, scale: number): Cell[][] => {
57 const levels = height * 4
58 const top = Math.max(scale, 1e-9)
59 const level = series.map(v => (v > 0 ? Math.max(1, Math.min(levels, Math.round((v / top) * levels))) : 0))
60 const width = Math.ceil(series.length / 2)
61 const rows: Cell[][] = []
62 for (let r = 0; r < height; r++) {
63 const cells: Cell[] = []
64 for (let c = 0; c < width; c++) {
65 let bits = 0
66 for (let side = 0; side < 2; side++) {
67 const l = level[2 * c + side] ?? 0
68 for (let d = 0; d < 4; d++) {
69 const fromBottom = (height - 1 - r) * 4 + (3 - d) + 1
70 if (l >= fromBottom) bits |= DOTS[side]![d]!
71 }
72 }
73 const value = Math.max(series[2 * c] ?? 0, series[2 * c + 1] ?? 0)
74 cells.push({ char: bits === 0 ? null : String.fromCharCode(0x2800 + bits), value })
75 }
76 rows.push(cells)
77 }
78 return rows
79}
80
81// A one line braille sparkline, scaled to its own peak.
82export const sparkline = (series: readonly number[]): string => {
83 const peak = Math.max(0, ...series)
84 const [row] = brailleArea(series, 1, peak)
85 return (row ?? []).map(c => c.char ?? '⣀').join('')
86}
87types/index.d.ts 124 lines1export type TaskStatus = 'open' | 'closed' | 'halted'
2
3export type Task = {
4 id: string
5 name: string
6 status: TaskStatus
7 usd: number
8 tokens: number
9 // 0 means no budget: the general task, which catches unassigned work.
10 budgetUsd: number
11 calls: number
12 fails: number
13 blocked: number
14 openedAt: number
15 haltReason?: string
16}
17
18export type Outcome = 'ok' | 'fail' | 'blocked'
19
20export type Entry = {
21 at: number
22 task: string
23 tool: string
24 summary: string
25 ms: number
26 outcome: Outcome
27 note?: string
28 // The loop that made the call: a subagent's id, absent for the main loop.
29 agent?: string
30}
31
32export type TripKind = 'budget' | 'session' | 'halted' | 'loop' | 'burn'
33
34export type Trip = {
35 at: number
36 kind: TripKind
37 task: string
38 taskId?: string
39 tool: string
40 summary: string
41 reason: string
42 // What the person did about it from the pane, once they did something.
43 resolved?: string
44}
45
46export type Streak = { fp: string; n: number }
47
48// A running total at a moment: dollars for the cost series, tokens for the token series.
49export type Sample = { at: number; usd: number }
50export type TokenSample = { at: number; n: number }
51
52// A plan's usage window as the API last reported it: five_hour, seven_day, spend_limit.
53export type RateWindow = { kind: string; percentUsed: number; resetsAt?: string }
54export type LimitSample = { at: number; pct: number }
55
56// Where a reply's tokens went, as the API counts them.
57export type TokenMix = { input: number; cacheRead: number; cacheWrite: number; output: number }
58
59// One loop's share of the session: the main loop, or a subagent.
60export type AgentLedger = {
61 id: string
62 name: string
63 type?: string
64 status?: string
65 model?: string
66 usd: number
67 tokens: number
68 mix: TokenMix
69 steps: number
70 calls: number
71 firstAt: number
72 lastAt: number
73}
74
75export type Book = {
76 v: 6
77 tasks: Task[]
78 entries: Entry[]
79 trips: Trip[]
80 agents: AgentLedger[]
81 active: string | null
82 lastUsd: number | null
83 samples: Sample[]
84 tokens: number
85 tokenSamples: TokenSample[]
86 mix: TokenMix
87 rateLimits: RateWindow[]
88 limitSamples: Record<string, LimitSample[]>
89 streaks: Record<string, Streak>
90 burnPausedUntil: number
91 // Calls the person let through a tripped circuit from the pane.
92 passes: number
93}
94
95export type Tab = 'overview' | 'tasks' | 'activity' | 'alerts' | 'agents'
96
97// What the pane is showing: its own state, apart from the book it draws.
98export type View = {
99 tab: Tab
100 // Sections the person folded, and sections they opened. Their choice
101 // always wins over the pane folding things itself to fit its height.
102 folded: string[]
103 opened: string[]
104 task: string | null
105 entry: number | null
106}
107
108export type SessionSummary = {
109 startedAt: number
110 usd: number
111 tokens?: number
112 tasks: { name: string; status: TaskStatus; usd: number; calls: number }[]
113}
114
115// The bar above the prompt: hidden until the person asks for it in a
116// session, with a button that opens and closes the dashboard.
117export type Bar = { shown: boolean; open: boolean }
118
119declare module 'claude-code' {
120 interface PluginState {
121 tokenmunim: { book: Book; view: View; bar: Bar }
122 }
123}
124