SLOPSHOPPER

session-ledger

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

newpaneguardcommandtoaststatus
v0.1.0no licenseupdated 2026-10-26jade-hg/mods/session-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-ledger
│ ┃ Session ledger ✕ › fix the failing auth test and add an audit log call │ ┃ Session ledger started 8 │ ┃ ⏺ Read(src/auth.ts) │ ┃ Tokens context ⎿ Read 6 lines │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────── ⏺ Update(src/auth.ts) │ ┃ in 0 · out 0 · cache 0 · $0.42 ⎿ Added 2 lines, removed 1 line │ ┃ 5-hour ━━━━━━━━───────────────── 31% resets ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Files and pages 3 fil │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [project] /work/app │ ┃ └─ src\ ✻ Worked for 42s · done 4:20 PM │ ┃ ├─ ~ audit.ts │ ┃ ├─ ~ auth.ts › /ledger │ ┃ └─ ~ cache.ts ⎿ session-ledger: Opened the session ledger. │ ┃ │ ┃ Skills 0 loaded · │ ┃ No skills loaded yet. │ ┃ │ ┃ Agents │ ┃ No agents yet. │ ┃ │ ┃ /ledger copy · /ledger clear · /ledger close │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ session-ledger: ◆ ledger: 0 files · 0 pages · 49% ctx · $0.42 · 0 skills · /ledger to open

Draws

Pane · Session ledger
Session ledger started 8:23 AM · 30m Tokens context 49% of 200k ━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────────────────── in 0 · out 0 · cache 0 · $0.42 5-hour ━━━━━━━━───────────────── 31% resets NaN:NaN PM Files and pages 3 files · 0 pages [project] /work/app └─ src\ ├─ ~ audit.ts +0 ├─ ~ auth.ts +0 └─ ~ cache.ts +0 Skills 0 loaded · 0 always on No skills loaded yet. Agents No agents yet. /ledger copy · /ledger clear · /ledger close
README

Claude Code mods

Mods for Claude Code: plugins that change Claude Code's own interface.

session-ledger

A pane that sits beside the chat and keeps a running ledger of the session:

  • Tokens. Context fill, input, output and cache tokens, session cost, and your plan's 5-hour and weekly limits. A limit meter turns amber at 80% and red at 95%, and a banner and a one-time toast warn you.
  • Files and pages. A tree of every file Claude created or edited, grouped by the folder it lives in (your project, Claude's scratchpad, its memory folder), with lines added and removed. Published artifacts and Claude Docs are listed with their links.
  • Skills. The skills loaded this session, then any always-on skills your plugins inject.
  • Agents. A card per subagent, grouped as Running, Finished and Planned, with its task, effort and model, context use, tokens, approximate cost, elapsed time, and a progress bar.

Install

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.

Use

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.

What it can and cannot see

  • It records from the moment it loads, so work from earlier in a session is not listed. Token totals and cost cover the whole session.
  • A file is listed when Claude writes it with the Write, Edit or NotebookEdit tools. A file written by a shell command or a script does not appear.
  • An agent's progress bar is exact when the agent keeps a task list. Otherwise the bar is a striped estimate based on how long that type of agent has taken before, and the first few agents of a type show a moving stripe with no percent.
  • Agent cost is an estimate from published per-token prices, marked with ≈.
  • Always-on skills are found by reading the text a session-start hook injects under an "Always-on skills" line. Without such a hook, that list stays empty.
Source 3 files
hooks/register.tsx 831 lines
1import { 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}
831
hooks/format.ts 285 lines
1import 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_-]+/
285
types/index.d.ts 90 lines
1export 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