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.

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.
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)).
claude plugin marketplace add <owner>/<repo>
claude plugin install cm-tool-call-counter@claudemods
| Key | Type | Default | Description |
|---|---|---|---|
showInSpinner | boolean | true | Append " · N tools" (this turn's count) to the working spinner. |
showInStatus | boolean | false | Also show the session's total tool calls and errors in the status line. |
| API | Why |
|---|---|
$.command.register | Registers the /tally command at session start. |
$.state.get / $.state.set | Keeps the turn and session tallies in cm-tool-call-counter.turn / .session, so the spinner redraws as calls come in. |
$.ui.status | Only when showInStatus is on: the session total in the status line. |
Tested with Claude Code 2.1.291.
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}.isError or is denied by another hook.hooks/tally.ts; the state type is declared in types/index.d.ts.MIT. See LICENSE. More at https://claudemods.app/mods/cm-tool-call-counter.
hooks/register.ts 56 lines1import { 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}
56hooks/tally.ts 64 lines1/**
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}
64types/index.d.ts 21 lines1/** 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