SLOPSHOPPER

turn-counter

Counts this turn's tool calls in a band above the prompt.

newbandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-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 › /turn-counter ⎿ turn-counter: Usage: /turn-counter [demo | hide | show] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Turn Counter

Counts this turn's tool calls in a band above the prompt.

<!-- One screen: what the person sees and why it helps. Replace this comment with a short paragraph and, if it draws, an ASCII capture of its main state from SPEC.md. -->

Try it

/turn-counter demo

<!-- List every subcommand the mod answers, one line each. -->

Install

From a GitHub marketplace that lists it:

/plugin install turn-counter --marketplace <owner>/<repo>

From a local folder marketplace (claude plugin marketplace add <folder> once):

/plugin install turn-counter@<marketplace-name>

While developing, from its folder:

claude --plugin-dir <path-to>/turn-counter

Changes load with /reload-plugins or a new session.

Options

<!-- One row per userConfig key from plugin.json, or "None." -->

OptionDefaultWhat it does

What it reaches

Mods are not sandboxed. They run with your access and skip Claude's permission prompts, so this table lists everything this one can touch. It is generated from claude plugin validate.

<!-- Fill from the validate output's hooks / calls / gating lines: -->

ReachDetail
Hooks
Processes
Files
Network
Model calls
Gating hooks

Development

claude plugin validate .
claude plugin test .

Built on Claude Code <version>. Written spec-first with /mod-builder:build; see SPEC.md.

Source 3 files
hooks/register.tsx 77 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { bandTextOf, countCall } from './turn-counter-logic'
5
6/**
7 * Turn Counter: a band above the prompt counting this turn's tool calls.
8 * Archetype: band. It composes with the bands below, steps aside for a
9 * survey, and hides when idle.
10 */
11
12const turn = atom({ plugin: 'turn-counter', key: 'turn' } as const, null)
13const isHidden = atom({ plugin: 'turn-counter', key: 'isHidden' } as const, false)
14
15async function showDemo($: EngineInterface): Promise<void> {
16  await update($, isHidden, () => false)
17  await update($, turn, () => ({ tools: 7, failures: 1 }))
18}
19
20export const register: Register = on => {
21  on('session.start', async ($, e, next) => {
22    await $.command
23      .register({ name: 'turn-counter', description: "Counts this turn's tool calls in a band above the prompt.", argumentHint: '[demo | hide | show]' })
24      .catch(() => undefined)
25    return next(e)
26  })
27
28  on('command.run', { command: 'turn-counter' }, async ($, e) => {
29    const word = e.args.trim().toLowerCase()
30    if (word === 'demo') {
31      void showDemo($).catch(() => undefined)
32      return { text: 'Turn Counter: a demo band is above the prompt. It clears when the next turn ends.' }
33    }
34    if (word === 'hide' || word === 'show') {
35      void update($, isHidden, () => word === 'hide').catch(() => undefined)
36      return { text: `Turn Counter: band ${word === 'hide' ? 'hidden' : 'shown'}.` }
37    }
38    return { text: 'Usage: /turn-counter [demo | hide | show]' }
39  })
40
41  on('prompt.submit', async ($, e, next) => {
42    await update($, turn, () => null)
43    return next(e)
44  }).catch(($, e, next) => next(e))
45
46  on('tool.call', async ($, e, next) => {
47    const ran = await next(e)
48    if (!e.agentId) await update($, turn, current => countCall(current, ran.deny !== undefined || ran.isError === true))
49    return ran
50  }).catch(($, e, next) => next(e))
51
52  on('turn.complete', async ($, e, next) => {
53    if (!e.agentId) await update($, turn, () => null)
54    return next(e)
55  })
56
57  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
58    const rest = await next(e)
59    try {
60      if (e.props.hasSurvey || e.props.maxRows < 1 || (await read($, isHidden))) return rest
61      const text = bandTextOf(await read($, turn), e.props.bodyColumns - 2)
62      if (!text) return rest
63      const { Box, Text } = $.ui.resolve(e)
64      return (
65        <Box flexDirection="column">
66          <Box paddingX={1}>
67            <Text color="#D97757">{text}</Text>
68          </Box>
69          {rest}
70        </Box>
71      )
72    } catch {
73      return rest
74    }
75  })
76}
77
hooks/turn-counter-logic.ts 22 lines
1/**
2 * Turn Counter's rules, free of `$` so tests can call them.
3 */
4
5import type { Turn } from '../types'
6
7export type { Turn }
8
9/** The band's one line, cut to `columns`; empty when there is nothing to say. */
10export function bandTextOf(turn: Turn | null, columns: number): string {
11  if (!turn || turn.tools === 0) return ''
12  const calls = `${turn.tools} tool call${turn.tools === 1 ? '' : 's'}`
13  const failed = turn.failures ? ` · ${turn.failures} failed` : ''
14  return `◆ turn-counter  ${calls} this turn${failed}`.slice(0, Math.max(0, columns))
15}
16
17/** One more call, failed or not. */
18export function countCall(turn: Turn | null, isError: boolean): Turn {
19  const base = turn ?? { tools: 0, failures: 0 }
20  return { tools: base.tools + 1, failures: base.failures + (isError ? 1 : 0) }
21}
22
types/index.d.ts 10 lines
1// Turn Counter's state contract. Self-contained: no imports.
2
3export type Turn = { tools: number; failures: number }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'turn-counter': { turn: Turn | null; isHidden: boolean }
8  }
9}
10