SLOPSHOPPER

subagent-meter

A live status line showing how much of your session's tokens go to subagents, and which agent type uses the most.

newstatus
A shopper browsing a rack in a slop shop
README

subagent-meter: a Claude Code mod by Naren

A live status line showing how much of your session's tokens go to subagents, and which agent type uses the most.

Part of Naren's Claude Toolkit.

What it shows

One line under the prompt, refreshed after every turn once a subagent has run:

subagents 14x · 12% of output · 54% of all tokens · top Explore
  • 14x: how many subagents (and in-process teammates) have finished a turn this session.
  • 12% of output: the share of output tokens that subagents produced, out of main thread plus subagents.
  • 54% of all tokens: the same share counting every token processed (fresh input, cache reads, cache writes and output).
  • top Explore: the agent type that used the most tokens.

Read the two shares together. When "all tokens" is far above "output", the subagents are mostly re-reading context, not producing work: that gap is the context tax. The line disappears when no subagent has run, and resets on /clear and /resume.

What it does not do

It is tokens only: no prices, no history, and it never reads old transcripts. For cost after the fact, per agent type, with suggestions for cheaper models, use the subagent-tax-auditor skill. The mod only watches: it never changes, delays or blocks a turn.

Try it

This is a mod, so it loads from a folder rather than from the skills directory:

claude --plugin-dir plugins/subagent-meter

Then ask Claude to run a few subagents (for example "use three Explore agents to look at three folders") and watch the line appear. The counts live in session state, so they survive a reload of the mod.

Marketplace install:

/plugin marketplace add NarenDawar/narens-claude-toolkit
/plugin install subagent-meter@narens-claude-toolkit

Tests

The logic is unit-tested and the hooks are tested with the mod test kit:

claude plugin test plugins/subagent-meter
claude plugin validate plugins/subagent-meter

These run locally. The repository's CI runs on a runner without the Claude CLI, so it checks the manifest and the module path but does not run these tests yet.

Known limits

  • It measures the current session only and does not persist across sessions.
  • An agent's type is looked up the first time the agent is seen; if the engine does not list it yet, it counts as unknown.
  • It stores a small record per agent (about 100 bytes); there is no cap, which only matters for sessions with thousands of agents.

Made by Naren. If this helped, star the repo.

Source 3 files
hooks/register.ts 60 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { addTurn, emptyStats, statusLine } from './meter'
5
6const stats = atom({ plugin: 'subagent-meter', key: 'stats' } as const, emptyStats())
7
8export const register: Register = on => {
9  on('turn.complete', async ($, e, next) => {
10    const result = await next(e)
11
12    try {
13      const { usage, agentId } = e
14      if (usage !== undefined) {
15        let type: string | undefined
16        if (agentId !== undefined && (await read($, stats)).agents[agentId] === undefined) {
17          try {
18            type = (await $.agent.list()).find(agent => agent.id === agentId)?.type
19          } catch {
20            type = undefined
21          }
22        }
23        await update($, stats, held => addTurn(held, { agentId, type, usage }))
24        $.ui.status(statusLine(await read($, stats)))
25      }
26    } catch {
27      // The meter only watches; a failure here must never reach the turn.
28    }
29
30    return result
31  })
32
33  on('session.start', async ($, e, next) => {
34    const result = await next(e)
35
36    try {
37      $.ui.status(statusLine(await read($, stats)))
38    } catch {
39      // Same rule: never break the session.
40    }
41
42    return result
43  })
44
45  on('session.end', async ($, e, next) => {
46    // /clear and /resume both end the conversation while the process goes on under another
47    // session, and the counts in session state would otherwise carry over into it.
48    if (e.reason === 'clear' || e.reason === 'resume') {
49      try {
50        await update($, stats, () => emptyStats())
51        $.ui.status(undefined)
52      } catch {
53        // Same rule: never break the session.
54      }
55    }
56
57    return next(e)
58  })
59}
60
hooks/meter.ts 98 lines
1import type { MeterStats } from '../types'
2
3export type Usage = {
4  input_tokens?: number
5  output_tokens?: number
6  cache_read_input_tokens?: number
7  cache_creation_input_tokens?: number
8}
9
10export type Turn = { agentId?: string; type?: string; usage?: Usage }
11
12export type Summary = {
13  spawns: number
14  outputShare: number
15  allShare: number
16  topType: string
17}
18
19const MAX_TYPE_LENGTH = 16
20
21const count = (n: unknown): number =>
22  typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : 0
23
24const share = (part: number, whole: number): number => (whole > 0 ? part / whole : 0)
25
26const compare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
27
28const percent = (ratio: number): string => {
29  const value = ratio * 100
30  return value > 0 && value < 1 ? '<1%' : `${Math.round(value)}%`
31}
32
33export const emptyStats = (): MeterStats => ({ main: { output: 0, all: 0 }, agents: {} })
34
35export const tokensOf = (usage: Usage): { output: number; all: number } => {
36  const output = count(usage.output_tokens)
37  const all =
38    count(usage.input_tokens) +
39    count(usage.cache_read_input_tokens) +
40    count(usage.cache_creation_input_tokens) +
41    output
42  return { output, all }
43}
44
45export const addTurn = (stats: MeterStats, turn: Turn): MeterStats => {
46  if (turn.usage === undefined) {
47    return stats
48  }
49  const { output, all } = tokensOf(turn.usage)
50  if (turn.agentId === undefined) {
51    return {
52      ...stats,
53      main: { output: stats.main.output + output, all: stats.main.all + all },
54    }
55  }
56  const held = stats.agents[turn.agentId] ?? { type: turn.type ?? 'unknown', output: 0, all: 0 }
57  return {
58    ...stats,
59    agents: {
60      ...stats.agents,
61      [turn.agentId]: { type: held.type, output: held.output + output, all: held.all + all },
62    },
63  }
64}
65
66export const summarize = (stats: MeterStats): Summary | undefined => {
67  const agents = Object.values(stats.agents)
68  if (agents.length === 0) {
69    return undefined
70  }
71  const sum = agents.reduce(
72    (total, agent) => ({ output: total.output + agent.output, all: total.all + agent.all }),
73    { output: 0, all: 0 },
74  )
75  const perType = new Map<string, number>()
76  for (const agent of agents) {
77    perType.set(agent.type, (perType.get(agent.type) ?? 0) + agent.all)
78  }
79  const [topType] = [...perType.entries()].sort(
80    (a, b) => b[1] - a[1] || compare(a[0], b[0]),
81  )[0]
82  return {
83    spawns: agents.length,
84    outputShare: share(sum.output, sum.output + stats.main.output),
85    allShare: share(sum.all, sum.all + stats.main.all),
86    topType,
87  }
88}
89
90export const formatLine = (summary: Summary): string =>
91  `subagents ${summary.spawns}x · ${percent(summary.outputShare)} of output · ` +
92  `${percent(summary.allShare)} of all tokens · top ${summary.topType.slice(0, MAX_TYPE_LENGTH)}`
93
94export const statusLine = (stats: MeterStats): string | undefined => {
95  const summary = summarize(stats)
96  return summary === undefined ? undefined : formatLine(summary)
97}
98
types/index.d.ts 13 lines
1export type MeterAgent = { type: string; output: number; all: number }
2
3export type MeterStats = {
4  main: { output: number; all: number }
5  agents: Record<string, MeterAgent>
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'subagent-meter': { stats: MeterStats }
11  }
12}
13