COMPANION, a mod. Records each session's token usage, request count, tool calls by name, tool errors, wall time and model in the plugin store under…

A mod: a hooks module Claude Code runs inside every session that loads it. This one records what the session spent and nothing else.
/plugin marketplace add cbmono/loopd # already added? skip
/plugin install loopd-mod-usage@loopd
Uninstall it (/plugin → Installed) and session-usage.sh reads the transcript as it did before, with no other edits anywhere. Absence is the safe behaviour, never an error.
After every turn it writes one record to the plugin store, keyed by the session:
| Key | Value |
|---|---|
loopd.usage.<session id> | { input, output, cacheRead, cacheWrite, requests, turns, ms, model, tools: { <tool name>: <calls> }, toolErrors } — tokens from each request's usage (turn.step), or the turn's totals when a turn's requests carried none; ms is the sum of durationMs over turns; toolErrors counts tool results with isError |
loopd.usage.<session id>.ended | true, written at session.end |
Core's plugin/scripts/session-usage.sh <session-id> reads that record first and prints the same usage tokens=N tools=N ms=N cached=N errors=N by=Name:N,… line it prints from a transcript (tokens = input + cacheWrite + output; cached = cacheRead; tools = the sum over names; errors = toolErrors; by = the top five names by count, then name). No record, two records, a corrupt store file, a non-number anywhere or a tool name outside the line's grammar, and it reads the transcript exactly as before. The one visible thing: in an interactive session, one dim ● loopd-mod-usage: line at start saying it is recording. A claude -p or claude --bg session gets no line.
A mod runs with your permissions in every session on the machine, so the lines it does not cross are the whole design, and tests/mods.test.sh in cbmono/loopd asserts each on the source and on what claude plugin validate reads out of it:
Claude Code keeps one JSON file per plugin under ${CLAUDE_CONFIG_DIR:-~/.claude}/plugins/store/, named <plugin name>_<marketplace>-<hash>.json, a flat object of key → value (measured on the built-in cc-plugin-diff_builtin-….json; this plugin's file is loopd-mod-usage_loopd-….json once it has written). The 4 MiB store limit is the vendor's; a record here is a few hundred bytes.
Claude Code 2.1.293 (claude --version, 2026-10-08). Mods need v2.1.287 or later. The events and methods can change between releases, so after a CLI update run, from this directory:
claude plugin validate . --strict
claude plugin test
docs/spikes/mods-in-background-sessions.md in cbmono/loopd records what was and was not measured about mods in claude -p and claude --bg sessions — the shape loopd's role agents run in — and the probe that measures it.
/plugin uninstall loopd-mod-usage@loopd, or remove the directory from a checkout. The contract this plugin is an instance of — how a companion registers, where core looks, and the rule that a companion ADDS behaviour and never removes a core gate — is in ../plugin/README.md → "Companion plugins".
hooks/register.ts 102 lines1// loopd-mod-usage — what this session spent, written to the plugin store after every turn
2// under `loopd.usage.<session id>`, and `loopd.usage.<session id>.ended` at session end.
3// Observe only: every hook hands the event on unchanged. It never calls a model, never
4// decides a permission, never starts a process, never reaches the network or a file.
5// tests/mods.test.sh in cbmono/loopd asserts those absences; README.md says why.
6
7type Usage = {
8 input_tokens?: unknown
9 output_tokens?: unknown
10 cache_read_input_tokens?: unknown
11 cache_creation_input_tokens?: unknown
12 model?: unknown
13}
14
15type Record = {
16 input: number
17 output: number
18 cacheRead: number
19 cacheWrite: number
20 requests: number
21 turns: number
22 ms: number
23 model: string | null
24 tools: { [name: string]: number }
25 toolErrors: number
26}
27
28const acc: Record = {
29 input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
30 requests: 0, turns: 0, ms: 0, model: null, tools: {}, toolErrors: 0,
31}
32// Request-level usage (turn.step results) is the primary source; the turn's own totals on
33// turn.complete are the fallback for a turn whose steps carried none, never a second count.
34let stepUsageThisTurn = false
35
36function num(v: unknown): number {
37 return typeof v === 'number' && isFinite(v) && v >= 0 ? Math.floor(v) : 0
38}
39
40function add(u: unknown): boolean {
41 if (!u || typeof u !== 'object') return false
42 const usage = u as Usage
43 acc.input += num(usage.input_tokens)
44 acc.output += num(usage.output_tokens)
45 acc.cacheRead += num(usage.cache_read_input_tokens)
46 acc.cacheWrite += num(usage.cache_creation_input_tokens)
47 acc.requests += 1
48 if (typeof usage.model === 'string' && usage.model) acc.model = usage.model
49 return true
50}
51
52// Without a session id there is no key, and a record under a made-up key would be a
53// number nothing can attribute — so nothing is written.
54async function save($: any, ended: boolean, knownId?: unknown): Promise<void> {
55 let id: unknown = typeof knownId === 'string' && knownId ? knownId : null
56 if (!id) { try { id = await $.session.id() } catch { id = null } }
57 if (typeof id !== 'string' || !id) return
58 await $.store.set('loopd.usage.' + id, { ...acc, tools: { ...acc.tools } })
59 if (ended) await $.store.set('loopd.usage.' + id + '.ended', true)
60}
61
62export function register(on: any) {
63 on('session.start', async ($: any, e: any, next: any) => {
64 // One line, and only where somebody can see it: a `claude -p` or `--bg` session has
65 // no surface, and a line there would reach the transcript instead.
66 if (e && e.isInteractive === true) {
67 $.ui.log('recording this session\'s usage in the plugin store as loopd.usage.<session id>')
68 }
69 return next(e)
70 })
71
72 on('turn.step', async function* ($: any, e: any, next: any) {
73 const result = yield* next(e)
74 if (result && add(result.usage)) stepUsageThisTurn = true
75 return result
76 })
77
78 // A gating hook: if this one throws, the call still goes through (fail open).
79 on('tool.call', async ($: any, e: any, next: any) => {
80 const result = await next(e)
81 const name = e && typeof e.tool === 'string' && e.tool ? e.tool : 'unknown'
82 acc.tools[name] = (acc.tools[name] || 0) + 1
83 if (result && result.isError === true) acc.toolErrors += 1
84 return result
85 }).catch(($: any, e: any, next: any) => next(e))
86
87 on('turn.complete', async ($: any, e: any, next: any) => {
88 acc.turns += 1
89 acc.ms += num(e ? e.durationMs : 0)
90 if (!stepUsageThisTurn) add(e ? e.usage : null)
91 stepUsageThisTurn = false
92 await save($, false)
93 return next(e)
94 })
95
96 // session.end carries the ending session's id itself (measured 2026-10-09, 2.1.293).
97 on('session.end', async ($: any, e: any, next: any) => {
98 await save($, true, e ? e.sessionId : null)
99 return next(e)
100 })
101}
102