SLOPSHOPPER

loopd — usage (a mod that records what a session spent)

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…

newguard
v3.0.0MITupdated 2026-10-08cbmono/loopd/plugin-mod-usage
A shopper browsing a rack in a slop shop
README

loopd-mod-usage — the usage-recording companion (a mod)

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.

What it does

After every turn it writes one record to the plugin store, keyed by the session:

KeyValue
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>.endedtrue, 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.

What it never does

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:

  • No model call — nothing here spends the plan.
  • No permission decision — it never answers the permission event; every hook hands the event on unchanged.
  • No process, no network, no file — the plugin store is the only thing it writes.
  • No store write without a session id — an unattributable number is worse than none.

Where the store lives

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.

Tested with

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.

Delete 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".

Source 1 files
hooks/register.ts 102 lines
1// 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