SLOPSHOPPER

turn-receipt

After each turn, it displays the turn's tokens and cost in US dollars, its subagents' share, and the session's running total. Commands: `/receipt` shows and…

newbandspinnercommandprocessagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-receipt
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. Worked for 42s • receipt · in 2.1k · out 1.5k · cache read 91k · cache write 4.3k · $0.00 · opus-5-5 • session · 1 turn · 98.9k tokens · $0.420 › /receipt ⎿ turn-receipt: This session (preview-session): ⎿ turn-receipt: turns 1 | agent runs 0 | $0.420 ⎿ turn-receipt: tokens 98.9k: in:2.1k | out:1.5k | cache read:91k | cache write:4.3k ⎿ turn-receipt: Saved. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Turn line
Worked for 42s • receipt · in 2.1k · out 1.5k · cache read 91k · cache write 4.3k · $0.00 · opus-5-5 • session · 1 turn · 98.9k tokens · $0.420
README

Turn Receipt

Turn receipt is a Claude Code mod that prints a receipt beneath each response from an LLM. The receipt shows last turn's cost in tokens and US dollars, what its subagents cost, and the cumulative session cost. It also saves each session's total and keeps a usage + cost tally per calendar month.

Sample receipt in the CLI:

- receipt · in 4 · out 26 · cache read 109.5k · cache write 1.3k · $0.054 · fable-5-1
- agents · 2 runs · in 292 · out 3.1k · cache read 314.7k · cache write 76.3k · $0.269 · turn total $0.323 · fable-5-1
- session · 4 turns · 3 agent runs · 1.5M tokens · $4.88

You can configure the receipt to not render on every turn, and it will keep your monthly and session tallies. You can retrieve these via the /receipt-* commands explained below.

Contents

What it shows

The receipt shows:

  • receipt: the main loop's four token counts as the API reported them: uncached input, output, cache reads and cache writes, the turn's cost in US dollars, and the model that answered.
  • agents: present when the turn ran subagents: how many runs, token counts, the cost, the whole turn's cost, and the model of the last run.
  • session: the running totals for the session: total turns, total subagent runs, all four token kinds summed (cache reads included), and the ledger's total.

The token counts come from the turn.complete event's usage. The dollars come from $.session.usage().cost.usd, the same ledger /cost reports.

Commands

CommandWhat it does
/receiptShows this session's turns, subagent runs, tokens and dollars, and saves them.
/receipt-total [days]Sums every session record saved in the last N days. N is passed as a parameter; when omitted, it will use the keepSessionDetailsNNDays configuration setting (14 by default). The total receipt includes sessions, turns, subagent runs, tokens and dollars. The current session is saved first, so it is counted.
/receipt-month [YYYY-MM]Shows a month's tally: sessions, turns, subagent runs, tokens and dollars. This month when not given, followed by one line per earlier month kept. The current session is saved first, so its latest turns are in.
/receipt-clearSaves this session, then drops every other session record. The monthly tallies stay.
/receipt-total 2
Last 2 days: 9 sessions
  turns 61 | agent runs 9 | $13.40
  tokens 12.1M: in:390.2k | out:131.7k | cache read:11.0M | cache write:602.4k
/receipt-month
2026-10: 12 sessions
  turns 184 | agent runs 31 | $42.17
  tokens 38.6M: in:1.2M | out:410.5k | cache read:35.1M | cache write:1.9M

Configuration Options

Six options, declared in the manifest's userConfig. Each is a row in /config under the plugin's name. Changing the value of an option reloads the module with the new value.

Option/config rowTypeDefaultWhat it does
renderReceiptAsRender the receipt asui \text \noneuiui: a markdown receipt after every turn. In the terminal a ui.render hook on the TurnDuration row draws it beneath the turn's duration; in the desktop app the latest receipt is drawn in the band above the prompt. <br />text: one plain line, `receipt \in:200 \… // session \…, returned as the turn.complete text, which the engine shows beneath the answer. Less intrusive than ui, and the mode to use with the Agent SDK. <br />none: no receipt after each turn. The tally is still computed and saved; read it with /receipt, /receipt-total and /receipt-month`.
showSessionTotalsShow Session TotalsbooleantrueAdds the session tally to each receipt.
showSubAgentDetailsShow Subagent DetailsbooleantrueAdds the agents line when the turn ran subagents. When it is set to false, the subagents' dollars are folded into the receipt line's figure, so you still see the whole turn's cost.
saveEveryNNTurnsSave every NN turnsnumber10Saves the session's record every NN main-loop turns. A turn is one prompt through to Claude's final answer. By saving every NN turns, we prevent losing data if the session crashes or is force-terminated and the session.end hook never runs: at most NN turns are lost. 0 saves only at session end and on the commands, so it loses more.
keepSessionDetailsNNDaysKeep detailed session records for NN daysnumber14Determines how long session records are kept. Mod data is saved in a json file, so we trim session data to prevent bloating the file and slowing the turns. <br />The monthly tallies are kept apart, clearing session data does not affect monthly tallies.
allowEmptyReceiptsAllow empty receiptsbooleanfalseAlso prints a receipt, and counts the turn, when the turn reported no usage and moved no ledger. An interrupt before any response or an API error causes one. Off, such a turn gets no receipt and is not counted. On, every turn has a receipt, which keeps the stream regular for an Agent SDK harness.

Install

Requires Claude Code 2.1.287 or later. Built and tested on 2.1.299.

This repository is a plugin marketplace (see .claude-plugin/marketplace.json at its root), so one line at the prompt of a Claude Code session installs the plugin:

/plugin install turn-receipt --marketplace jaimerodriguez/claude-code-plugins

Answer y to add the marketplace, pick a scope (user is first), and set the options or keep their defaults.

From the shell:

claude plugin marketplace add jaimerodriguez/claude-code-plugins
claude plugin install turn-receipt@jaime-rodriguez-claude-code-plugins --scope user

To update later:

claude plugin update turn-receipt

then /reload-plugins in a running session, or start a new one.

Layout

turn-receipt/
├── .claude-plugin/
│   ├── plugin.json            manifest: name, version, userConfig options
│   └── types/                 written by Claude Code when the plugin loads; gitignored
├── hooks/
│   ├── hooks.json             names the hooks module: { "modules": ["./turn-receipt.mjs"] }
│   ├── turn-receipt.mjs       the hooks module (all the logic)
│   └── turn-receipt.test.ts   tests, run by `claude plugin test`
├── .gitignore
├── CHANGELOG.md               dated entries, Keep a Changelog format
├── LICENSE                    Apache-2.0
├── tsconfig.json              extends the generated .claude-plugin/types/tsconfig.json
└── README.md

There is no package.json, no dependencies and no bundling step. The hooks module is plain JavaScript that Claude Code loads directly. "Build" here means validate, type-check and test.

Build and test

Run these from the repository root. All three must pass before a commit.

  1. Validate. Reads the manifest and the module's source the way the engine will, and lists what the module hooks and calls. It also reports anything the engine would refuse, such as a gating hook without a .catch handler or a userConfig field without a title.
   claude plugin validate ./turn-receipt
   claude plugin validate .            # the marketplace manifest at the repo root

Add --strict to fail on warnings, --json for a machine-readable report.

  1. Test. Runs every *.test.ts under the folder against the real engine. The test's own hooks sit beneath the plugin's and stand in for the engine: they answer session.usage from a ledger the test moves, agent.spawn with a model and an id, turn.complete with the answer, and the four store.* events from a Map the test can inspect.
   claude plugin test ./turn-receipt

Exits 1 when a test fails. To test an option value, pass it to the test: test(name, { options: { showSessionTotals: false } }, body). Left out, the plugin gets its manifest's defaults.

  1. Type-check. Claude Code writes the plugin's TypeScript types to .claude-plugin/types/ each time it loads the plugin (interactive or headless). Nothing generates them otherwise, so run the plugin once first (see the next section), then:
   tsc -p turn-receipt

No output means it passed. The generated claude-code/index.d.ts is also the API reference: grep it for an event or noun ('turn.complete', session.usage, store.keys) and read the declaration it lands on.

A quick smoke test that proves the module loads and settles on a real turn, without an interactive session:

claude -p "Reply with the single word OK." --plugin-dir ./turn-receipt --max-turns 1 \
  --debug-file /tmp/turn-receipt-debug.log
grep turn-receipt /tmp/turn-receipt-debug.log

Run it from the folder while developing

--plugin-dir loads the plugin from disk for one session, whatever is installed:

claude --plugin-dir ./turn-receipt

In an interactive session that folder is watched. Saving a file reloads the hooks module: register runs again, session.start fires again, and the module's own variables start over. The session's counters are read back from the saved record on reload, so at most saveEveryNNTurns turns of the count are lost. Values in $.store are the host's and survive a reload.

Where no flag can be given (a session the desktop app starts), set CLAUDE_CODE_PLUGIN_DIRS to the folder's absolute path in the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/your/plugin-or-mod"
  }
}

If the plugin is installed from this repository's folder as a local marketplace (claude plugin marketplace add /path/to/claude-code-plugins), the installed plugin is read from the folder itself, not from a copy. Edit, then /reload-plugins in the session, with no new version and no reinstall. claude plugin list shows which folder a plugin is read from.

Debug

  • Run with claude --debug (or --debug-file <path>) during development. The debug log has a line for every hook that was skipped and every result the engine refused, prefixed turn-receipt:. By default the log goes to ~/.claude/debug/<session-id>.txt, with ~/.claude/debug/latest pointing at the newest. Filter categories with --debug "hooks" to cut the noise.
  • A failing hook is skipped, not fatal. The chain continues without it, so a plugin that seems to do nothing has usually been told why in the log. While a --plugin-dir folder is watched, the transcript also shows one dim line naming the plugin, the event and the reason. The agent.spawn hook is a gating hook, so it has a .catch that passes the spawn on; keep that if you change it.
  • No receipt beneath an answer? Check that renderReceiptAs is not none. If the turn may have counted nothing (an interrupt, an API error), set allowEmptyReceipts to true to receipt those too. With ui, a surface that draws neither the TurnDuration row nor the band above the prompt (vscode, mobile) shows nothing; use text there. Another plugin's ui.render hook on the same row, or its own turn.complete text, can also take the place.
  • cost n/a in the receipt means the host keeps no cost ledger. The CLI always has one.
  • Check the saved records. The store is the file ~/.claude/plugins/store/turn-receipt_<marketplace>-<hash>.json, plain JSON keyed session:<id> and month:<YYYY-MM>.
  • Options not taking effect? Plugin options are read from user settings, --settings, or managed settings, never a project's .claude/settings.json.
  • Reproduce in a test before fixing. hooks/turn-receipt.test.ts already drives every event the module hooks. Copy the closest test, move the fake ledger or usage to match the case, and assert on the text or on the Map behind the store.
  • Debugging in Claude Desktop. Use the /debug command, then /reload-plugins, and check the logs.

Release a new version

  1. Bump version in .claude-plugin/plugin.json and add a dated entry to CHANGELOG.md.
  2. Run validate, test and type-check (above).
  3. Commit and push to main.
  4. People who installed from this marketplace get it with claude plugin update turn-receipt followed by /reload-plugins.

How it works

HookWhat it does
session.startRegisters the four commands, reads the ledger once (so a resumed session's earlier spend is not billed to its first turn), loads this session's saved record, and prunes session records older than keepSessionDetailsNNDays and months older than 12.
agent.spawnReads the ledger and credits the movement to the main loop, then lets the spawn go on.
turn.completeA subagent's run (agentId set): credits the ledger's movement and the run's tokens to the agents. The main loop's turn: credits the movement to the main loop, keeps the receipt as markdown for the turn's closing row (renderReceiptAs ui) or returns it as the hook's one-line text (text), starts the next turn's count, and saves every N turns.
ui.render (TurnDuration)Registered only with renderReceiptAs ui. Claims the receipt the turn left and draws it, the duration line first, then the markdown. A redraw of the same row gets the same receipt; a row with none is the engine's own. Terminal only.
ui.render (AbovePrompt)Registered only with renderReceiptAs ui. Draws the latest receipt in the band above the prompt on a surface that is not the terminal (the desktop app); on the terminal it stands down, and it yields while a survey holds the band.
session.endBanks into the month and saves the record one last time.
command.runAnswers /receipt, /receipt-total, /receipt-month and /receipt-clear.

The plugin never rewrites the answer: it passes it through, and the receipt is drawn on the turn's closing row (ui) or is the hook's text beneath it (text).

Splitting dollars between loops. The ledger is one figure for the whole session, so the plugin reads it at each spawn and at each turn's end, and credits each movement to the loop whose event ended the window:

Reading taken atCredited to
agent.spawn, before the subagent startsthe main loop
a subagent run's turn.completethe agents
the main loop's turn.completethe main loop

A foreground subagent blocks the main loop while it runs, so for those the split is exact.

Persistence. The plugin stores a tally for every session, and a cumulative tally for the month.

Each individual session record, under session:<id>, is the session's running total: the session id, when it began, when it was saved, turns, subagent runs, the token counts, the ledger's dollars, and how much of all that is already in a month's tally. It is written at chosen moments, never on every event:

  • every saveEveryNNTurns turns (10 by default; 0 turns this off),
  • at session.end, which shares one short time bound with everything else that runs at exit, and
  • on any /receipt-* command.

A session with nothing in it (no turns, no subagent runs, no tokens, and a ledger at zero) is not written at any of these moments, so a session opened and closed without a prompt leaves no record. At session.start the plugin reads the record back, so a resumed session or a hot-reloaded module continues its count instead of starting at zero.

The month record, under month:<YYYY-MM>, is the tally of every session that saved in that calendar month (local time): sessions, turns, subagent runs, tokens and dollars. At every save, the plugin takes what the session has added since its last save and adds it to the month of the save, then notes in the session record that it is banked. The next save adds only what came after, so a session saved ten times is counted once, and a save that adds nothing leaves the month alone. A session is counted among the month's sessions the first time it banks something into it; a session that straddles midnight on the last day of a month puts what it saved before into one month and the rest into the next. A record saved by 0.1.x, with no banked figure, banks its whole total at its next save.

The two records have different lives, which is the point. Session records are dropped at session.start once older than keepSessionDetailsNNDays (14 by default; this session's is never dropped), or all at once by /receipt-clear, so the store stays small whatever the number of sessions. Month records are kept for 12 months. Clearing the sessions loses nothing from the months, since everything saved was banked first.

Known limitations

  • The dollar figure is the host's ledger, what /cost reports: list price unless the host is configured with its own pricing. On a subscription it is shown, but it is not what you are billed.
  • A turn's cost is a delta. Anything else that moves the ledger during a window (a compaction's own request, a plugin's own model call) is credited to whichever loop's event ended that window.
  • Background subagents run beside the main loop. What the main loop spends while one runs is credited to the agents. A background subagent that finishes after the turn ends is counted on the next turn's agents line.
  • A subagent's tokens are its own loop's. The receipt line does not include them; the agents line and the session line do.
  • The session's dollars count from its first launch. A resumed session's ledger includes what it spent before the resume, as /cost does.
  • Cents are rounded. Under one dollar the receipt shows three decimals; under one cent it shows <$0.01.
  • Subagent runs are counted, not named.
  • A crash or forced termination loses at most saveEveryNNTurns turns of the session's record and of the month. The session's dollars are not lost: the next save carries the whole session's figure.
  • /receipt-total is of the session records still kept. With the default keepSessionDetailsNNDays of 14 it cannot look back further than two weeks; /receipt-month is the long view. A session that ended before the plugin was installed is in neither. The window is by the record's save time, not its start.
  • A month's dollars are what this plugin watched. They are the ledger's movement between saves, so a resumed session's spend from before the plugin was installed is in its session record (the whole ledger) but not in the month. A month is the local calendar month at the time of each save.
  • A crash between the two writes of a save can count once more. The month is written first, then the session record; a kill between them leaves the record saying less is banked than is, and the next save banks that part again. The window is two small writes.
  • Session records are per plugin store. Installing under a different plugin name or from a different folder starts a separate store.
  • The store is capped at 4 MiB by the host. A record is a few hundred bytes, session records are dropped after keepSessionDetailsNNDays. Month records last 12 months, one record per month. Ordinary use should stay far below the limit.
  • The desktop app shows only the latest receipt, in the band above the prompt, since it does not render TurnDuration. If the band is too intrusive, set renderReceiptAs to none or to text. vscode and mobile raise neither component; use text there.

Using turn-receipt with the Agent SDK

The plugin works in a headless session driven by the Agent SDK. Two details matter:

  • The plugin's identity depends on how it is loaded.
  • The SDK renders no UI, so the receipt has to come as text and be handled by the harness.

Agent SDK Requirements

  • claude-agent-sdk 0.2.165 or later. The SDK runs the Claude Code binary it bundles, not the one on your PATH, and 0.2.165 is the first release whose bundled binary (2.1.294) loads mods.

Different identities based on how the plugin is loaded

  1. An Agent SDK session that uses the user's settings loads the installed plugin with a turn-receipt@<marketplace> identity.
  1. A sealed SDK session, one with setting_sources=[], will not load the plugin unless the harness explicitly asks for it. If the harness loads the plugin using a direct, local path, the identity becomes turn-receipt@inline.

Since the plugin keeps its ledger in a file that includes the identity, the two ways of loading give two separate stores:

Installed pluginDirectory (--plugin-dir)
SDK optionextra_args={"settings": ...} with the plugin's keyplugins=[{"type": "local", "path": ...}]
Identityturn-receipt@<marketplace>turn-receipt@inline
Store fileturn-receipt_<marketplace>-<hash>.json, the one your own sessions writeturn-receipt_inline-<hash>.json, a second file
Shows up in /receipt-total in your terminalyesno
How to read it headlesslyclaude -p "/receipt-total"claude -p "/receipt-total" --plugin-dir /path/to/turn-receipt
Updated by claude plugin updateyesno, it is whatever the checkout holds
Pick it whenyou want the harness's spend in the same totals as your own sessions1. you are developing the plugin, or <br> 2. you want the harness's spend kept apart

Sample code for each route:

Route 1: load the installed plugin. Pass a settings fragment holding only the plugin's key. The CLI honors --settings even with the settings files off, and loads the plugin exactly as installed: same identity, same store, same version as your own sessions.

import json
from claude_agent_sdk import ClaudeAgentOptions

options = ClaudeAgentOptions(
    setting_sources=[],
    extra_args={"settings": json.dumps(
        {"enabledPlugins": {"turn-receipt@<marketplace>": True}})},
    ...
)

The key is the plugin's name @ marketplace as claude plugin list prints it, or as the key reads in ~/.claude/plugins/installed_plugins.json. This route gives one tally across the SDK and your own Claude Code sessions.

Route 2: load a directory. The SDK's plugins option becomes --plugin-dir:

options = ClaudeAgentOptions(
    plugins=[{"type": "local", "path": "/path/to/turn-receipt"}],
    setting_sources=[],
    ...
)

A plugin loaded this way is turn-receipt@inline, and the CLI gives it a store of its own. Nothing the SDK harness spends appears in your terminal's /receipt-total. To read the harness's ledger, start a session with the same --plugin-dir:

claude -p "/receipt-total" --plugin-dir /path/to/turn-receipt

The receipt in the SDK

The default, ui, draws with ui.render hooks, and a headless session has no surface for them. Set renderReceiptAs to text and the receipt arrives as an informational system message.

With text or none the tally still accumulates and is saved at the usual points, so a harness can read it back with /receipt, /receipt-total or /receipt-month.

Origin and license

This plugin began as an example mod in anthropics/claude-code-playground (`claud

Source 1 files
hooks/turn-receipt.mjs 788 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Turn Receipt: a receipt beneath each answer, with the subagents' share.
5//
6// turn.complete (main loop): read the turn's token counts from e.usage (what
7// the API reported, summed over the turn's requests) and the session's cost
8// from $.session.usage() (the same ledger /cost totals). Keep the receipt as
9// markdown, the answer passed through untouched. With `renderReceiptAs` "ui", a
10// ui.render hook on the TurnDuration row (the line that closes the turn; the
11// terminal only) draws that markdown beneath the turn's duration, and one on
12// AbovePrompt (the desktop app, where there is no such row) draws the latest
13// receipt in the band above the prompt: one list item per section, the
14// numbers in bold. "text" returns the receipt instead as the hook's text, one
15// line (the engine replaces every line break in it), plain. "none" shows
16// nothing each turn; only the ui.render hook is left unregistered, every other
17// hook stays, so the tally, the saves and the commands go on.
18// turn.complete (a subagent's run, e.agentId set): count the run and its
19// tokens toward the current main turn's "agents" line.
20// agent.spawn: take a ledger reading before the subagent starts.
21// session.start: take a baseline, so a resumed session's earlier spend is not
22// billed to its first turn.
23//
24// Dollars per loop: the API prices nothing per agent, so the mod reads the
25// ledger at each spawn and at each turn's end, and credits each movement to
26// the loop whose event ended the window: a spawn credits the main loop, a
27// subagent run's end credits the agents, the main turn's end credits the main
28// loop. A foreground subagent blocks the main loop while it runs, so for those
29// the split is exact. A background subagent runs beside the main loop, so what
30// the main loop spends meanwhile is credited to the agents.
31//
32// Persistence: two kinds of record in $.store, never written on every event.
33// The session's running total is one record under `session:<id>`, written
34// every `saveEveryNNTurns` turns, at session.end, and by the commands. session.start
35// reads it back, so a resumed session or a reloaded module continues its
36// count. Each month's tally is one record under `month:<YYYY-MM>`: at every
37// save, what the session added since its last save is banked into the month
38// of the save, and the session record notes how much is banked, so nothing is
39// counted twice. Session records are short-lived (`keepSessionDetailsNNDays`, 14 by default;
40// /receipt-clear drops them on demand) and the months stay (12 kept), so the
41// store stays small whatever the session count.
42//
43// Why bank, instead of summing the session records when a month is asked for:
44// the two kinds of record have different lives. Session records are dropped
45// after `keepSessionDetailsNNDays` and by /receipt-clear, so by the time a month is asked for
46// most of its sessions are gone; the month has to be written while they are
47// still here. And it has to be written a little at a time, at every save, not
48// once at session.end: a session can be killed before session.end fires,
49// resumed days later, or have its module reloaded mid-count, and a session
50// that runs past midnight on the last day of a month owes part of itself to
51// each month. So each save moves the session's unbanked part (its total minus
52// `banked`) into the month of the save, then records the new `banked` in the
53// session record. That subtraction is what makes saves safe to repeat: a
54// session saved ten times is counted once, a resumed session goes on from
55// where its record left off, and a save that added nothing leaves the month
56// alone. Dollars need one more care: the ledger is a running total for the
57// whole session, not a per-turn count, so `banked.usd` holds the ledger
58// reading the next bank subtracts from (the reading at session.start for a
59// fresh session, so a resumed session's earlier spend, banked already, is not
60// banked again). The month is written before the session record, so a crash
61// between the two writes can count a part once more but never lose it.
62// /receipt-total [days] sums the session records saved in the last N days.
63// /receipt-month [YYYY-MM] shows a month's tally, the current one by default.
64// At session.start the module prints a trace to the console: the host's
65// process id, what the session's record had banked, and every month kept.
66//
67// Options (plugin.json userConfig, set in /config): `showSessionTotals`, on
68// by default, adds the session's running total; `showSubAgentDetails`, on by
69// default, adds a line for the subagent runs the turn made; `saveEveryNNTurns`,
70// 10 by default, is how many turns pass between saves (0: only at session end
71// and on the commands); `keepSessionDetailsNNDays`, 14 by default, is how long
72// session records are kept;
73// `allowEmptyReceipts`, off by default, prints a receipt (and counts the
74// turn) when the turn reported no usage and moved no ledger, such as an
75// interrupt before any response; its token counts read 0.
76// `renderReceiptAs`: "ui" (default) draws the receipt as markdown with a
77// ui.render hook on TurnDuration; "text" returns it as the turn.complete text,
78// one line; "none" only tallies and saves. The receipt's lines follow the
79// options above: the turn, its subagents (when shown and there were runs) and
80// the session (when shown).
81//
82// The host reads on(...) and $.noun.method(...) from source, so they are
83// spelled literally, and helpers that take $ are top-level functions.
84
85const SEP = " | ";
86const DOT = " · ";
87const SECTION_SEP = " // ";
88const RENDER_MODES = ["ui", "text", "none"];
89const MODEL_PREFIX = "claude-";
90const KEY = "session:";
91const MONTH_KEY = "month:";
92const KEEP_MONTHS = 12;
93const DAY_MS = 24 * 60 * 60 * 1000;
94const SAVE_EVERY_DEFAULT = 10;
95const KEEP_DAYS_DEFAULT = 14;
96const ALLOW_EMPTY_RECEIPTS_DEFAULT = false;
97const RENDER_AS_DEFAULT = "ui";
98const KEEP_DRAWN = 200;
99
100let showSession = true;
101let showAgents = true;
102let saveEvery = SAVE_EVERY_DEFAULT;
103let keepDays = KEEP_DAYS_DEFAULT;
104let allowEmptyReceipts = ALLOW_EMPTY_RECEIPTS_DEFAULT;
105let renderAs = RENDER_AS_DEFAULT;
106
107// The latest turn's receipt, as markdown, until a TurnDuration row claims it;
108// and the receipts the rows already drew, by row, so a redraw keeps its own.
109let pending = null;
110const drawn = new Map();
111
112// The latest receipt, as markdown, for the band above the prompt: the surfaces
113// that have no TurnDuration row (the desktop app) show it there.
114let latest = null;
115const bandSurfaces = new Set();
116
117// This session's id and when it began, from session.start; null before it.
118let sessionId = null;
119let startedAt = 0;
120
121// The ledger's total at the last reading, in US dollars; null before any.
122let lastUsd = null;
123
124// The main turn in progress.
125let turn = emptyTurn();
126
127// The session: main-loop turns receipted, subagent runs, and their tokens.
128let session = emptySession();
129
130// How much of the session is already in a month's tally, and which months.
131// Saved in the session record beside the totals, so a resume or a reload
132// knows what not to bank again. See "Why bank" above.
133let banked = emptyBanked(null);
134
135export function register(on, options) {
136  showSession = options?.showSessionTotals !== false;
137  showAgents = options?.showSubAgentDetails !== false;
138  saveEvery = Math.max(0, Math.floor(Number(options?.saveEveryNNTurns ?? SAVE_EVERY_DEFAULT)) || 0);
139  keepDays = Math.max(1, Math.floor(Number(options?.keepSessionDetailsNNDays ?? KEEP_DAYS_DEFAULT)) || KEEP_DAYS_DEFAULT);
140  allowEmptyReceipts = options?.allowEmptyReceipts === true;
141  renderAs = RENDER_MODES.includes(options?.renderReceiptAs) ? options.renderReceiptAs : RENDER_AS_DEFAULT;
142
143  on("session.start", async ($, e, next) => {
144    const result = await next(e);
145    await $.command.register({
146      name: "receipt",
147      description: "Show this session's turns, subagent runs, tokens and cost, and save them.",
148    });
149    await $.command.register({
150      name: "receipt-total",
151      description: "Total the saved sessions of the last N days (the kept days by default).",
152      argumentHint: "[days]",
153    });
154    await $.command.register({
155      name: "receipt-month",
156      description: "Show a month's tally of sessions, turns, tokens and cost (this month by default).",
157      argumentHint: "[YYYY-MM]",
158    });
159    await $.command.register({
160      name: "receipt-clear",
161      description: "Save this session, then drop the other saved session records. The monthly tallies stay.",
162    });
163    turn = emptyTurn();
164    session = emptySession();
165    lastUsd = await readLedger($);
166    banked = emptyBanked(lastUsd);
167    await loadSession($);
168    await prune($);
169    try {
170      await trace($, e.surface);
171    } catch {
172      // Tracing never fails the start.
173    }
174    return result;
175  });
176
177  // The last save: a short, bounded step, so the exit stays fast.
178  on("session.end", async ($, e, next) => {
179    await save($);
180    return next(e);
181  });
182
183  on("command.run", { command: "receipt" }, async ($) => {
184    await save($);
185    return { text: sessionReport() };
186  });
187
188  on("command.run", { command: "receipt-total" }, async ($, e) => {
189    const days = Math.max(1, Math.floor(Number(e.args.trim())) || keepDays);
190    await save($);
191    return { text: await totalReport($, days) };
192  });
193
194  on("command.run", { command: "receipt-month" }, async ($, e) => {
195    await save($);
196    return { text: await monthReport($, e.args.trim()) };
197  });
198
199  on("command.run", { command: "receipt-clear" }, async ($) => {
200    await save($);
201    return { text: await clearReport($) };
202  });
203
204  // The ledger up to a spawn is the main loop's. An observer: whatever
205  // happens to the reading, the spawn goes on.
206  on("agent.spawn", async ($, e, next) => {
207    turn.mainUsd = addUsd(turn.mainUsd, await ledgerDelta($));
208    return next(e);
209  }).catch(($, e, next) => next(e));
210
211  on("turn.complete", async ($, e, next) => {
212    const result = await next(e);
213    const usage = e.usage ?? result.usage ?? null;
214    const delta = await ledgerDelta($);
215
216    if (e.agentId) {
217      turn.agentUsd = addUsd(turn.agentUsd, delta);
218      turn.agentRuns += 1;
219      session.agentRuns += 1;
220      if (usage) {
221        turn.agentTokens = addTokens(turn.agentTokens, usage);
222        session.agentTokens = addTokens(session.agentTokens, usage);
223        turn.agentModel = usage.model ?? turn.agentModel;
224      }
225      return result;
226    }
227
228    pending = null;
229    turn.mainUsd = addUsd(turn.mainUsd, delta);
230    if (!usage && turn.mainUsd === null && turn.agentRuns === 0 && !allowEmptyReceipts) {
231      return result;
232    }
233    session.turns += 1;
234    if (usage) {
235      session.mainTokens = addTokens(session.mainTokens, usage);
236    }
237    const lines = renderAs === "none" ? [] : receiptLines(usage, turn, session, lastUsd, renderAs === "ui");
238    turn = emptyTurn();
239    if (saveEvery > 0 && session.turns % saveEvery === 0) {
240      await save($);
241    }
242    if (renderAs === "ui") {
243      pending = lines.map((line) => `- ${line}`).join("\n");
244      latest = pending;
245      // A surface other than the terminal draws the band, and nothing else
246      // tells it the receipt changed.
247      if ((await $.session.surfaces()).some((surface) => surface !== "terminal")) {
248        $.ui.invalidate("ui.render");
249      }
250    } else if (renderAs === "text") {
251      // One line: the engine replaces every line break in a hook's text.
252      return { ...result, text: lines.join(SECTION_SEP) };
253    }
254    return result;
255  });
256
257  if (renderAs === "ui") {
258    // The row that closes a turn. It claims the receipt the turn left, once;
259    // a redraw of the same row gets the same receipt back. A row with none
260    // (an empty turn, a subagent's) is the engine's own.
261    on("ui.render", { component: "TurnDuration" }, async ($, e, next) => {
262      const id = String(e.requestId ?? "");
263      let text = drawn.get(id);
264      if (text === undefined && pending !== null) {
265        text = pending;
266        pending = null;
267        drawn.set(id, text);
268        if (drawn.size > KEEP_DRAWN) {
269          drawn.delete(drawn.keys().next().value);
270        }
271      }
272      if (text === undefined) {
273        // Remember the row had none, so a later redraw (the band's) does not
274        // hand it a newer turn's receipt.
275        drawn.set(id, null);
276      }
277      if (!text) {
278        return next(e);
279      }
280      const { Box, Text, Markdown } = $.ui.resolve(e);
281      return Box({
282        flexDirection: "column",
283        children: [
284          Text({ dimColor: true, children: `${e.props.word} for ${duration(e.props.durationMs)}` }),
285          Markdown({ text }),
286        ],
287      });
288    });
289
290    // The terminal has the TurnDuration row above, so the band stands down
291    // there (and while a survey holds it). Elsewhere the band is where the
292    // latest receipt is.
293    on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
294      if (e.surface === "terminal" || e.props.hasSurvey || latest === null) {
295        return next(e);
296      }
297      if (!bandSurfaces.has(e.surface)) {
298        // Once per surface, so the debug log says where the band drew.
299        bandSurfaces.add(e.surface);
300        $.ui.log(`turn-receipt: AbovePrompt band drawn on surface ${e.surface}`, { to: "debug" });
301      }
302      const { Box, Text, Markdown } = $.ui.resolve(e);
303      return Box({
304        flexDirection: "column",
305        children: [Text({ dimColor: true, children: "Last turn" }), Markdown({ text: latest })],
306      });
307    });
308  }
309}
310
311// The turn's length as the closing line says it: `3s`, `1m 4s`.
312function duration(ms) {
313  const total = Math.max(0, Math.round(ms / 1000));
314  return total < 60 ? `${total}s` : `${Math.floor(total / 60)}m ${total % 60}s`;
315}
316
317// ---- persistence ----
318
319async function loadSession($) {
320  try {
321    sessionId = await $.session.id();
322    startedAt = (await $.session.usage()).startedAt ?? 0;
323    const record = await $.store.get(KEY + sessionId);
324    if (record && typeof record === "object") {
325      session.turns = record.turns ?? 0;
326      session.agentRuns = record.agentRuns ?? 0;
327      session.mainTokens = addTokens(emptyTokens(), record.tokens ?? {});
328      session.agentTokens = addTokens(emptyTokens(), record.agentTokens ?? {});
329      // A record from before the monthly tally has nothing banked yet, and
330      // its dollars are the whole ledger, so they bank from zero.
331      banked = readBanked(record.banked) ?? emptyBanked(0);
332    }
333  } catch {
334    // No store or no id: the session counts from zero and is not saved.
335    sessionId = null;
336  }
337}
338
339export function record(now) {
340  return {
341    id: sessionId,
342    startedAt,
343    savedAt: now,
344    turns: session.turns,
345    agentRuns: session.agentRuns,
346    tokens: session.mainTokens,
347    agentTokens: session.agentTokens,
348    usd: lastUsd,
349    banked,
350  };
351}
352
353async function save($) {
354  if (sessionId === null || isEmpty()) {
355    return false;
356  }
357  try {
358    const now = await $.clock.now();
359    await bank($, now);
360    await $.store.set(KEY + sessionId, record(now));
361    return true;
362  } catch {
363    return false;
364  }
365}
366
367// A session with nothing in it: no turns, no subagent runs, no tokens, and a
368// ledger at zero (or none). Saving it would only litter the store with empty
369// records, one per session opened and closed without a prompt, so save() does
370// not. Once it has anything, every save after is written as usual.
371function isEmpty() {
372  return (
373    session.turns === 0 &&
374    session.agentRuns === 0 &&
375    sum(session.mainTokens) + sum(session.agentTokens) === 0 &&
376    !(lastUsd > 0)
377  );
378}
379
380// What the session has added since its last save: its totals minus `banked`.
381// This is the only part a save may add to a month; the rest is there already.
382// Null dollars where the ledger gave none to compare.
383function unbanked() {
384  return {
385    turns: session.turns - banked.turns,
386    agentRuns: session.agentRuns - banked.agentRuns,
387    tokens: subTokens(session.mainTokens, banked.tokens),
388    agentTokens: subTokens(session.agentTokens, banked.agentTokens),
389    usd: lastUsd === null || banked.usd === null ? null : Math.max(0, lastUsd - banked.usd),
390  };
391}
392
393// Adds the session's unbanked part to the tally of the month `now` falls in,
394// then marks it banked by setting `banked` to the session's current totals.
395// Called from every save, so the month grows by increments and a session that
396// ends without session.end has still banked up to its last save. A session
397// that added nothing is not counted, and one that banks into a month it has
398// not seen before counts as one more of that month's sessions. The month is
399// written first: if the session record's write is lost, the next save banks
400// this increment again (counting it once more), which beats losing it.
401async function bank($, now) {
402  const d = unbanked();
403  if (d.turns === 0 && d.agentRuns === 0 && sum(d.tokens) + sum(d.agentTokens) === 0 && !(d.usd > 0)) {
404    return;
405  }
406  const month = monthOf(now);
407  const key = MONTH_KEY + month;
408  const m = readMonth(await $.store.get(key), month);
409  const isNew = !banked.months.includes(month);
410  await $.store.set(key, {
411    month,
412    savedAt: now,
413    sessions: m.sessions + (isNew ? 1 : 0),
414    turns: m.turns + d.turns,
415    agentRuns: m.agentRuns + d.agentRuns,
416    tokens: addTokens(m.tokens, d.tokens),
417    agentTokens: addTokens(m.agentTokens, d.agentTokens),
418    usd: addUsd(m.usd, d.usd),
419  });
420  banked = {
421    turns: session.turns,
422    agentRuns: session.agentRuns,
423    tokens: session.mainTokens,
424    agentTokens: session.agentTokens,
425    usd: lastUsd ?? banked.usd,
426    months: isNew ? [...banked.months, month] : banked.months,
427  };
428}
429
430// Drops session records older than keepDays (never this session's) and
431// months older than the KEEP_MONTHS kept.
432async function prune($) {
433  try {
434    const now = await $.clock.now();
435    const cutoff = now - keepDays * DAY_MS;
436    const oldest = monthOf(now, 1 - KEEP_MONTHS);
437    for (const key of await $.store.keys()) {
438      if (key.startsWith(KEY)) {
439        if (key === KEY + sessionId) continue;
440        const r = await $.store.get(key);
441        if (!r || typeof r !== "object" || (r.savedAt ?? 0) < cutoff) {
442          await $.store.delete(key);
443        }
444      } else if (key.startsWith(MONTH_KEY)) {
445        if (key.slice(MONTH_KEY.length) < oldest) {
446          await $.store.delete(key);
447        }
448      }
449    }
450  } catch {
451    // The store keeps what it has.
452  }
453}
454
455// `YYYY-MM` of the local month `ms` falls in, moved by `offset` months.
456export function monthOf(ms, offset = 0) {
457  const d = new Date(ms);
458  const index = d.getFullYear() * 12 + d.getMonth() + offset;
459  const y = Math.floor(index / 12);
460  const m = index - y * 12 + 1;
461  return `${y}-${String(m).padStart(2, "0")}`;
462}
463
464// ---- tracing ----
465
466// Prints to the console, at session.start, where the tally stands: the
467// host's process id (to attach a debugger to), what this session's record
468// had banked, and every month record kept. A hooks module has no `process`
469// of its own, so the id is read from a child's $PPID; where the console is
470// missing, the lines go to the debug log. Never fails the start.
471async function trace($, startSurface) {
472  const lines = [
473    `turn-receipt session.start: host pid ${await hostPid($)}, receipt ${renderAs}, surface ${startSurface ?? "none"}, attached ${(await surfaces($)).join(", ") || "none"}`,
474    `  session ${sessionId ?? "(none)"}: turns ${session.turns}, agent runs ${session.agentRuns}, ledger ${dollars(lastUsd)}`,
475    `  banked: turns ${banked.turns}, agent runs ${banked.agentRuns}, tokens ${sum(banked.tokens) + sum(banked.agentTokens)}, ${dollars(banked.usd)}, months ${banked.months.join(", ") || "none"}`,
476  ];
477  try {
478    const months = [];
479    for (const key of await $.store.keys()) {
480      if (!key.startsWith(MONTH_KEY)) continue;
481      const m = readMonth(await $.store.get(key), key.slice(MONTH_KEY.length));
482      months.push(`  month ${m.month}: sessions ${m.sessions}, turns ${m.turns}, agent runs ${m.agentRuns}, tokens ${sum(m.tokens) + sum(m.agentTokens)}, ${dollars(m.usd)}`);
483    }
484    lines.push(...(months.length > 0 ? months.sort() : ["  months: none"]));
485  } catch (err) {
486    lines.push(`  months: store unreadable (${err?.message ?? err})`);
487  }
488  const text = lines.join("\n");
489  try {
490    if (typeof console !== "undefined" && typeof console.log === "function") {
491      console.log(text);
492    } else {
493      $.ui.log(text, { to: "debug" });
494    }
495  } catch {
496    // Nowhere to print: the start goes on.
497  }
498}
499
500// The surfaces the session draws on; none where the host cannot say.
501async function surfaces($) {
502  try {
503    return await $.session.surfaces();
504  } catch {
505    return [];
506  }
507}
508
509// The host process's id, as a child it starts sees its parent; "unknown"
510// where no child can run (the test kit, a host without $.process).
511async function hostPid($) {
512  try {
513    const { exitCode, stdout } = await $.process.run(["sh", "-c", "echo $PPID"], { timeoutMs: 5000 });
514    const pid = stdout.trim();
515    return exitCode === 0 && /^\d+$/.test(pid) ? pid : "unknown";
516  } catch {
517    return "unknown";
518  }
519}
520
521// ---- reports ----
522
523function sessionReport() {
524  const tokens = addTokens(session.mainTokens, session.agentTokens);
525  const lines = [
526    `This session${sessionId ? ` (${sessionId})` : ""}:`,
527    `  turns ${session.turns}${SEP}agent runs ${session.agentRuns}${SEP}${dollars(lastUsd)}`,
528    `  tokens ${short(sum(tokens))}: ${tokenParts(tokens).join(SEP) || "none"}`,
529    sessionId === null ? "Not saved: no session id." : isEmpty() ? "Not saved: nothing to save yet." : "Saved.",
530  ];
531  return lines.join("\n");
532}
533
534async function totalReport($, days) {
535  const now = await $.clock.now();
536  const cutoff = now - days * DAY_MS;
537  const rows = [];
538  for (const key of await $.store.keys()) {
539    if (!key.startsWith(KEY)) continue;
540    const r = await $.store.get(key);
541    if (r && typeof r === "object" && (r.savedAt ?? 0) >= cutoff) {
542      rows.push(r);
543    }
544  }
545  if (rows.length === 0) {
546    return `No sessions saved in the last ${days} ${days === 1 ? "day" : "days"}.`;
547  }
548  let turns = 0;
549  let agentRuns = 0;
550  let usd = null;
551  let tokens = emptyTokens();
552  for (const r of rows) {
553    turns += r.turns ?? 0;
554    agentRuns += r.agentRuns ?? 0;
555    usd = addUsd(usd, typeof r.usd === "number" ? r.usd : null);
556    tokens = addTokens(tokens, r.tokens ?? {});
557    tokens = addTokens(tokens, r.agentTokens ?? {});
558  }
559  const lines = [
560    `Last ${days} ${days === 1 ? "day" : "days"}: ${rows.length} ${rows.length === 1 ? "session" : "sessions"}`,
561    `  turns ${turns}${SEP}agent runs ${agentRuns}${SEP}${dollars(usd)}`,
562    `  tokens ${short(sum(tokens))}: ${tokenParts(tokens).join(SEP) || "none"}`,
563  ];
564  return lines.join("\n");
565}
566
567async function monthReport($, arg) {
568  const now = await $.clock.now();
569  const asked = /^\d{4}-\d{2}$/.test(arg);
570  const month = asked ? arg : monthOf(now);
571  const r = await $.store.get(MONTH_KEY + month);
572  if (!r || typeof r !== "object") {
573    return `Nothing saved for ${month}.`;
574  }
575  const m = readMonth(r, month);
576  const tokens = addTokens(m.tokens, m.agentTokens);
577  const lines = [
578    `${month}: ${m.sessions} ${m.sessions === 1 ? "session" : "sessions"}`,
579    `  turns ${m.turns}${SEP}agent runs ${m.agentRuns}${SEP}${dollars(m.usd)}`,
580    `  tokens ${short(sum(tokens))}: ${tokenParts(tokens).join(SEP) || "none"}`,
581  ];
582  if (!asked) {
583    const earlier = [];
584    for (const key of await $.store.keys()) {
585      if (!key.startsWith(MONTH_KEY) || key === MONTH_KEY + month) continue;
586      const e = readMonth(await $.store.get(key), key.slice(MONTH_KEY.length));
587      earlier.push(`  ${e.month}${SEP}${e.sessions} ${e.sessions === 1 ? "session" : "sessions"}${SEP}${e.turns} ${e.turns === 1 ? "turn" : "turns"}${SEP}${dollars(e.usd)}`);
588    }
589    if (earlier.length > 0) {
590      lines.push("Earlier months:", ...earlier.sort().reverse());
591    }
592  }
593  return lines.join("\n");
594}
595
596async function clearReport($) {
597  let dropped = 0;
598  try {
599    for (const key of await $.store.keys()) {
600      if (!key.startsWith(KEY) || key === KEY + sessionId) continue;
601      await $.store.delete(key);
602      dropped += 1;
603    }
604  } catch {
605    return `Dropped ${dropped} session ${dropped === 1 ? "record" : "records"} before the store refused. The monthly tallies are kept.`;
606  }
607  return `Dropped ${dropped} session ${dropped === 1 ? "record" : "records"}. This session's record and the monthly tallies are kept.`;
608}
609
610async function readLedger($) {
611  try {
612    const { cost } = await $.session.usage();
613    return typeof cost?.usd === "number" ? cost.usd : null;
614  } catch {
615    return null;
616  }
617}
618
619// How far the ledger moved since the last reading; null where there is none.
620async function ledgerDelta($) {
621  const usd = await readLedger($);
622  if (usd === null) {
623    return null;
624  }
625  const delta = lastUsd === null ? null : Math.max(0, usd - lastUsd);
626  lastUsd = usd;
627  return delta;
628}
629
630// The receipt's lines: the turn, its subagents (when shown and there were
631// runs) and the session (when shown). With `md`, as markdown, the numbers in
632// bold and the fields joined by ·; else plain, `in:200`, joined by |.
633export function receiptLines(usage, t, s, sessionUsd, md) {
634  const lines = [];
635  const sep = md ? DOT : SEP;
636  const b = (x) => (md ? `**${x}**` : String(x));
637  const fmt = md ? b : undefined;
638  const agentsShown = showAgents && t.agentRuns > 0;
639  // With the agents line off, the turn's figure is the whole turn, agents included.
640  const turnUsd = agentsShown ? t.mainUsd : addUsd(t.mainUsd, t.agentUsd);
641  const tag = (model) => (model ? `${sep}${shortModel(model)}` : "");
642
643  // No usage reads as zeros, so the line keeps its shape for anything parsing it.
644  const main = [b("receipt"), ...tokenParts(usage ?? emptyTokens(), fmt), b(dollars(turnUsd))];
645  lines.push(main.join(sep) + tag(usage?.model));
646
647  if (agentsShown) {
648    const agents = [
649      b("agents"),
650      `${b(t.agentRuns)} ${t.agentRuns === 1 ? "run" : "runs"}`,
651      ...tokenParts(t.agentTokens, fmt),
652      b(dollars(t.agentUsd)),
653      `turn total ${b(dollars(addUsd(t.mainUsd, t.agentUsd)))}`,
654    ];
655    lines.push(agents.join(sep) + tag(t.agentModel));
656  }
657
658  if (showSession) {
659    const parts = [b("session"), `${b(s.turns)} ${s.turns === 1 ? "turn" : "turns"}`];
660    if (s.agentRuns > 0) {
661      parts.push(`${b(s.agentRuns)} agent ${s.agentRuns === 1 ? "run" : "runs"}`);
662    }
663    parts.push(`${b(short(sum(addTokens(s.mainTokens, s.agentTokens))))} tokens`);
664    parts.push(b(dollars(sessionUsd)));
665    lines.push(parts.join(sep));
666  }
667
668  return lines;
669}
670
671// `in:1.2k`, `out:348` and the cache counts shown; with `fmt`, as markdown
672// does it, `in **1.2k**`.
673function tokenParts(usage, fmt) {
674  if (!usage) {
675    return [];
676  }
677  const part = (label, n) => (fmt ? `${label} ${fmt(short(n))}` : `${label}:${short(n)}`);
678  const parts = [part("in", usage.input_tokens), part("out", usage.output_tokens)];
679  if (usage.cache_read_input_tokens > 0) {
680    parts.push(part("cache read", usage.cache_read_input_tokens));
681  }
682  if (usage.cache_creation_input_tokens > 0) {
683    parts.push(part("cache write", usage.cache_creation_input_tokens));
684  }
685  return parts;
686}
687
688// Every model id starts with "claude-"; the receipt shows the rest.
689export function shortModel(model) {
690  return model.startsWith(MODEL_PREFIX) && model.length > MODEL_PREFIX.length ? model.slice(MODEL_PREFIX.length) : model;
691}
692
693function emptyTurn() {
694  return { mainUsd: null, agentUsd: null, agentRuns: 0, agentTokens: emptyTokens(), agentModel: null };
695}
696
697function emptySession() {
698  return { turns: 0, agentRuns: 0, mainTokens: emptyTokens(), agentTokens: emptyTokens() };
699}
700
701// Nothing banked yet; `usd` is the ledger reading the session's dollars count
702// from. For a fresh session that is the reading at session.start, since the
703// ledger is a running total and anything before it belongs to an earlier
704// session already banked; for a 0.1.x record (no `banked` field) it is zero,
705// since that record's `usd` was the whole ledger and none of it is banked.
706function emptyBanked(usd) {
707  return { turns: 0, agentRuns: 0, tokens: emptyTokens(), agentTokens: emptyTokens(), usd, months: [] };
708}
709
710function readBanked(b) {
711  if (!b || typeof b !== "object") {
712    return null;
713  }
714  return {
715    turns: b.turns ?? 0,
716    agentRuns: b.agentRuns ?? 0,
717    tokens: addTokens(emptyTokens(), b.tokens ?? {}),
718    agentTokens: addTokens(emptyTokens(), b.agentTokens ?? {}),
719    usd: typeof b.usd === "number" ? b.usd : null,
720    months: Array.isArray(b.months) ? b.months.filter((m) => typeof m === "string") : [],
721  };
722}
723
724function readMonth(r, month) {
725  const m = r && typeof r === "object" ? r : {};
726  return {
727    month,
728    sessions: m.sessions ?? 0,
729    turns: m.turns ?? 0,
730    agentRuns: m.agentRuns ?? 0,
731    tokens: addTokens(emptyTokens(), m.tokens ?? {}),
732    agentTokens: addTokens(emptyTokens(), m.agentTokens ?? {}),
733    usd: typeof m.usd === "number" ? m.usd : null,
734  };
735}
736
737function emptyTokens() {
738  return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
739}
740
741function addTokens(a, b) {
742  return {
743    input_tokens: a.input_tokens + (b.input_tokens ?? 0),
744    output_tokens: a.output_tokens + (b.output_tokens ?? 0),
745    cache_read_input_tokens: a.cache_read_input_tokens + (b.cache_read_input_tokens ?? 0),
746    cache_creation_input_tokens: a.cache_creation_input_tokens + (b.cache_creation_input_tokens ?? 0),
747  };
748}
749
750function subTokens(a, b) {
751  return {
752    input_tokens: Math.max(0, a.input_tokens - b.input_tokens),
753    output_tokens: Math.max(0, a.output_tokens - b.output_tokens),
754    cache_read_input_tokens: Math.max(0, a.cache_read_input_tokens - b.cache_read_input_tokens),
755    cache_creation_input_tokens: Math.max(0, a.cache_creation_input_tokens - b.cache_creation_input_tokens),
756  };
757}
758
759// Sums two dollar figures, null standing for "no ledger".
760function addUsd(a, b) {
761  if (a === null) return b;
762  if (b === null) return a;
763  return a + b;
764}
765
766function sum(t) {
767  return t.input_tokens + t.output_tokens + t.cache_read_input_tokens + t.cache_creation_input_tokens;
768}
769
770export function dollars(usd) {
771  if (usd === null) return "cost n/a";
772  if (usd === 0) return "$0.00";
773  if (usd < 0.01) return "<$0.01";
774  return `$${usd.toFixed(usd >= 1 ? 2 : 3)}`;
775}
776
777export function short(n) {
778  if (n >= 1_000_000) return `${tenths(n / 1_000_000)}M`;
779  if (n >= 1_000) return `${tenths(n / 1_000)}k`;
780  return String(n);
781}
782
783// One decimal, rounded half up in tenths, a whole number shown without it.
784function tenths(x) {
785  const r = Math.round(x * 10) / 10;
786  return Number.isInteger(r) ? String(r) : r.toFixed(1);
787}
788