SLOPSHOPPER

fleet-gauge

Weekly cap, context fill and each subagent's call count above the prompt, with a warning when an agent runs long.

newbandguardcommandtoastagents
v0.1.0no licenseupdated 2026-10-06jeevankarandikar/skill-issue/fleet-gauge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fleet-gauge
› 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 › /fleet ⎿ fleet-gauge: 5h 31%, context 97k of 200k ⎿ fleet-gauge: No subagent has made a tool call in this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

fleet-gauge

fleet-gauge is a Claude Code mod that warns when usage needs a decision. It needs Claude Code 2.1.287 or later. Cursor and Codex get a smaller version, described at the end.

A row appears above the prompt only when one of these is true:

  • the weekly limit is at 50% or more
  • the five-hour limit is at 70% or more
  • the session's context is at 200k tokens or more
  • a subagent is running, with the call count of the longest one

A toast fires when a subagent reaches 150 tool calls, and again at 300. An agent rereads its whole context on every call, so long agents cost the most.

/fleet prints every reading and lists the session's subagents by tool calls made.

Load it

For one session:

claude --plugin-dir ~/Developer/GitHub/skill-issue/fleet-gauge

For every session, including the desktop app, add the path to ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/Developer/GitHub/skill-issue/fleet-gauge"
  }
}

Start a new session after changing it.

Change the thresholds

They are constants at the top of the hooks module. Run claude plugin test . in this folder after an edit.

Cursor and Codex

Neither agent lets a mod draw in its interface, so they get the parts that can be rebuilt.

python3 other-agents/install.py
  • Both get a hook that counts tool calls in a session. At 150 calls, and every 50 after, it tells the agent to finish the step in hand, write a handoff note and say a new session should take over. Set FLEET_GAUGE_CALLS to change the limit.
  • Codex also gets a status line showing context used and the five-hour and weekly limits. Codex draws that itself; the installer only turns it on, and leaves a status line you already set alone.
  • Cursor does not tell hooks about your usage and has no status row to write to, so there is no limits display there.

Codex skips a new hook until you trust it. Open Codex, type /hooks, and press t.

python3 other-agents/install.py --remove takes it out.

Source 2 files
hooks/register.tsx 198 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AgentRow, Gauge } from '../types'
5
6// An agent rereads its whole context on every call, so cost grows with the
7// square of its length. In the week of 2026-09-25 the agents past 150 calls
8// were 72% of the weekly cap, and the ones past 300 were 36%.
9const LONG = 150
10const VERY_LONG = 300
11const KEEP = 80
12
13const agents = atom({ plugin: 'fleet-gauge', key: 'agents' } as const, {})
14const gauge = atom({ plugin: 'fleet-gauge', key: 'gauge' } as const, null)
15
16const tint = (value: number, warn: number, stop: number) =>
17  value >= stop ? 'red' : value >= warn ? 'yellow' : undefined
18
19// The band is silent until a number needs a decision. /fleet prints them all.
20const SHOW_AT: Record<string, number> = { seven_day: 50, five_hour: 70 }
21const CONTEXT_AT = 200_000
22
23const thousands = (tokens: number) =>
24  tokens >= 1_000_000
25    ? `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`
26    : `${Math.round(tokens / 1000)}k`
27
28function gaugeLine(now: Gauge | null): string {
29  if (now === null) {
30    return 'No usage reading yet.'
31  }
32
33  const parts = now.limits.map(l => `${windowName(l.kind)} ${Math.round(l.percentUsed)}%`)
34
35  if (now.tokens !== undefined) {
36    parts.push(`context ${thousands(now.tokens)} of ${thousands(now.window)}`)
37  }
38
39  return parts.join(', ')
40}
41
42const windowName = (kind: string) =>
43  kind === 'seven_day' ? 'week' : kind === 'five_hour' ? '5h' : kind
44
45function trimmed(rows: Record<string, AgentRow>): Record<string, AgentRow> {
46  const entries = Object.entries(rows)
47
48  if (entries.length <= KEEP) {
49    return rows
50  }
51
52  return Object.fromEntries(
53    entries.sort((a, b) => b[1].lastAt - a[1].lastAt).slice(0, KEEP),
54  )
55}
56
57export const register: Register = on => {
58  on('session.start', async ($, e, next) => {
59    await $.command.register({
60      name: 'fleet',
61      description: "List this session's subagents by tool calls made",
62    })
63
64    return next(e)
65  })
66
67  on('session.measure', async ($, e, next) => {
68    const now: Gauge = {
69      tokens: e.context.tokens,
70      window: e.context.window,
71      limits: e.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({
72        kind,
73        percentUsed,
74        resetsAt,
75      })),
76    }
77    await update($, gauge, () => now)
78
79    return next(e)
80  })
81
82  on('agent.spawn', async ($, e, next) => {
83    const started = await next(e)
84    const id = 'agentId' in started ? started.agentId : undefined
85
86    if (id !== undefined) {
87      const at = await $.clock.now()
88      await update($, agents, rows => ({
89        ...rows,
90        [id]: {
91          calls: 0,
92          isDone: false,
93          ...rows[id],
94          label: e.description || e.subagentType,
95          lastAt: at,
96        },
97      }))
98    }
99
100    return started
101  })
102
103  on('tool.call', async ($, e, next) => {
104    const id = e.agentId
105
106    if (id === undefined) {
107      return next(e)
108    }
109
110    const at = await $.clock.now()
111    let row: AgentRow = { label: id.slice(0, 8), calls: 0, lastAt: at, isDone: false }
112    await update($, agents, rows => {
113      row = { ...row, ...rows[id], lastAt: at, isDone: false }
114      row = { ...row, calls: row.calls + 1 }
115
116      return trimmed({ ...rows, [id]: row })
117    })
118
119    if (row.calls === LONG || row.calls === VERY_LONG) {
120      $.ui.toast(
121        `${row.label} has made ${row.calls} tool calls. Long agents cost the most. Consider a handoff.`,
122      )
123    }
124
125    return next(e)
126  })
127
128  on('turn.complete', async ($, e, next) => {
129    const id = (e as { agentId?: string }).agentId
130
131    if (id !== undefined) {
132      await update($, agents, rows =>
133        rows[id] ? { ...rows, [id]: { ...rows[id], isDone: true } } : rows,
134      )
135    }
136
137    return next(e)
138  })
139
140  on('command.run', { command: 'fleet' }, async $ => {
141    const rows = Object.values(await read($, agents)).sort((a, b) => b.calls - a.calls)
142    const head = gaugeLine(await read($, gauge))
143
144    if (rows.length === 0) {
145      return { text: `${head}\nNo subagent has made a tool call in this session.` }
146    }
147
148    return {
149      text: [
150        head,
151        ...rows.map(
152          r => `${String(r.calls).padStart(4)} calls  ${r.isDone ? 'done   ' : 'running'}  ${r.label}`,
153        ),
154      ].join('\n'),
155    }
156  })
157
158  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
159    const now = await read($, gauge)
160    const rows = Object.values(await read($, agents))
161
162    const running = rows.filter(r => !r.isDone)
163    const longest = running.reduce<AgentRow | null>(
164      (top, r) => (top === null || r.calls > top.calls ? r : top),
165      null,
166    )
167    const limits = (now?.limits ?? []).filter(l => l.percentUsed >= (SHOW_AT[l.kind] ?? 80))
168    const tokens = now?.tokens !== undefined && now.tokens >= CONTEXT_AT ? now.tokens : undefined
169
170    if (e.props.hasSurvey || (limits.length === 0 && tokens === undefined && longest === null)) {
171      return next(e)
172    }
173
174    const { Box, Text } = $.ui.resolve(e)
175
176    return (
177      <Box>
178        {limits.map(limit => (
179          <Text color={tint(limit.percentUsed, 50, 80)}>
180            {windowName(limit.kind)} {Math.round(limit.percentUsed)}%{'   '}
181          </Text>
182        ))}
183        {now !== null && tokens !== undefined && (
184          <Text color={tint(tokens, CONTEXT_AT, 400_000)}>
185            context {thousands(tokens)} of {thousands(now.window)}{'   '}
186          </Text>
187        )}
188        {longest !== null && (
189          <Text color={tint(longest.calls, LONG, VERY_LONG)} dimColor={longest.calls < LONG}>
190            {running.length} {running.length === 1 ? 'agent' : 'agents'} running, longest{' '}
191            {longest.calls} calls ({longest.label})
192          </Text>
193        )}
194      </Box>
195    )
196  })
197}
198
types/index.d.ts 17 lines
1export type AgentRow = {
2  label: string
3  calls: number
4  lastAt: number
5  isDone: boolean
6}
7
8export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
9
10export type Gauge = { tokens?: number; window: number; limits: Limit[] }
11
12declare module 'claude-code' {
13  interface PluginState {
14    'fleet-gauge': { agents: Record<string, AgentRow>; gauge: Gauge | null }
15  }
16}
17