SLOPSHOPPER

Turn Meter

Shows how long each turn took, how many tools it used in the main thread and in subagents, and the tokens it spent, in a band above the prompt, and alerts you…

newbandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-meter
› 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 › /turn-meter ⎿ turn-meter: Turn Meter band hidden. Run /turn-meter to show it again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

⏱️ Turn Meter

Version Works in Category Claude Code License: MIT

Why use it • What's inside • Install • Usage • Compatibility • FAQ


💻 Claude Code only — it is a mod: it draws in Claude Code's own interface, which the Claude app's chat and Cowork don't load.

[!TIP] TL;DR — After every turn, one line above the prompt tells you how long it took, how many tools Claude and its subagents ran and how many tokens it spent, and a long turn alerts you when it ends: a toast by default, or a native notification.

✨ Why use it

  • See what a turn cost at a glance — time, tool calls and tokens in one line, without opening /cost or the transcript.
  • Know how much work subagents did — tool calls are split between the main thread and subagents, and their tokens are added in.
  • Walk away from long turns — a toast or a native notification tells you when a turn that ran past your threshold is done.
  • Spot what went wrong — an interrupted turn, an API error and a refusal each say so, with how long they ran.
  • Stays out of the way — it only observes: it never changes a prompt, a tool call or a reply, and the Hide button or /turn-meter hides it.

🧩 What's inside

ComponentNameWhat it doesHow it runs
🧩 Modturn-meterCounts each turn's tools and tokens, draws the last-turn band above the prompt, alerts on long turns, and adds the /turn-meter commandAutomatically in every Claude Code session once installed

✅ Requirements

RequirementDetails
Claude Codev2.1.295 or later — check with claude --version. Tested with v2.1.308.
SurfacesSee Compatibility
External toolsNone
Accounts / API keysNone
Native notifications (optional)A terminal Claude Code can notify through: iTerm2, kitty or Ghostty, or the terminal bell. Claude Code picks it with its preferredNotifChannel setting. Where none is available, the alert falls back to a toast.

[!NOTE] Turn Meter makes no network calls, runs no commands, and reads or writes no files. It keeps the last turn's summary in the session's memory only, so nothing carries over between sessions.

📥 Installation

# One step: adds the marketplace (if needed) and installs the plugin — Claude Code v2.1.292+
claude plugin install turn-meter --marketplace nerymurillohnd/the-claude-factory

# Or, with the marketplace already added
claude plugin install turn-meter@the-claude-factory

Add --scope project to enable it for everyone working in the current repository; each collaborator still installs it once on their own machine.

/plugin install turn-meter --marketplace nerymurillohnd/the-claude-factory

Requires Claude Code v2.1.275+. With the marketplace already added, /plugin install turn-meter@the-claude-factory opens the plugin's details so you can pick a scope. You can also browse it in /plugin → Discover.

With the marketplace added, click + next to the prompt box → Add plugins (or Plugins → Browse plugins), or open Settings → Plugins → Discover; then select Turn Meter and choose a scope.

[!NOTE] New to this marketplace? The installation guide covers every surface step by step.

A mod runs with your permissions and no sandbox. Before you install it, you can see everything it hooks and calls with claude plugin validate <path to the plugin folder>.

🚀 Usage

Install it and work as usual. The band appears after your first turn ends.

Example: a turn with a subagent

Last turn: 3m 12s · 24 tools (9 in 2 subagents) · 41.2k in / 6.3k out   [ Hide ]

Result: the turn took 3 minutes 12 seconds. Claude ran 15 tools itself and two subagents ran 9 more. Input tokens count uncached input, cache reads and cache writes, for the main thread and its subagents together.

Example: a turn that did not finish

How the turn endedThe band reads
You pressed EscLast turn: interrupted after 1m 05s · 7 tools
An API error ended itLast turn: ended by API error after 40s · 3 tools
The model refusedLast turn: refused after 12s
A narrow band (under 60 columns of room)3m12s · 24 tools · 48k tok

Example: hide or show the band

/turn-meter

Result: the band hides if it is showing and comes back if it is hidden. The Hide button on the band hides it too.

Example: get a notification when a long turn ends

claude plugin install turn-meter@the-claude-factory --config alert=notify --config alertAfterSeconds=90

Result: a turn that runs 90 seconds or more sends a native notification ("That turn took 3m 12s") when it ends. A turn you interrupt never alerts: you were there.

⚙️ Configuration

OptionTypeDefaultDescription
alerttoast, notify or offtoastHow a long turn alerts you: a message inside Claude Code, a native notification (a toast when none can be sent), or nothing
alertAfterSecondsnumber, at least 1120A main turn that runs at least this long alerts you
showTokensbooleantrueShow the turn's tokens in the band; turn it off to see only time and tools

Change them in /config, where alert is a picker (v2.1.271+), or in /plugin → Installed → Turn Meter → Configure options. From the shell, pass --config <option>=<value> to claude plugin install.

🔄 Updating

claude plugin update turn-meter@the-claude-factory

Or in a session: /plugin → Installed → Turn Meter → Update now. Then run /reload-plugins.

[!TIP] Turn on auto-update once for the whole marketplace: /plugin → Marketplaces → the-claude-factory → Enable auto-update.

🗑️ Uninstalling

claude plugin uninstall turn-meter@the-claude-factory

In a session: /plugin → Installed → Turn Meter → Uninstall.

🧭 Compatibility

Works in: 💻 Claude Code only

ComponentClaude CodeClaude app
🧩 Mods✅ every Claude Code session except a Desktop session on WSL⚠️ the plugin directory lists a mod for Claude Code only

<sub>✅ works · ⚠️ partial · 🔗 manual step · ➖ ignored · ❌ the plugin can't be installed there. The Claude app (web, desktop and mobile) runs a conversation as a Cowork session when it can, and as a chat conversation otherwise, for example on a device that can't run Cowork or in the separate Chat mode that Team and Enterprise plans keep for now.</sub>

The band draws in the terminal and the desktop app's Code tab. The VS Code extension does not draw it yet: there the mod still counts and alerts, and /turn-meter still works.

Limitations

  • A tool call counts when Claude makes it, even if a permission rule or a hook then denies it.
  • A background subagent that finishes after its turn ended counts toward the next turn.
  • If the plugin reloads in the middle of a turn, such as when you update it, that turn's counts start over.
  • These settings turn the mod off: disableAllHooks, allowManagedModsOnly and allowManagedHooksOnly in managed settings, and starting Claude Code with --safe-mode or --bare.
  • In organizations with managed settings or a Team or Enterprise login, Claude Code's built-in guard runs ahead of every mod and can refuse to load it.

❓ FAQ & Troubleshooting

The band appears only after the first turn of a session ends, and it steps aside while Claude Code shows a survey above the prompt. If you hid it, type /turn-meter. Then run /plugin: it lists active mods as N mod active · <names>. If turn-meter is missing there, start Claude Code with claude --debug and look in the debug log for turn-meter: a line saying the module was not loaded, or a hook was skipped, names the reason.

Claude Code could not send a native notification: your terminal has no notification channel it supports, notifications are turned off, or the session has no terminal, as in the desktop app. Turn Meter then shows a toast so you still see the alert. Check the preferredNotifChannel setting in /config.

It hooks the start of each session (to add /turn-meter), the start and end of each turn, each tool call, the band above the prompt, and the /turn-meter command. It calls only Claude Code's interface: registering the command, the session's own memory, drawing, toasts and notifications. It never changes a prompt, a tool call or a reply, and it never touches files, processes, the network or the model. claude plugin validate lists the same hooks and calls.

🔗 Related

🐞 Found a problem?

Report a bug • Request a feature • Ask a question

📝 Changelog

See CHANGELOG.md for every release.

📄 License

MIT © Nery Murillo

Source 2 files
hooks/register.tsx 233 lines
1import type { On, PluginOptions, Register, TurnUsage } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { TurnSummary } from '../types'
5
6type Alert = 'toast' | 'notify' | 'off'
7
8type Settings = {
9  alert: Alert
10  alertAfterMs: number
11  showTokens: boolean
12}
13
14const COMMAND = 'turn-meter'
15const NARROW_COLUMNS = 60
16
17const last = atom({ plugin: 'turn-meter', key: 'last' } as const, null)
18const isHidden = atom({ plugin: 'turn-meter', key: 'isHidden' } as const, false)
19
20// The engine fills in the manifest's defaults; these only cover a value of the wrong type.
21const readSettings = (options: PluginOptions): Settings => {
22  const { alert, alertAfterSeconds, showTokens } = options
23  const seconds =
24    typeof alertAfterSeconds === 'number' && alertAfterSeconds > 0 ? alertAfterSeconds : 120
25
26  return {
27    alert: alert === 'notify' || alert === 'off' ? alert : 'toast',
28    alertAfterMs: seconds * 1000,
29    showTokens: typeof showTokens === 'boolean' ? showTokens : true,
30  }
31}
32
33const tokensIn = (usage: TurnUsage | undefined): number =>
34  usage === undefined
35    ? 0
36    : usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
37
38// "3m 12s", "1m 05s" and "40s"; the short form drops the space: "3m12s".
39export const formatDuration = (ms: number, isShort = false): string => {
40  const total = Math.round(ms / 1000)
41  const minutes = Math.floor(total / 60)
42  const seconds = total % 60
43
44  if (minutes === 0) {
45    return `${seconds}s`
46  }
47
48  return `${minutes}m${isShort ? '' : ' '}${String(seconds).padStart(2, '0')}s`
49}
50
51// "6.3k" and "41.2k"; the short form rounds to whole thousands: "47k". The unit is picked after
52// rounding, so 999,999 reads "1M", never "1000k".
53export const formatTokens = (count: number, isShort = false): string => {
54  const thousands = isShort ? String(Math.round(count / 1000)) : (count / 1000).toFixed(1)
55
56  if (count >= 1000 && Number(thousands) < 1000) {
57    return `${thousands.replace(/\.0$/, '')}k`
58  }
59
60  if (count >= 1000) {
61    return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
62  }
63
64  return String(count)
65}
66
67const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
68
69const LEAD: Record<TurnSummary['reason'], string> = {
70  answer: '',
71  aborted: 'interrupted after ',
72  error: 'ended by API error after ',
73  refusal: 'refused after ',
74}
75
76const SHORT_LEAD: Record<TurnSummary['reason'], string> = {
77  answer: '',
78  aborted: 'interrupted ',
79  error: 'API error ',
80  refusal: 'refused ',
81}
82
83export const bandText = (turn: TurnSummary, showTokens: boolean, columns: number): string => {
84  const isShort = columns < NARROW_COLUMNS
85  const parts = [
86    `${(isShort ? SHORT_LEAD : LEAD)[turn.reason]}${formatDuration(turn.durationMs, isShort)}`,
87  ]
88
89  if (turn.reason !== 'refusal') {
90    const tools = plural(turn.tools + turn.subagentTools, 'tool')
91    const split =
92      isShort || turn.subagents === 0
93        ? ''
94        : ` (${turn.subagentTools} in ${plural(turn.subagents, 'subagent')})`
95    parts.push(`${tools}${split}`)
96  }
97
98  if (showTokens && turn.tokensIn + turn.tokensOut > 0) {
99    parts.push(
100      isShort
101        ? `${formatTokens(turn.tokensIn + turn.tokensOut, true)} tok`
102        : `${formatTokens(turn.tokensIn)} in / ${formatTokens(turn.tokensOut)} out`,
103    )
104  }
105
106  return `${isShort ? '' : 'Last turn: '}${parts.join(' · ')}`
107}
108
109export const register: Register = (on: On, options: PluginOptions): void => {
110  const settings = readSettings(options)
111
112  // The turn in progress. A reload in the middle of a turn starts these over,
113  // so that one turn comes out short; the last finished turn lives in $.state.
114  let tools = 0
115  let subagentTools = 0
116  let subagentIds = new Set<string>()
117  let subagentTokensIn = 0
118  let subagentTokensOut = 0
119
120  on('session.start', async ($, e, next) => {
121    await $.command.register({
122      name: COMMAND,
123      description: 'Show or hide the last-turn band above the prompt',
124    })
125
126    return next(e)
127  })
128
129  on('turn.start', (_$, e, next) => {
130    if (e.agentId === undefined) {
131      tools = 0
132      subagentTools = 0
133      subagentIds = new Set()
134      subagentTokensIn = 0
135      subagentTokensOut = 0
136    }
137
138    return next(e)
139  })
140
141  on('tool.call', (_$, e, next) => {
142    if (e.agentId === undefined) {
143      tools += 1
144    } else {
145      subagentTools += 1
146      subagentIds.add(e.agentId)
147    }
148
149    return next(e)
150  })
151
152  on('turn.complete', async ($, e, next) => {
153    if (e.agentId !== undefined) {
154      subagentIds.add(e.agentId)
155      subagentTokensIn += tokensIn(e.usage)
156      subagentTokensOut += e.usage?.output_tokens ?? 0
157
158      return next(e)
159    }
160
161    const turn: TurnSummary = {
162      durationMs: e.durationMs,
163      reason: e.reason,
164      tools,
165      subagentTools,
166      subagents: subagentIds.size,
167      tokensIn: tokensIn(e.usage) + subagentTokensIn,
168      tokensOut: (e.usage?.output_tokens ?? 0) + subagentTokensOut,
169    }
170    await update($, last, () => turn)
171
172    // An interrupted turn needs no alert: the person pressed Esc and is watching.
173    const isLong = turn.durationMs >= settings.alertAfterMs && turn.reason !== 'aborted'
174    if (isLong && settings.alert !== 'off') {
175      const text = `That turn took ${formatDuration(turn.durationMs)}`
176      // A notification nobody can receive (no terminal channel, the desktop app)
177      // falls back to a toast, so the alert is never lost.
178      const isSent =
179        settings.alert === 'notify' &&
180        (await $.ui.notify(text).then(
181          (sent) => sent.isSent,
182          () => false,
183        ))
184      if (!isSent) {
185        $.ui.toast(text)
186      }
187    }
188
189    return next(e)
190  })
191
192  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
193    const turn = await read($, last)
194
195    if (e.props.hasSurvey || turn === null || (await read($, isHidden))) {
196      return next(e)
197    }
198
199    const { Box, Button, Text } = $.ui.resolve(e)
200    // The band is shared: what the mods after this one draw stays beneath the row.
201    const below = await next(e)
202
203    return (
204      <Box flexDirection="column">
205        <Box flexDirection="row" gap={1}>
206          <Text dimColor={true}>{bandText(turn, settings.showTokens, e.props.bodyColumns)}</Text>
207          <Button
208            key="hide"
209            label="Hide"
210            onPress={(): Promise<boolean> => update($, isHidden, () => true)}
211          />
212        </Box>
213        {below}
214      </Box>
215    )
216  })
217
218  on('command.run', { command: COMMAND }, async ($) => {
219    const hidden = await update($, isHidden, (value) => !value)
220    const hasTurn = (await read($, last)) !== null
221
222    if (hidden) {
223      return { text: 'Turn Meter band hidden. Run /turn-meter to show it again.' }
224    }
225
226    return {
227      text: hasTurn
228        ? 'Turn Meter band shown.'
229        : 'Turn Meter band shown. It appears after the first turn ends.',
230    }
231  })
232}
233
types/index.d.ts 25 lines
1/** Why a turn ended, as `turn.complete` reports it. */
2export type TurnReason = 'answer' | 'aborted' | 'error' | 'refusal'
3
4/** One finished main-loop turn, as the band shows it. */
5export type TurnSummary = {
6  durationMs: number
7  reason: TurnReason
8  /** Tool calls made by the main loop. */
9  tools: number
10  /** Tool calls made by subagents during the turn. */
11  subagentTools: number
12  /** Distinct subagents that ran during the turn. */
13  subagents: number
14  /** Input tokens: uncached, cache reads and cache writes, main loop and subagents. */
15  tokensIn: number
16  /** Output tokens, main loop and subagents. */
17  tokensOut: number
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'turn-meter': { last: TurnSummary | null; isHidden: boolean }
23  }
24}
25