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…

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.
The receipt shows:
The token counts come from the turn.complete event's usage. The dollars come from $.session.usage().cost.usd, the same ledger /cost reports.
| Command | What it does |
|---|---|
/receipt | Shows 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-clear | Saves 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
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 row | Type | Default | What it does | |||||
|---|---|---|---|---|---|---|---|---|---|
renderReceiptAs | Render the receipt as | ui \ | text \ | none | ui | ui: 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`. |
showSessionTotals | Show Session Totals | boolean | true | Adds the session tally to each receipt. | |||||
showSubAgentDetails | Show Subagent Details | boolean | true | Adds 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. | |||||
saveEveryNNTurns | Save every NN turns | number | 10 | Saves 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. | |||||
keepSessionDetailsNNDays | Keep detailed session records for NN days | number | 14 | Determines 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. | |||||
allowEmptyReceipts | Allow empty receipts | boolean | false | Also 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. |
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.
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.
Run these from the repository root. All three must pass before a commit.
.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.
*.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.
.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
--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.
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.--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.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.~/.claude/plugins/store/turn-receipt_<marketplace>-<hash>.json, plain JSON keyed session:<id> and month:<YYYY-MM>.--settings, or managed settings, never a project's .claude/settings.json.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./debug command, then /reload-plugins, and check the logs.version in .claude-plugin/plugin.json and add a dated entry to CHANGELOG.md.main.claude plugin update turn-receipt followed by /reload-plugins.| Hook | What it does |
|---|---|
session.start | Registers 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.spawn | Reads the ledger and credits the movement to the main loop, then lets the spawn go on. |
turn.complete | A 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.end | Banks into the month and saves the record one last time. |
command.run | Answers /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 at | Credited to |
|---|---|
agent.spawn, before the subagent starts | the main loop |
a subagent run's turn.complete | the agents |
the main loop's turn.complete | the 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:
saveEveryNNTurns turns (10 by default; 0 turns this off),session.end, which shares one short time bound with everything else that runs at exit, and/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.
/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./cost does.<$0.01.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.keepSessionDetailsNNDays. Month records last 12 months, one record per month. Ordinary use should stay far below the limit.TurnDuration. If the band is too intrusive, set renderReceiptAs to none or to text. vscode and mobile raise neither component; use text there.The plugin works in a headless session driven by the Agent SDK. Two details matter:
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.turn-receipt@<marketplace> identity.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 plugin | Directory (--plugin-dir) | |
|---|---|---|
| SDK option | extra_args={"settings": ...} with the plugin's key | plugins=[{"type": "local", "path": ...}] |
| Identity | turn-receipt@<marketplace> | turn-receipt@inline |
| Store file | turn-receipt_<marketplace>-<hash>.json, the one your own sessions write | turn-receipt_inline-<hash>.json, a second file |
Shows up in /receipt-total in your terminal | yes | no |
| How to read it headlessly | claude -p "/receipt-total" | claude -p "/receipt-total" --plugin-dir /path/to/turn-receipt |
Updated by claude plugin update | yes | no, it is whatever the checkout holds |
| Pick it when | you want the harness's spend in the same totals as your own sessions | 1. 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 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.
This plugin began as an example mod in anthropics/claude-code-playground (`claud
hooks/turn-receipt.mjs 788 lines1// 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