A pane beside the chat listing the files and pages Claude made, token and limit usage, loaded skills, and agents with their progress.

Mods for Claude Code: plugins that change Claude Code's own interface.
A pane that sits beside the chat and keeps a running ledger of the session:
At the prompt of a Claude Code terminal session:
/plugin install session-ledger --marketplace jade-hg/mods
From a shell:
claude plugin install session-ledger --marketplace jade-hg/mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with jade-hg/mods, then Add plugin.
The pane opens by itself when the terminal is at least 144 columns wide. In a narrower terminal, a one-line summary sits under the prompt; type /ledger to open the pane.
/ledger copy puts every file path and page link in the chat as plain text./ledger clear empties the file and page lists./ledger close closes the pane.hooks/register.tsx 831 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 LedgerAgent,
6 LedgerAgentStatus,
7 LedgerLimit,
8 LedgerTask,
9 LedgerUsage,
10} from '../types'
11import {
12 ARTIFACT_URL,
13 basename,
14 buildTree,
15 capitalize,
16 countPatch,
17 effortStyle,
18 extractAlwaysOn,
19 fmtClock,
20 fmtElapsed,
21 fmtSpan,
22 fmtTokens,
23 fmtUsd,
24 isFinished,
25 isRunning,
26 levelOf,
27 limitLabel,
28 lineCount,
29 median,
30 modelName,
31 progressOf,
32 samePath,
33 shortSkill,
34 stepCost,
35 type Usage,
36} from './format'
37
38const PANE = 'session-ledger'
39const TITLE = 'Session ledger'
40const DURATIONS = 'durations'
41
42// Bump a shape tag when that value's fields change: it declines what an
43// earlier version stored, which outlives a reload.
44const cwdAtom = atom({ plugin: 'session-ledger', key: 'cwd' } as const, '', { shape: 'cwd-1' })
45const filesAtom = atom({ plugin: 'session-ledger', key: 'files' } as const, [], { shape: 'files-1' })
46const pagesAtom = atom({ plugin: 'session-ledger', key: 'pages' } as const, [], { shape: 'pages-1' })
47const skillsAtom = atom({ plugin: 'session-ledger', key: 'skills' } as const, [], { shape: 'skills-1' })
48const alwaysOnAtom = atom({ plugin: 'session-ledger', key: 'alwaysOn' } as const, [], { shape: 'always-1' })
49const agentsAtom = atom({ plugin: 'session-ledger', key: 'agents' } as const, [], { shape: 'agents-1' })
50const tickAtom = atom({ plugin: 'session-ledger', key: 'tick' } as const, 0, { shape: 'tick-1' })
51const viewAtom = atom(
52 { plugin: 'session-ledger', key: 'view' } as const,
53 { agentsCollapsed: false, finishedOpen: true, plannedOpen: false },
54 { shape: 'view-1' },
55)
56const usageAtom = atom(
57 { plugin: 'session-ledger', key: 'usage' } as const,
58 {
59 startedAt: 0,
60 window: 1_000_000,
61 limits: [],
62 input: 0,
63 output: 0,
64 cacheRead: 0,
65 cacheWrite: 0,
66 warned: [],
67 } as LedgerUsage,
68 { shape: 'usage-1' },
69)
70
71const MASCOT = [' ▐▛███▜▌', '▝▜█████▛▘', ' ▘▘ ▝▝']
72
73type $ = EngineInterface
74
75// Typical run time per agent type, from earlier sessions: what the
76// estimated bar compares elapsed time with. Reloaded at session.start.
77let durations: Record<string, number[]> = {}
78let lastStatus: string | undefined
79
80async function safely(work: () => Promise<unknown>) {
81 try {
82 await work()
83 } catch {
84 // The ledger is a view: a failed record never fails the tool or turn.
85 }
86}
87
88async function recordFile($: $, path: string, isNew: boolean, added: number, removed: number) {
89 const at = await $.clock.now()
90 await update($, filesAtom, list => {
91 const i = list.findIndex(f => samePath(f.path, path))
92 if (i < 0) return [...list, { path, isNew, added, removed, at }].slice(-300)
93 const copy = [...list]
94 const old = copy[i]!
95 copy[i] = { ...old, added: old.added + added, removed: old.removed + removed, at }
96
97 return copy
98 })
99}
100
101function blankAgent(id: string, now: number): LedgerAgent {
102 return {
103 id,
104 description: 'agent',
105 type: '',
106 status: 'running',
107 startedAt: now,
108 ctxTokens: 0,
109 outputTokens: 0,
110 costUsd: 0,
111 tasks: [],
112 }
113}
114
115async function changeAgent($: $, id: string, fn: (agent: LedgerAgent) => LedgerAgent) {
116 const now = await $.clock.now()
117 await update($, agentsAtom, list => {
118 const i = list.findIndex(a => a.id === id)
119 if (i < 0) return [...list, fn(blankAgent(id, now))].slice(-100)
120 const copy = [...list]
121 copy[i] = fn(copy[i]!)
122
123 return copy
124 })
125}
126
127async function noteDuration($: $, agent: LedgerAgent | undefined, endedAt: number) {
128 if (agent === undefined || agent.type === '' || agent.status !== 'completed') return
129 const list = [...(durations[agent.type] ?? []), endedAt - agent.startedAt].slice(-10)
130 durations = { ...durations, [agent.type]: list }
131 await $.store.set(DURATIONS, durations)
132}
133
134async function addAlwaysOn($: $, names: string[]) {
135 if (names.length === 0) return
136 await update($, alwaysOnAtom, list => {
137 const fresh = names.filter(n => !list.includes(n))
138
139 return fresh.length === 0 ? list : [...list, ...fresh]
140 })
141}
142
143function textOf(content: unknown): string {
144 if (typeof content === 'string') return content
145 if (!Array.isArray(content)) return ''
146
147 return content
148 .map(block =>
149 typeof block === 'object' && block !== null && typeof (block as { text?: unknown }).text === 'string'
150 ? (block as { text: string }).text
151 : '',
152 )
153 .join('\n')
154}
155
156async function refresh($: $) {
157 const now = await $.clock.now()
158 const live = await $.session.usage()
159 const limits: LedgerLimit[] = live.rateLimits.map(l => ({
160 kind: l.kind,
161 percentUsed: l.percentUsed,
162 ...(l.resetsAt !== undefined ? { resetsAt: l.resetsAt } : {}),
163 }))
164
165 const prev = await read($, usageAtom)
166 const warned = [...prev.warned]
167 for (const limit of limits) {
168 const level = levelOf(limit.percentUsed)
169 const mark = `${limit.kind}:${level}:${limit.resetsAt ?? ''}`
170 if (level > 0 && !warned.includes(mark)) {
171 warned.push(mark)
172 const resets = limit.resetsAt ? ` It resets at ${fmtClock(Date.parse(limit.resetsAt), now)}.` : ''
173 $.ui.toast(`${capitalize(limitLabel(limit.kind))} limit ${Math.round(limit.percentUsed)}% used.${resets}`, {
174 timeoutMs: 8000,
175 })
176 }
177 }
178 const next: LedgerUsage = {
179 ...prev,
180 startedAt: live.startedAt,
181 window: live.context.window,
182 limits,
183 warned,
184 ...(live.context.percent !== undefined ? { contextPercent: live.context.percent } : {}),
185 ...(live.cost !== undefined ? { costUsd: live.cost.usd } : {}),
186 }
187 if (JSON.stringify(next) !== JSON.stringify(prev)) await update($, usageAtom, () => next)
188
189 // Agents: the engine's list carries status, queued agents and teammates.
190 const infos = await $.agent.list()
191 const agents = await read($, agentsAtom)
192 let merged = [...agents]
193 const ended: LedgerAgent[] = []
194 for (const info of infos) {
195 const status = info.status as LedgerAgentStatus
196 const i = merged.findIndex(a => a.id === info.id)
197 const old = i < 0 ? blankAgent(info.id, now) : merged[i]!
198 const wasPending = old.status === 'pending'
199 const fresh: LedgerAgent = {
200 ...old,
201 description: info.description || old.description,
202 type: info.type || old.type,
203 status,
204 ...(info.parentId !== undefined ? { parentId: info.parentId } : {}),
205 ...(wasPending && status !== 'pending' ? { startedAt: now } : {}),
206 }
207 if (isFinished(fresh) && fresh.endedAt === undefined) {
208 fresh.endedAt = now
209 ended.push(fresh)
210 }
211 if (i < 0) merged = [...merged, fresh]
212 else merged[i] = fresh
213 }
214 if (JSON.stringify(merged) !== JSON.stringify(agents)) await update($, agentsAtom, () => merged.slice(-100))
215 for (const agent of ended) await noteDuration($, agent, agent.endedAt ?? now)
216
217 // A narrow terminal waits to seat the pane: say the gist in the status line.
218 const panes = await $.ui.panes()
219 const isSeated = panes.some(p => p.id === PANE && p.isShown)
220 const status = isSeated ? undefined : await summary($)
221 if (status !== lastStatus) {
222 lastStatus = status
223 $.ui.status(status)
224 }
225}
226
227async function summary($: $) {
228 const [files, pages, skills, agents, usage] = await Promise.all([
229 read($, filesAtom),
230 read($, pagesAtom),
231 read($, skillsAtom),
232 read($, agentsAtom),
233 read($, usageAtom),
234 ])
235 const parts = [`◆ ledger: ${files.length} files`, `${pages.length} pages`]
236 if (usage.contextPercent !== undefined) parts.push(`${usage.contextPercent}% ctx`)
237 if (usage.costUsd !== undefined) parts.push(fmtUsd(usage.costUsd))
238 parts.push(`${skills.length} skills`)
239 const running = agents.filter(isRunning).length
240 if (running > 0) parts.push(`${running} agents running`)
241 for (const limit of usage.limits) {
242 if (levelOf(limit.percentUsed) > 0) parts.push(`▲ ${limitLabel(limit.kind)} ${Math.round(limit.percentUsed)}%`)
243 }
244
245 return parts.join(' · ') + ' · /ledger to open'
246}
247
248async function ledgerText($: $) {
249 const [files, pages] = await Promise.all([read($, filesAtom), read($, pagesAtom)])
250 if (files.length === 0 && pages.length === 0) return 'The ledger is empty: Claude has not written a file or page yet.'
251 const lines: string[] = []
252 if (files.length > 0) {
253 lines.push('Files Claude created or edited this session:')
254 for (const f of files) lines.push(`- ${f.path} (${f.isNew ? 'new' : 'edited'}, +${f.added} -${f.removed})`)
255 }
256 if (pages.length > 0) {
257 lines.push('Pages Claude published this session:')
258 for (const p of pages) lines.push(`- ${p.title}: ${p.url}`)
259 }
260
261 return lines.join('\n')
262}
263
264async function setTasks($: $, agentId: string, fn: (tasks: LedgerTask[]) => LedgerTask[]) {
265 await changeAgent($, agentId, agent => ({ ...agent, tasks: fn(agent.tasks) }))
266}
267
268export const register: Register = on => {
269 on('session.start', async ($, e, next) => {
270 await safely(async () => {
271 await update($, cwdAtom, () => e.cwd)
272 const stored = await $.store.get(DURATIONS)
273 if (typeof stored === 'object' && stored !== null) durations = stored as Record<string, number[]>
274 })
275 await $.command.register({
276 name: 'ledger',
277 description: 'Session ledger pane: open it, copy every path and link as text, or clear it',
278 argumentHint: 'open | copy | clear | close',
279 })
280 $.clock.every(5000, () => void safely(() => refresh($)))
281 $.clock.every(1000, () =>
282 void safely(async () => {
283 const agents = await read($, agentsAtom)
284 if (agents.some(isRunning)) await update($, tickAtom, n => n + 1)
285 }),
286 )
287 void safely(async () => {
288 const found = await $.session.messages()
289 if (Array.isArray(found)) {
290 for (const message of found) await addAlwaysOn($, extractAlwaysOn(message.text))
291 }
292 })
293 void safely(() => refresh($))
294 void $.ui.open({ id: PANE, title: TITLE, columns: 58 })
295
296 return next(e)
297 })
298
299 on('command.run', { command: 'ledger' }, async ($, e) => {
300 const arg = e.args.trim().toLowerCase()
301 if (arg === 'copy') return { text: await ledgerText($) }
302 if (arg === 'clear') {
303 await update($, filesAtom, () => [])
304 await update($, pagesAtom, () => [])
305
306 return { text: 'Cleared the file and page lists. Tokens, skills and agents keep counting.' }
307 }
308 if (arg === 'close') {
309 await $.ui.close({ id: PANE })
310
311 return { text: 'Closed the session ledger. Type /ledger to open it again.' }
312 }
313 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: 58 })
314
315 return {
316 text: opened.isPlaced ? 'Opened the session ledger.' : `The session ledger could not open yet: ${opened.reason}`,
317 }
318 })
319
320 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
321 const ran = await next(e)
322 if (ran.deny === undefined && ran.isError !== true) {
323 await safely(async () => {
324 const result = ran.result as { type?: string; structuredPatch?: { lines: string[] }[] } | undefined
325 const isNew = result?.type === 'create'
326 const counts = isNew ? { added: lineCount(e.content), removed: 0 } : countPatch(result?.structuredPatch)
327 await recordFile($, e.file_path, isNew, counts.added, counts.removed)
328 })
329 }
330
331 return ran
332 }).catch(($, e, next) => next(e))
333
334 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
335 const ran = await next(e)
336 if (ran.deny === undefined && ran.isError !== true) {
337 await safely(async () => {
338 const result = ran.result as { structuredPatch?: { lines: string[] }[] } | undefined
339 const counts = countPatch(result?.structuredPatch)
340 await recordFile($, e.file_path, false, counts.added, counts.removed)
341 })
342 }
343
344 return ran
345 }).catch(($, e, next) => next(e))
346
347 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
348 const ran = await next(e)
349 if (ran.deny === undefined && ran.isError !== true) {
350 await safely(() =>
351 recordFile($, e.notebook_path, false, e.edit_mode === 'delete' ? 0 : lineCount(e.new_source), 0),
352 )
353 }
354
355 return ran
356 }).catch(($, e, next) => next(e))
357
358 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
359 const ran = await next(e)
360 const agentId = e.agentId
361 if (agentId !== undefined && ran.deny === undefined && ran.isError !== true) {
362 await safely(() =>
363 setTasks($, agentId, () =>
364 e.todos.map((t, i) => ({ id: String(i), subject: t.content, status: t.status, activeForm: t.activeForm })),
365 ),
366 )
367 }
368
369 return ran
370 }).catch(($, e, next) => next(e))
371
372 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
373 const ran = await next(e)
374 const agentId = e.agentId
375 const id = (ran.result as { task?: { id?: string } } | undefined)?.task?.id
376 if (agentId !== undefined && id !== undefined && ran.deny === undefined && ran.isError !== true) {
377 const task: LedgerTask = {
378 id,
379 subject: e.subject,
380 status: 'pending',
381 ...(e.activeForm !== undefined ? { activeForm: e.activeForm } : {}),
382 }
383 await safely(() => setTasks($, agentId, tasks => [...tasks.filter(t => t.id !== id), task]))
384 }
385
386 return ran
387 }).catch(($, e, next) => next(e))
388
389 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
390 const ran = await next(e)
391 const agentId = e.agentId
392 const status = e.status
393 if (agentId !== undefined && ran.deny === undefined && ran.isError !== true) {
394 await safely(() =>
395 setTasks($, agentId, tasks =>
396 status === 'deleted'
397 ? tasks.filter(t => t.id !== e.taskId)
398 : tasks.map(t =>
399 t.id !== e.taskId
400 ? t
401 : {
402 ...t,
403 ...(e.subject !== undefined ? { subject: e.subject } : {}),
404 ...(e.activeForm !== undefined ? { activeForm: e.activeForm } : {}),
405 ...(status !== undefined ? { status } : {}),
406 },
407 ),
408 ),
409 )
410 }
411
412 return ran
413 }).catch(($, e, next) => next(e))
414
415 // Pages: Artifact publishes and Claude Docs births, read from the result text.
416 on('tool.call', async ($, e, next) => {
417 const ran = await next(e)
418 const tool = String(e.tool)
419 const isArtifact = tool === 'Artifact'
420 const isDoc = /Claude_Docs__(batch|create)$/.test(tool)
421 if ((!isArtifact && !isDoc) || ran.deny !== undefined || ran.isError === true) return ran
422
423 await safely(async () => {
424 const input = e as unknown as Record<string, unknown>
425 if (isArtifact && ((input.action ?? 'publish') !== 'publish' || input.asset === true)) return
426 const create = (input.container as { create?: { name?: unknown } } | undefined)?.create
427 if (isDoc && tool.endsWith('__batch') && create === undefined) return
428 const url = ARTIFACT_URL.exec(ran.text ?? '')?.[0]
429 if (url === undefined) return
430 const named =
431 typeof input.title === 'string'
432 ? input.title
433 : typeof create?.name === 'string'
434 ? create.name
435 : typeof input.file_path === 'string'
436 ? basename(input.file_path)
437 : url
438 const at = await $.clock.now()
439 await update($, pagesAtom, list => {
440 const i = list.findIndex(p => p.url === url)
441 if (i >= 0) return list
442 return [...list, { url, title: named, kind: isDoc ? ('doc' as const) : ('artifact' as const), at }].slice(-100)
443 })
444 })
445
446 return ran
447 }).catch(($, e, next) => next(e))
448
449 on('skill.prompt', async ($, e, next) => {
450 const ran = await next(e)
451 await safely(() => update($, skillsAtom, list => (list.includes(e.skill) ? list : [...list, e.skill])))
452
453 return ran
454 })
455
456 on('session.append', async ($, e, next) => {
457 const ran = await next(e)
458 await safely(() => addAlwaysOn($, extractAlwaysOn(textOf(e.message.content))))
459
460 return ran
461 }).catch(($, e, next) => next(e))
462
463 on('agent.spawn', async ($, e, next) => {
464 const ran = await next(e)
465 const agentId = ran.agentId
466 if (agentId !== undefined) {
467 const now = await $.clock.now()
468 await safely(() =>
469 changeAgent($, agentId, agent => ({
470 ...agent,
471 description: e.description || agent.description,
472 type: e.subagentType,
473 status: 'running',
474 startedAt: now,
475 model: ran.model,
476 ...(e.parentAgentId !== undefined ? { parentId: e.parentAgentId } : {}),
477 })),
478 )
479 }
480
481 return ran
482 }).catch(($, e, next) => next(e))
483
484 on('turn.step', async function* ($, e, next) {
485 const res = yield* next(e)
486 const usage = res.usage
487 if (usage !== null) {
488 await safely(async () => {
489 const counted = usage as Usage
490 await update($, usageAtom, u => ({
491 ...u,
492 input: u.input + counted.input_tokens,
493 output: u.output + counted.output_tokens,
494 cacheRead: u.cacheRead + counted.cache_read_input_tokens,
495 cacheWrite: u.cacheWrite + counted.cache_creation_input_tokens,
496 }))
497 const agentId = e.agentId
498 if (agentId === undefined) return
499 await changeAgent($, agentId, agent => ({
500 ...agent,
501 model: counted.model,
502 ...(e.effort !== undefined ? { effort: String(e.effort) } : {}),
503 ctxTokens:
504 counted.input_tokens + counted.cache_read_input_tokens + counted.cache_creation_input_tokens,
505 outputTokens: agent.outputTokens + counted.output_tokens,
506 costUsd: agent.costUsd + stepCost(counted),
507 }))
508 })
509 }
510
511 return res
512 })
513
514 on('turn.complete', async ($, e, next) => {
515 const ran = await next(e)
516 const agentId = e.agentId
517 if (agentId !== undefined) {
518 await safely(async () => {
519 const now = await $.clock.now()
520 const before = (await read($, agentsAtom)).find(a => a.id === agentId)
521 const status: LedgerAgentStatus = e.isAborted ? 'killed' : 'completed'
522 await changeAgent($, agentId, agent => ({ ...agent, status, endedAt: now }))
523 if (before !== undefined && !isFinished(before)) await noteDuration($, { ...before, status }, now)
524 })
525 }
526
527 return ran
528 })
529
530 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
531 const { Box, Text, Button } = $.ui.resolve(e)
532 const width = Math.max(28, e.component === 'Pane' ? e.props.bodyColumns : 56)
533 const [cwd, files, pages, skills, alwaysOn, agents, usage, view, tick] = await Promise.all([
534 read($, cwdAtom),
535 read($, filesAtom),
536 read($, pagesAtom),
537 read($, skillsAtom),
538 read($, alwaysOnAtom),
539 read($, agentsAtom),
540 read($, usageAtom),
541 read($, viewAtom),
542 read($, tickAtom),
543 ])
544 const now = await $.clock.now()
545
546 const bar = (w: number, frac: number | undefined, color: string, isStriped = false) => {
547 const cells = Math.max(1, w)
548 if (frac === undefined) {
549 const stripe = Array.from({ length: cells }, (_, i) => ((i + tick) % 4 < 2 ? '━' : '─')).join('')
550
551 return <Text color={color}>{stripe}</Text>
552 }
553 const n = Math.round(Math.min(1, Math.max(0, frac)) * cells)
554 const fill = isStriped
555 ? Array.from({ length: n }, (_, i) => ((i + tick) % 3 === 0 ? '─' : '━')).join('')
556 : '━'.repeat(n)
557
558 return (
559 <Box>
560 <Text color={color}>{fill}</Text>
561 <Text color="subtle">{'─'.repeat(cells - n)}</Text>
562 </Box>
563 )
564 }
565
566 const heading = (title: string, right: string) => (
567 <Box justifyContent="space-between" marginTop={1}>
568 <Text bold>{title}</Text>
569 <Text dimColor>{right}</Text>
570 </Box>
571 )
572
573 // Header and the limit warning.
574 const started = usage.startedAt > 0 ? `started ${fmtClock(usage.startedAt, now)} · ${fmtSpan(now - usage.startedAt)}` : ''
575 const worst = [...usage.limits].sort((a, b) => b.percentUsed - a.percentUsed)[0]
576 const running = agents.filter(isRunning)
577 let alert = null
578 if (worst !== undefined && levelOf(worst.percentUsed) > 0) {
579 const tone = levelOf(worst.percentUsed) >= 95 ? 'error' : 'warning'
580 const resetsAt = worst.resetsAt !== undefined ? Date.parse(worst.resetsAt) : undefined
581 const resets =
582 resetsAt !== undefined ? ` It resets at ${fmtClock(resetsAt, now)}, in ${fmtSpan(resetsAt - now)}.` : ''
583 const busy = running.length > 0 ? ` ${running.length} ${running.length === 1 ? 'agent is' : 'agents are'} still running.` : ''
584 alert = (
585 <Box borderStyle="round" borderColor={tone} paddingX={1} marginTop={1}>
586 <Text color={tone}>
587 {`▲ ${capitalize(limitLabel(worst.kind))} limit ${Math.round(worst.percentUsed)}% used.${resets}${busy}`}
588 </Text>
589 </Box>
590 )
591 }
592
593 // Tokens: context, totals, cost, and the plan's limit windows.
594 const ctxPct = usage.contextPercent
595 const ctxColor = ctxPct === undefined ? 'subtle' : ctxPct >= 95 ? 'error' : ctxPct >= 80 ? 'warning' : 'claude'
596 const windowLabel = usage.window >= 1_000_000 ? `${usage.window / 1_000_000}M` : fmtTokens(usage.window)
597 const totals = [
598 `in ${fmtTokens(usage.input)}`,
599 `out ${fmtTokens(usage.output)}`,
600 `cache ${fmtTokens(usage.cacheRead + usage.cacheWrite)}`,
601 ...(usage.costUsd !== undefined ? [fmtUsd(usage.costUsd)] : []),
602 ].join(' · ')
603 const limitRows = usage.limits.map(limit => {
604 const level = levelOf(limit.percentUsed)
605 const color = level >= 95 ? 'error' : level >= 80 ? 'warning' : 'success'
606 const resets = limit.resetsAt !== undefined ? `resets ${fmtClock(Date.parse(limit.resetsAt), now)}` : ''
607 const label = limitLabel(limit.kind).padEnd(7)
608 const pct = `${Math.round(limit.percentUsed)}%`.padStart(5)
609 const room = width - label.length - pct.length - resets.length - 2
610
611 return (
612 <Box>
613 <Text dimColor>{label}</Text>
614 {bar(Math.max(4, room), limit.percentUsed / 100, color)}
615 <Text color={level > 0 ? color : 'text'}>{pct}</Text>
616 <Text dimColor>{resets === '' ? '' : ' ' + resets}</Text>
617 </Box>
618 )
619 })
620
621 // Files and pages as a tree under the folder each one lives in.
622 const groups = buildTree(files, cwd)
623 const chip = { project: 'success', scratchpad: 'warning', memory: 'subtle', other: 'subtle' } as const
624 const fileTree = groups.map(group => (
625 <Box flexDirection="column" marginTop={1}>
626 <Box>
627 <Text color={chip[group.kind]}>{`[${group.kind}] `}</Text>
628 <Text dimColor wrap="truncate-start">
629 {group.root}
630 </Text>
631 </Box>
632 {group.lines.map(line => (
633 <Box justifyContent="space-between">
634 <Box flexShrink={1}>
635 <Text color="subtle">{line.prefix}</Text>
636 {line.file === undefined ? (
637 <Text color="suggestion">{line.name}</Text>
638 ) : (
639 <Text wrap="truncate-end">
640 <Text color={line.file.isNew ? 'success' : 'warning'}>{line.file.isNew ? '+ ' : '~ '}</Text>
641 {line.name}
642 </Text>
643 )}
644 </Box>
645 {line.file !== undefined && (
646 <Box flexShrink={0}>
647 <Text color="success">{` +${line.file.added}`}</Text>
648 {line.file.removed > 0 && <Text color="error">{` −${line.file.removed}`}</Text>}
649 </Box>
650 )}
651 </Box>
652 ))}
653 {group.kind === 'scratchpad' && (
654 <Text color="warning">Temp folder. Windows can clear it, so move anything you want to keep.</Text>
655 )}
656 </Box>
657 ))
658 const pageTree =
659 pages.length === 0 ? null : (
660 <Box flexDirection="column" marginTop={1}>
661 <Box>
662 <Text color="suggestion">[web] </Text>
663 <Text dimColor>claude.ai, private to you until shared</Text>
664 </Box>
665 {pages.map((page, i) => {
666 const isLast = i === pages.length - 1
667
668 return (
669 <Box flexDirection="column">
670 <Box justifyContent="space-between">
671 <Box flexShrink={1}>
672 <Text color="subtle">{isLast ? '└─ ' : '├─ '}</Text>
673 <Text wrap="truncate-end">
674 <Text color="suggestion">{page.kind === 'doc' ? '▤ ' : '↗ '}</Text>
675 {page.title}
676 </Text>
677 </Box>
678 <Text dimColor>{page.kind === 'doc' ? ' Claude Doc' : ' artifact'}</Text>
679 </Box>
680 <Box>
681 <Text color="subtle">{isLast ? ' ' : '│ '}</Text>
682 <Text color="suggestion" wrap="truncate-end">
683 {page.url}
684 </Text>
685 </Box>
686 </Box>
687 )
688 })}
689 </Box>
690 )
691
692 // Skills: loaded this session, then the always-on ones.
693 const shownAlways = alwaysOn.filter(n => !skills.includes(n))
694 const skillChips = (
695 <Box flexWrap="wrap" columnGap={2}>
696 {skills.map(name => (
697 <Text>
698 <Text color="claude">◆ </Text>
699 {shortSkill(name)}
700 </Text>
701 ))}
702 {shownAlways.map(name => (
703 <Text dimColor>{'◇ ' + shortSkill(name)}</Text>
704 ))}
705 {skills.length + shownAlways.length === 0 && <Text dimColor>No skills loaded yet.</Text>}
706 </Box>
707 )
708
709 // Agents, grouped as running, finished and planned.
710 const finished = agents.filter(isFinished)
711 const planned = agents.filter(a => a.status === 'pending')
712 const inner = Math.max(16, width - 11)
713 const card = (agent: LedgerAgent) => {
714 const done = isFinished(agent)
715 const style = effortStyle(agent.effort)
716 const progress = progressOf(agent, now, median(durations[agent.type] ?? []))
717 const isBad = agent.status === 'failed' || agent.status === 'killed'
718 const mark = done ? (isBad ? '✗' : '✓') : agent.status === 'pending' ? '·' : tick % 2 === 0 ? '●' : '○'
719 const markColor = isBad ? 'error' : agent.status === 'pending' ? 'subtle' : 'success'
720 const ctx = usage.window > 0 ? Math.round((agent.ctxTokens / usage.window) * 100) : 0
721 const elapsed = fmtElapsed((agent.endedAt ?? now) - agent.startedAt)
722 const nums =
723 agent.status === 'pending'
724 ? ''
725 : `ctx ${ctx}% · ${fmtTokens(agent.ctxTokens)} ≈ ${fmtUsd(agent.costUsd)} · ${elapsed}`
726 const model = modelName(agent.model)
727 const detail = [model, agent.effort ?? '', agent.type].filter(part => part !== '').join(' · ')
728
729 return (
730 <Box marginTop={1}>
731 <Box flexDirection="column" width={10} flexShrink={0}>
732 {MASCOT.map(row => (
733 <Text color="claude" dimColor={done}>
734 {row}
735 </Text>
736 ))}
737 </Box>
738 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
739 <Box justifyContent="space-between">
740 <Text bold wrap="truncate-end">
741 {capitalize(agent.description)}
742 </Text>
743 <Text color={markColor}>{' ' + mark}</Text>
744 </Box>
745 <Text wrap="truncate-end">
746 <Text color={style.color}>{style.word === '' ? '' : style.word + ' '}</Text>
747 <Text dimColor>{detail}</Text>
748 </Text>
749 <Box justifyContent="space-between" flexWrap="wrap">
750 <Text wrap="truncate-end">{progress.label}</Text>
751 <Text dimColor>{nums}</Text>
752 </Box>
753 {agent.status !== 'pending' && bar(inner, progress.frac, isBad ? 'error' : style.color, progress.isEstimate)}
754 </Box>
755 </Box>
756 )
757 }
758 const toggleView = (key: 'agentsCollapsed' | 'finishedOpen' | 'plannedOpen') => () =>
759 void update($, viewAtom, v => ({ ...v, [key]: !v[key] }))
760 const agentCounts = `${running.length} running · ${finished.length} finished · ${planned.length} planned`
761 const agentSection =
762 agents.length === 0 ? (
763 <Text dimColor>No agents yet.</Text>
764 ) : view.agentsCollapsed ? (
765 <Text dimColor>{agentCounts}</Text>
766 ) : (
767 <Box flexDirection="column">
768 {running.length > 0 && <Text dimColor>{`Running · ${running.length}`}</Text>}
769 {running.map(card)}
770 {finished.length > 0 && (
771 <Box marginTop={1}>
772 <Button key="finished" plain onPress={toggleView('finishedOpen')}>
773 {`${view.finishedOpen ? '▾' : '▸'} Finished · ${finished.length}`}
774 </Button>
775 </Box>
776 )}
777 {view.finishedOpen && finished.map(card)}
778 {planned.length > 0 && (
779 <Box marginTop={1}>
780 <Button key="planned" plain onPress={toggleView('plannedOpen')}>
781 {`${view.plannedOpen ? '▾' : '▸'} Planned · ${planned.length}`}
782 </Button>
783 </Box>
784 )}
785 {view.plannedOpen && planned.map(card)}
786 </Box>
787 )
788
789 return (
790 <Box flexDirection="column" width={width}>
791 <Box justifyContent="space-between">
792 <Text bold color="claude">
793 Session ledger
794 </Text>
795 <Text dimColor>{started}</Text>
796 </Box>
797 {alert}
798
799 {heading('Tokens', ctxPct === undefined ? `context window ${windowLabel}` : `context ${ctxPct}% of ${windowLabel}`)}
800 {bar(width, ctxPct === undefined ? 0 : ctxPct / 100, ctxColor)}
801 <Text dimColor>{totals}</Text>
802 {limitRows}
803
804 {heading('Files and pages', `${files.length} files · ${pages.length} pages`)}
805 {files.length + pages.length === 0 && (
806 <Text dimColor>Nothing yet. Files Claude writes or edits, and pages it publishes, show up here.</Text>
807 )}
808 {fileTree}
809 {pageTree}
810
811 {heading('Skills', `${skills.length} loaded · ${shownAlways.length} always on`)}
812 {skillChips}
813
814 <Box justifyContent="space-between" marginTop={1}>
815 <Text bold>Agents</Text>
816 {agents.length > 0 && (
817 <Button key="collapse" plain onPress={toggleView('agentsCollapsed')}>
818 {view.agentsCollapsed ? 'Expand' : 'Collapse'}
819 </Button>
820 )}
821 </Box>
822 {agentSection}
823
824 <Box marginTop={1}>
825 <Text dimColor>/ledger copy · /ledger clear · /ledger close</Text>
826 </Box>
827 </Box>
828 )
829 })
830}
831hooks/format.ts 285 lines1import type { LedgerAgent, LedgerFile } from '../types'
2
3// Pure helpers for the ledger: no `$`, so the tests call them directly.
4
5export type RootKind = 'project' | 'scratchpad' | 'memory' | 'other'
6
7export type TreeLine = {
8 prefix: string
9 name: string
10 file?: LedgerFile
11}
12
13export type TreeGroup = {
14 kind: RootKind
15 root: string
16 lines: TreeLine[]
17}
18
19export type Usage = {
20 model: string
21 input_tokens: number
22 output_tokens: number
23 cache_read_input_tokens: number
24 cache_creation_input_tokens: number
25}
26
27const SCRATCHPAD = /^(.*[\\/]claude[\\/].*?[\\/]scratchpad)[\\/]/i
28const MEMORY = /^(.*[\\/]\.claude[\\/]projects[\\/][^\\/]+[\\/]memory)[\\/]/i
29
30const fold = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
31
32const dirname = (path: string) => {
33 const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
34 return cut > 0 ? path.slice(0, cut) : path
35}
36
37export const basename = (path: string) => {
38 const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
39 return path.slice(cut + 1)
40}
41
42export const samePath = (a: string, b: string) => fold(a) === fold(b)
43
44export function rootOf(path: string, cwd: string): { kind: RootKind; root: string } {
45 const scratch = SCRATCHPAD.exec(path)
46 if (scratch?.[1]) return { kind: 'scratchpad', root: scratch[1] }
47 const memory = MEMORY.exec(path)
48 if (memory?.[1]) return { kind: 'memory', root: memory[1] }
49 if (cwd !== '' && fold(path).startsWith(fold(cwd) + '/')) {
50 return { kind: 'project', root: cwd.replace(/[\\/]+$/, '') }
51 }
52
53 return { kind: 'other', root: dirname(path) }
54}
55
56type Node = { name: string; children: Map<string, Node>; file?: LedgerFile }
57
58const ORDER: RootKind[] = ['project', 'scratchpad', 'memory', 'other']
59
60export function buildTree(files: readonly LedgerFile[], cwd: string): TreeGroup[] {
61 const groups = new Map<string, { kind: RootKind; root: string; top: Node }>()
62
63 for (const file of files) {
64 const { kind, root } = rootOf(file.path, cwd)
65 const id = fold(root)
66 let group = groups.get(id)
67 if (group === undefined) {
68 group = { kind, root, top: { name: '', children: new Map() } }
69 groups.set(id, group)
70 }
71 const parts = file.path.slice(root.length).split(/[\\/]+/).filter(part => part !== '')
72 let node = group.top
73 for (const part of parts) {
74 let child = node.children.get(part.toLowerCase())
75 if (child === undefined) {
76 child = { name: part, children: new Map() }
77 node.children.set(part.toLowerCase(), child)
78 }
79 node = child
80 }
81 node.file = file
82 }
83
84 const walk = (node: Node, indent: string, out: TreeLine[]) => {
85 const kids = [...node.children.values()].sort((a, b) => {
86 const aDir = a.file === undefined
87 const bDir = b.file === undefined
88 if (aDir !== bDir) return aDir ? -1 : 1
89
90 return a.name.localeCompare(b.name)
91 })
92 kids.forEach((kid, i) => {
93 const isLast = i === kids.length - 1
94 const isDir = kid.file === undefined
95 out.push({
96 prefix: indent + (isLast ? '└─ ' : '├─ '),
97 name: isDir ? kid.name + '\\' : kid.name,
98 file: kid.file,
99 })
100 walk(kid, indent + (isLast ? ' ' : '│ '), out)
101 })
102 }
103
104 return [...groups.values()]
105 .sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind) || a.root.localeCompare(b.root))
106 .map(group => {
107 const lines: TreeLine[] = []
108 walk(group.top, '', lines)
109
110 return { kind: group.kind, root: group.root, lines }
111 })
112}
113
114export function countPatch(
115 hunks: readonly { lines: readonly string[] }[] | undefined,
116): { added: number; removed: number } {
117 let added = 0
118 let removed = 0
119 for (const hunk of hunks ?? []) {
120 for (const line of hunk.lines) {
121 if (line.startsWith('+')) added += 1
122 else if (line.startsWith('-')) removed += 1
123 }
124 }
125
126 return { added, removed }
127}
128
129export const lineCount = (text: string) =>
130 text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0)
131
132export function fmtTokens(n: number): string {
133 if (n < 1000) return String(Math.round(n))
134 if (n < 10_000) return (n / 1000).toFixed(1) + 'k'
135 if (n < 1_000_000) return Math.round(n / 1000) + 'k'
136
137 return (n / 1_000_000).toFixed(2) + 'M'
138}
139
140export const fmtUsd = (usd: number) => (usd > 0 && usd < 0.01 ? '<$0.01' : '$' + usd.toFixed(2))
141
142export function fmtElapsed(ms: number): string {
143 const total = Math.max(0, Math.floor(ms / 1000))
144 const h = Math.floor(total / 3600)
145 const m = Math.floor((total % 3600) / 60)
146 const s = String(total % 60).padStart(2, '0')
147
148 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
149}
150
151export function fmtSpan(ms: number): string {
152 const minutes = Math.max(0, Math.round(ms / 60_000))
153 if (minutes < 60) return `${minutes}m`
154 const h = Math.floor(minutes / 60)
155 if (h < 48) return `${h}h ${minutes % 60}m`
156
157 return `${Math.round(h / 24)}d`
158}
159
160const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
161
162export function fmtClock(at: number, now: number): string {
163 const d = new Date(at)
164 const h = d.getHours()
165 const m = d.getMinutes()
166 const time = `${h % 12 === 0 ? 12 : h % 12}${m === 0 ? '' : ':' + String(m).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`
167
168 return at - now > 20 * 3600_000 ? `${DAYS[d.getDay()]} ${time}` : time
169}
170
171export function modelName(id: string | undefined): string {
172 if (id === undefined || id === '') return ''
173 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2}))?(?!\d)/i.exec(id)
174 if (m === null) return id
175 const family = m[1] ?? ''
176
177 return family[0]!.toUpperCase() + family.slice(1).toLowerCase() + ' ' + m[2] + (m[3] ? '.' + m[3] : '')
178}
179
180// Per-million-token prices from the Claude API model table (2026-10-06).
181// Cache writes are taken as 1.25x input, as for the 5-minute cache.
182type Price = { input: number; output: number; read: number }
183
184const PRICES: [RegExp, Price][] = [
185 [/fable|mythos/i, { input: 10, output: 50, read: 0.25 }],
186 [/opus-5-5/i, { input: 4, output: 20, read: 0.2 }],
187 [/opus/i, { input: 5, output: 25, read: 0.5 }],
188 [/sonnet-5/i, { input: 2, output: 10, read: 0.2 }],
189 [/sonnet/i, { input: 3, output: 15, read: 0.3 }],
190 [/haiku-5-5/i, { input: 0.1, output: 0.5, read: 0.01 }],
191 [/haiku/i, { input: 1, output: 5, read: 0.1 }],
192]
193
194export function stepCost(usage: Usage): number {
195 const price = PRICES.find(([re]) => re.test(usage.model))?.[1] ?? { input: 4, output: 20, read: 0.2 }
196
197 return (
198 (usage.input_tokens * price.input +
199 usage.output_tokens * price.output +
200 usage.cache_read_input_tokens * price.read +
201 usage.cache_creation_input_tokens * price.input * 1.25) /
202 1_000_000
203 )
204}
205
206const EFFORT: Record<string, { word: string; color: string }> = {
207 low: { word: 'light', color: '#5fb3a8' },
208 medium: { word: 'medium', color: '#6b9be0' },
209 high: { word: 'careful', color: '#d9934a' },
210 xhigh: { word: 'thorough', color: '#b49ad8' },
211 max: { word: 'exhaustive', color: '#e0675a' },
212}
213
214export const effortStyle = (effort: string | undefined) =>
215 (effort !== undefined ? EFFORT[effort] : undefined) ?? { word: '', color: '#8e877e' }
216
217export const isRunning = (a: LedgerAgent) => a.status === 'running' || a.status === 'waiting' || a.status === 'idle'
218export const isFinished = (a: LedgerAgent) => a.status === 'completed' || a.status === 'failed' || a.status === 'killed'
219
220export type Progress = {
221 label: string
222 // 0 to 1; undefined when nothing tells how far along the agent is.
223 frac: number | undefined
224 isEstimate: boolean
225}
226
227export function progressOf(agent: LedgerAgent, now: number, typicalMs: number | undefined): Progress {
228 const tasks = agent.tasks
229 if (tasks.length > 0) {
230 const done = tasks.filter(t => t.status === 'completed').length
231 const current = tasks.find(t => t.status === 'in_progress')
232 const step = current ? ' · ' + (current.activeForm ?? current.subject) : ''
233
234 return { label: `${done}/${tasks.length}${step}`, frac: done / tasks.length, isEstimate: false }
235 }
236 if (isFinished(agent)) {
237 const word = agent.status === 'completed' ? 'done' : agent.status === 'failed' ? 'failed' : 'stopped'
238
239 return { label: word, frac: 1, isEstimate: false }
240 }
241 if (agent.status === 'pending') return { label: 'queued', frac: 0, isEstimate: false }
242 if (typicalMs !== undefined && typicalMs > 0) {
243 const frac = Math.min(0.95, (now - agent.startedAt) / typicalMs)
244
245 return { label: `~${Math.round(frac * 100)}% · no task list, estimated`, frac, isEstimate: true }
246 }
247
248 return { label: 'working · no task list', frac: undefined, isEstimate: true }
249}
250
251export function median(values: readonly number[]): number | undefined {
252 if (values.length < 3) return undefined
253 const sorted = [...values].sort((a, b) => a - b)
254
255 return sorted[Math.floor(sorted.length / 2)]
256}
257
258// The always-on skill list a session-start hook injects reads
259// "- <name>: ..." or "- <name> (1 of 2): ..." under an "Always-on skills" line.
260export function extractAlwaysOn(text: string): string[] {
261 if (!text.includes('Always-on skills')) return []
262 const names = new Set<string>()
263 for (const m of text.matchAll(/^- ([A-Za-z0-9_.:-]+)(?: \(\d+ of \d+\))?:/gm)) {
264 if (m[1] !== undefined) names.add(m[1])
265 }
266
267 return [...names]
268}
269
270export const shortSkill = (name: string) => name.slice(name.lastIndexOf(':') + 1)
271
272export function limitLabel(kind: string): string {
273 if (kind === 'five_hour') return '5-hour'
274 if (kind === 'seven_day') return 'weekly'
275 if (kind === 'spend_limit') return 'spend'
276
277 return kind.replace(/_/g, ' ')
278}
279
280export const levelOf = (percent: number) => (percent >= 95 ? 95 : percent >= 80 ? 80 : 0)
281
282export const capitalize = (text: string) => (text === '' ? text : text[0]!.toUpperCase() + text.slice(1))
283
284export const ARTIFACT_URL = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+/
285types/index.d.ts 90 lines1export type LedgerFile = {
2 path: string
3 isNew: boolean
4 added: number
5 removed: number
6 at: number
7}
8
9export type LedgerPage = {
10 url: string
11 title: string
12 kind: 'artifact' | 'doc'
13 at: number
14}
15
16export type LedgerTask = {
17 id: string
18 subject: string
19 status: 'pending' | 'in_progress' | 'completed'
20 activeForm?: string
21}
22
23export type LedgerAgentStatus =
24 | 'pending'
25 | 'running'
26 | 'waiting'
27 | 'idle'
28 | 'completed'
29 | 'failed'
30 | 'killed'
31
32export type LedgerAgent = {
33 id: string
34 description: string
35 type: string
36 status: LedgerAgentStatus
37 parentId?: string
38 model?: string
39 effort?: string
40 startedAt: number
41 endedAt?: number
42 // Input tokens of the agent's latest request: how full its context is.
43 ctxTokens: number
44 outputTokens: number
45 costUsd: number
46 tasks: LedgerTask[]
47}
48
49export type LedgerLimit = {
50 kind: string
51 percentUsed: number
52 resetsAt?: string
53}
54
55export type LedgerUsage = {
56 startedAt: number
57 contextPercent?: number
58 window: number
59 costUsd?: number
60 limits: LedgerLimit[]
61 input: number
62 output: number
63 cacheRead: number
64 cacheWrite: number
65 // Limit warnings already shown, as `<kind>:<threshold>:<resetsAt>`.
66 warned: string[]
67}
68
69export type LedgerView = {
70 agentsCollapsed: boolean
71 finishedOpen: boolean
72 plannedOpen: boolean
73}
74
75declare module 'claude-code' {
76 interface PluginState {
77 'session-ledger': {
78 cwd: Shaped<string>
79 files: Shaped<LedgerFile[]>
80 pages: Shaped<LedgerPage[]>
81 skills: Shaped<string[]>
82 alwaysOn: Shaped<string[]>
83 agents: Shaped<LedgerAgent[]>
84 usage: Shaped<LedgerUsage>
85 view: Shaped<LedgerView>
86 tick: Shaped<number>
87 }
88 }
89}
90