SLOPSHOPPER

Tool Call Counter

Counts Claude's tool calls per turn and per session, shows the count beside the spinner, and adds /tally for a per-tool breakdown with error counts.

newspinnerguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cm-tool-call-counter
› 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 · done 4:20 PM › /tally ⎿ cm-tool-call-counter: **Tool calls** · this turn: 9 · this session: 9 (1 error) ⎿ cm-tool-call-counter: ⎿ cm-tool-call-counter: | Tool | Calls | Errors | ⎿ cm-tool-call-counter: | --- | ---: | ---: | ⎿ cm-tool-call-counter: | Bash | 4 | 1 | ⎿ cm-tool-call-counter: | Write | 2 | 0 | ✻ Thinking · 9 tools… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Tool Call Counter (cm-tool-call-counter)

Counts Claude's tool calls per turn and per session, shows the count beside the spinner, and adds /tally for a per-tool breakdown with error counts.

What it does

Counts every tool call Claude makes. While Claude works, the spinner shows this turn's count:

✻ Working… (esc to interrupt) · 7 tools

/tally prints a Markdown table of the session so far, by tool, with failed calls:

**Tool calls** · this turn: 7 · this session: 41 (2 errors)

| Tool | Calls | Errors |
| --- | ---: | ---: |
| Bash | 18 | 2 |
| Read | 15 | 0 |
| Edit | 8 | 0 |

Optionally the status line keeps the session total (🔧 41 tool calls (2 errors)).

Install

claude plugin marketplace add <owner>/<repo>
claude plugin install cm-tool-call-counter@claudemods

Configuration (userConfig)

KeyTypeDefaultDescription
showInSpinnerbooleantrueAppend " · N tools" (this turn's count) to the working spinner.
showInStatusbooleanfalseAlso show the session's total tool calls and errors in the status line.

Permissions

APIWhy
$.command.registerRegisters the /tally command at session start.
$.state.get / $.state.setKeeps the turn and session tallies in cm-tool-call-counter.turn / .session, so the spinner redraws as calls come in.
$.ui.statusOnly when showInStatus is on: the session total in the status line.

Compatibility

Tested with Claude Code 2.1.291.

Implementation notes

  • Hooks: session.start, turn.start (resets the turn tally), tool.call (all tools; observes only, with a .catch that lets the call run if counting fails), ui.render{component=Spinner} (rewrites suffix via next({ ...e, props })) and command.run{command=tally}.
  • A call counts when it starts; it counts as an error when the result comes back with isError or is denied by another hook.
  • The session tally covers the time since the mod loaded in this Claude Code process.
  • Counting and table formatting live in hooks/tally.ts; the state type is declared in types/index.d.ts.

License

MIT. See LICENSE. More at https://claudemods.app/mods/cm-tool-call-counter.

Source 3 files
hooks/register.ts 56 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { addCall, addError, emptyTally, formatTable, spinnerSuffix, statusText } from './tally'
5
6const turn = atom({ plugin: 'cm-tool-call-counter', key: 'turn' } as const, emptyTally())
7const session = atom({ plugin: 'cm-tool-call-counter', key: 'session' } as const, emptyTally())
8
9export const register: Register = (on, options) => {
10  const showInSpinner = options.showInSpinner !== false
11  const showInStatus = options.showInStatus === true
12
13  on('session.start', async ($, e, next) => {
14    await $.command.register({
15      name: 'tally',
16      description: 'Show how many tool calls Claude made this turn and this session, by tool',
17    })
18    return next(e)
19  })
20
21  on('turn.start', async ($, e, next) => {
22    await update($, turn, () => emptyTally())
23    return next(e)
24  })
25
26  // Observes only: counts the call, lets it run unchanged, then counts a
27  // failure. Never gates: should counting throw, the catch runs the call.
28  on('tool.call', async ($, e, next) => {
29    const tool = String(e.tool)
30    await update($, turn, t => addCall(t, tool))
31    const now = await update($, session, t => addCall(t, tool))
32    if (showInStatus) $.ui.status(statusText(now))
33
34    const ran = await next(e)
35
36    if (ran.deny !== undefined || ran.isError === true) {
37      await update($, turn, t => addError(t, tool))
38      const failed = await update($, session, t => addError(t, tool))
39      if (showInStatus) $.ui.status(statusText(failed))
40    }
41    return ran
42  }).catch(($, e, next) => next(e))
43
44  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
45    if (!showInSpinner) return next(e)
46    const { count } = await read($, turn)
47    if (count === 0) return next(e)
48    return next({ ...e, props: { ...e.props, suffix: spinnerSuffix(e.props.suffix, count) } })
49  })
50
51  on('command.run', { command: 'tally' }, async $ => {
52    const [now, last] = await Promise.all([read($, session), read($, turn)])
53    return { text: formatTable(now, last) }
54  })
55}
56
hooks/tally.ts 64 lines
1/**
2 * Pure helpers for cm-tool-call-counter: no `$`, so they are unit-tested directly.
3 */
4import type { Tally } from '../types'
5
6export function emptyTally(): Tally {
7  return { count: 0, errors: 0, byTool: {} }
8}
9
10/** A new tally with one more call of `tool` (counted when the call starts). */
11export function addCall(tally: Tally, tool: string): Tally {
12  const prev = tally.byTool[tool] ?? { calls: 0, errors: 0 }
13  return {
14    count: tally.count + 1,
15    errors: tally.errors,
16    byTool: { ...tally.byTool, [tool]: { calls: prev.calls + 1, errors: prev.errors } },
17  }
18}
19
20/** A new tally with one failed call of `tool` (counted when the call settles). */
21export function addError(tally: Tally, tool: string): Tally {
22  const prev = tally.byTool[tool] ?? { calls: 0, errors: 0 }
23  return {
24    count: tally.count,
25    errors: tally.errors + 1,
26    byTool: { ...tally.byTool, [tool]: { calls: prev.calls, errors: prev.errors + 1 } },
27  }
28}
29
30export function plural(n: number, word: string): string {
31  return `${n} ${word}${n === 1 ? '' : 's'}`
32}
33
34/** What the spinner's suffix becomes: `… · 7 tools`. */
35export function spinnerSuffix(suffix: string, count: number): string {
36  return `${suffix} · ${plural(count, 'tool')}`
37}
38
39/** The status line text: `🔧 12 tool calls (1 error)`. */
40export function statusText(session: Tally): string {
41  const errors = session.errors > 0 ? ` (${plural(session.errors, 'error')})` : ''
42  return `🔧 ${plural(session.count, 'tool call')}${errors}`
43}
44
45/** Escapes a tool name for a Markdown table cell. */
46function cell(text: string): string {
47  return text.replace(/\|/g, '\\|')
48}
49
50/** The /tally answer: totals, then a Markdown table of the session by tool. */
51export function formatTable(session: Tally, turn: Tally): string {
52  const head =
53    `**Tool calls** · this turn: ${turn.count} · this session: ${session.count}` +
54    (session.errors > 0 ? ` (${plural(session.errors, 'error')})` : '')
55  const rows = Object.entries(session.byTool).sort(([a, x], [b, y]) => y.calls - x.calls || a.localeCompare(b))
56  if (rows.length === 0) return `${head}\n\nNo tool calls yet in this session.`
57  const table = [
58    '| Tool | Calls | Errors |',
59    '| --- | ---: | ---: |',
60    ...rows.map(([tool, t]) => `| ${cell(tool)} | ${t.calls} | ${t.errors} |`),
61  ]
62  return `${head}\n\n${table.join('\n')}`
63}
64
types/index.d.ts 21 lines
1/** Calls and failed calls of one tool. */
2export type ToolTally = { calls: number; errors: number }
3
4/** Tool calls counted over a span (one turn, or the session so far). */
5export type Tally = {
6  count: number
7  errors: number
8  byTool: Record<string, ToolTally>
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'cm-tool-call-counter': {
14      /** Calls since the current (or last) turn started. */
15      turn: Tally
16      /** Calls since the mod loaded in this session. */
17      session: Tally
18    }
19  }
20}
21