SLOPSHOPPER

tokenmunim

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.

newpanespinnerguardcommandtoast
★ 1v0.9.0MITupdated 2026-10-06PiyushSinha-9/tokenmunim/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tokenmunim
│ ┃ tokenmunim ✕ › fix the failing auth test and add an audit log call │ ┃ TokenMunim cost control for AI agents ● LI │ ┃ ⏺ Read(src/auth.ts) │ ┃ Overview Tasks Activity Alerts Age ⎿ Read 6 lines │ ┃ ────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────── ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 5h ━━━━━━━━━━━━━━━ 69% left ↻ ? · ⎿ 3 pass, 1 fail │ ┃ │ ┃ SPENT COST/MIN ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ $0.00 $0.00 │ ┃ 0% of $20 limit $2.00 ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ TOKENS/MIN STOPPED › /munim │ ┃ 0 0 ⎿ tokenmunim: TokenMunim is on for this session. Click the TokenMu │ ┃ 0 total calls blocked │ ┃ │ ┃ ▸ BURN RATE $0.00/min · peak $0.00 │ ┃ │ ┃ ▸ TOKEN MIX waiting for a reply │ ┃ │ ┃ ▾ TASKS 1 · $0.00 │ ┃ TASK SPENT BUDGET │ ┃ USED CALLS │ ┃ ● general $0.00 no limit │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits TokenMunim

Draws

Pane · tokenmunim
TokenMunim cost control for AI agents ● LIVE Overview Tasks Activity Alerts Agents ────────────────────────────────────────────────────── 5h ━━━━━━━━━━━━━━━ 69% left ↻ ? · measuring SPENT COST/MIN $0.00 $0.00 0% of $20 limit $2.00 TOKENS/MIN STOPPED 0 0 0 total calls blocked ▸ BURN RATE $0.00/min · peak $0.00 ▸ TOKEN MIX waiting for a reply ▾ TASKS 1 · $0.00 TASK SPENT BUDGET USED CALLS ● general $0.00 no limit 9 › ▸ ACTIVITY 9 · last Bash ✓ ▸ ALERTS none session 30m · /munim for commands
README

TokenMunim

ci license Claude Code

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

Why

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:

  1. Where is the money going? Cost and tokens for every task.
  2. What did the agent actually do? A complete, readable record of every call.
  3. When should it stop? Rules that block a call before it runs.

What it does

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:

CircuitTrips whenThe agent is told
LoopThe same action fails 3 times in a rowStop retrying, read the error, change the approach
BurnSpend over the last two minutes passes the rate limitPause and work leaner
BudgetA task spends more than its budgetLeave this task and move to the next one
SessionThe session spends more than its budgetStop 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.

The pane

A dashboard that docks beside the transcript.

TabWhat it shows
OverviewPlan limits and runway, spend, cost and tokens per minute, calls stopped, the burn chart, the token mix, tasks, recent activity and alerts
TasksEvery task with its spend, tokens, budget bar and calls. › shows that task's calls
ActivityEvery call, newest first, or only one task's after you drill in. › opens a call's details
AlertsEverything the circuit breaker stopped, and what was done about it
AgentsThe main agent and each subagent, with their model, steps, tokens, cost and cache hit rate
  • Alerts wait at the end. The Alerts section stays folded behind a red ● 2 new badge until you open it, then lists every alert the badge counts, each halted task once.
  • Act on an alert from the pane. The alert has buttons: +$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.
  • Fold any section by clicking its title, and click again to open it. A section you open stays open: when room runs short the pane folds the others, never yours. From the keyboard: /munim fold activity, /munim unfold burn.
  • Fits any width. Columns drop out in a fixed order as the pane narrows, tabs shorten, and charts stretch or shrink, so nothing wraps or spills.
  • Never floods. Every list sits in a fixed frame. The overview fits itself to the pane's height, and older rows live in the ledger file.

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

Install

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.

Use 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.

CommandWhat it does
/munimShows the TokenMunim button under the prompt, for this session
/munim open / /munim closeOpens or closes the dashboard, like the button
/munim offHides the button and the dashboard; protection carries on
/munim statementPrints the session's statement
/munim ledgerWrites 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 endEnds the current task
/munim resetClears 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.

Settings

Change them in /plugin, under TokenMunim's configuration.

SettingDefaultMeaning
Budget per task$1A task that spends this much is halted
Session budget$20Every call is blocked once the session spends this much
Loop limit3Identical failures before a retry is blocked
Burn limit$2 a minuteMeasured over two minutes; pauses the agent once
Open the dashboard when a session startsOffTurn it on to see the dashboard without running /munim

What it costs

TokenMunim runs inside Claude Code, on your machine. It never calls a model itself, and nothing it records leaves your machine.

  • No extra model calls. Checking a call, recording it and drawing the pane all happen locally, in milliseconds, without using tokens.
  • One tool call per task. Starting a task is a tool call the agent makes, so it costs one small step. In sessions that load tools on demand, the first task also costs one lookup to load the tool.
  • How to avoid even that. Ask Claude to start each task in the same reply as its first command, so it adds no step of its own. Or skip tasks entirely: work then counts toward the general task, and every circuit except the per task budget still protects you.

Try it

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.

How it works

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.

Future plans

  • Budgets as a share of your plan. On a Pro or Max plan the 5 hour and weekly limits run out, not dollars, so a budget like 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.
  • Smarter loop detection. Catch thrashing (many different edits while the same test keeps failing) and no progress (many calls with no file or test changing), not only exact repeats.
  • Alerts for overnight runs. A desktop notification or a Slack message when a circuit trips, so an unattended run can wake you.

About the name

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.

Develop

claude plugin validate ./plugin
claude plugin test ./plugin

License

MIT

Source 5 files
hooks/register.tsx 428 lines
1import { 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}
428
hooks/ledger.ts 742 lines
1// 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}
742
hooks/pane.tsx 963 lines
1// 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}
963
hooks/chart.ts 87 lines
1// 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}
87
types/index.d.ts 124 lines
1export 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