SLOPSHOPPER

fleet

/fleet toggles a pane with the subagent budget spent per model and the agents running now

newpaneguardcommandtooltimer
v0.1.0no licenseupdated 2026-10-07abdelrahman-elkady/claude-plugins/plugins/fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fleet
│ ┃ Fleet ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet. │ ⏺ 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 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Fleet
No subagents yet.
README

fleet

/fleet toggles a pane with the subagent budget you gave Claude and the agents running now.

Haiku 5.5   17/30
Opus 5.5    0/2
● Sonnet 5.5  port helpers    2m
done: 17
k: ▲ 0 more
  Haiku 5.5   Random tip: regex
  Haiku 5.5   Random tip: Docker
j: ▼ 13 more
  • Tell Claude a budget ("use up to 2 opus and 10 sonnet agents", "add 2 more opus") and it calls the plugin's set_agent_budget tool, so the pane shows it.
  • A new budget restarts the counts; "add" raises the caps and keeps them. With no budget the pane counts spawns without caps.
  • Counts every spawn per exact model, nested agents included. A count at its cap turns yellow, past it red.
  • Finished agents are listed newest first, failed or killed ones marked ✗. When the list does not fit, click ▲/▼ or focus the pane and press k/j.
  • It only shows the budget: Claude keeps to it, nothing is blocked.

Built on Claude Code plugin function hooks (mods); tested on 2.1.293.

Source 2 files
hooks/register.tsx 227 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, EngineInterface, Register, Timer } from 'claude-code'
3import type { Budget, BudgetArgs, FleetAgent } from '../types'
4
5const PANE = 'fleet'
6const TOOL = 'set_agent_budget'
7const ACTIVE = new Set<AgentStatus>(['pending', 'running', 'waiting', 'idle'])
8const FAILED = new Set<AgentStatus>(['failed', 'killed'])
9
10const budget = atom({ plugin: 'fleet', key: 'budget' } as const, {})
11const agents = atom({ plugin: 'fleet', key: 'agents' } as const, [])
12const doneOffset = atom({ plugin: 'fleet', key: 'doneOffset' } as const, 0)
13
14const MODEL = /(opus|sonnet|haiku|fable)(?:-(\d{1,2})(?!\d)(?:-(\d{1,2})(?!\d))?)?/i
15
16// `claude-opus-5-5` and the alias `opus` are both family `opus`; any other model is its own.
17const familyOf = (model: string) => MODEL.exec(model)?.[1]?.toLowerCase() ?? model
18
19// `claude-opus-5-5` (with or without a date suffix) reads `Opus 5.5`, the alias `opus` reads `Opus`.
20const labelOf = (model: string) => {
21  const [, family, major, minor] = MODEL.exec(model) ?? []
22  if (!family) return model
23
24  const name = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
25  if (!major) return name
26
27  return `${name} ${major}${minor ? `.${minor}` : ''}`
28}
29
30// A row keeps the full model id it already knows when the budget names only the alias.
31const modelFor = (known: string | undefined, given: string) =>
32  known && given.toLowerCase() === familyOf(given) ? known : given
33
34// `new` replaces the caps and restarts the count; `add` raises the caps and keeps it.
35const applyBudget = (current: Budget, { mode, caps }: BudgetArgs): Budget => {
36  const next: Budget = mode === 'new' ? {} : { ...current }
37  for (const [given, n] of Object.entries(caps)) {
38    const family = familyOf(given)
39    const line = next[family]
40    next[family] = { model: modelFor(current[family]?.model, given), cap: (line?.cap ?? 0) + n, spent: line?.spent ?? 0 }
41  }
42
43  return next
44}
45
46const elapsed = (ms: number) => {
47  const s = Math.max(0, Math.floor(ms / 1000))
48  if (s < 60) return `${s}s`
49
50  const m = Math.floor(s / 60)
51  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
52}
53
54// Agent statuses and elapsed times are not state, so the open pane asks again every second.
55let ticker: Timer | undefined
56const tick = ($: EngineInterface) => {
57  ticker ??= $.clock.every(1000, () => $.ui.invalidate('ui.render'))
58}
59
60const DESCRIPTION = [
61  'Call this every time the user sets, changes, adds to or clears a subagent budget ("use up to 3 opus and 2 sonnet agents", "add 2 more opus").',
62  'It shows the budget in their /fleet pane; it does not enforce it, so you still keep to the budget yourself.',
63  'mode "new" replaces the caps and restarts the count; "add" adds to the caps and keeps the count; "new" with empty caps clears the budget.',
64].join(' ')
65
66const SCHEMA = {
67  type: 'object',
68  properties: {
69    mode: { type: 'string', enum: ['new', 'add'], description: '"new" for a new budget, "add" to add to the current one' },
70    caps: {
71      type: 'object',
72      description: 'Agents allowed per model, by exact model id: {"claude-opus-5-5": 3, "claude-sonnet-5-5": 2}',
73      additionalProperties: { type: 'integer', minimum: 0 },
74    },
75  },
76  required: ['mode', 'caps'],
77}
78
79export const register: Register = on => {
80  on('session.start', async ($, e, next) => {
81    await $.command.register({
82      name: 'fleet',
83      description: 'Toggle the pane with the subagent budget and the agents running now',
84      immediate: true,
85    })
86    await $.tool.register({ name: TOOL, description: DESCRIPTION, inputSchema: SCHEMA, isDeferred: false })
87    // A reload keeps the pane open but stops the module's timers.
88    if ((await $.ui.panes()).some(pane => pane.id === PANE)) tick($)
89
90    return next(e)
91  })
92
93  on('command.run', { command: 'fleet' }, async $ => {
94    if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
95      await $.ui.close({ id: PANE })
96      return {}
97    }
98
99    const opened = await $.ui.open({ id: PANE, title: 'Fleet' })
100    tick($)
101    return opened.isPlaced ? {} : { text: `The fleet pane waits: ${opened.reason}` }
102  })
103
104  // Every close, the person's included, stops the redraws.
105  on('ui.close', { id: PANE }, ($, e, next) => {
106    ticker?.cancel()
107    ticker = undefined
108    return next(e)
109  })
110
111  on('tool.call', { tool: 'mcp__fleet__set_agent_budget' }, async ($, e) => {
112    // The schema only guides the model, so the caps are checked again here.
113    const mode = e.mode === 'add' ? 'add' : 'new'
114    const caps = Object.fromEntries(Object.entries(e.caps ?? {}).filter(([, n]) => Number.isInteger(n) && n >= 0))
115    const lines = await update($, budget, current => applyBudget(current, { mode, caps }))
116
117    // A new budget starts the done list again; agents still running stay listed.
118    if (mode === 'new') {
119      const live = new Set((await $.agent.list()).filter(one => ACTIVE.has(one.status)).map(one => one.id))
120      await Promise.all([update($, agents, list => list.filter(one => live.has(one.id))), update($, doneOffset, () => 0)])
121    }
122
123    const summary = Object.values(lines)
124      .map(line => `${labelOf(line.model)} ${line.spent}/${line.cap ?? '-'}`)
125      .join(', ')
126
127    return { result: summary ? `The /fleet pane shows: ${summary}.` : 'Budget cleared.' }
128  }).catch(() => ({ deny: 'The fleet pane could not record the budget.' }))
129
130  // Counts every spawn the engine made, nested ones too; never changes or blocks one.
131  // Bookkeeping that fails leaves the spawn as the engine answered it.
132  on('agent.spawn', async ($, e, next) => {
133    const spawned = await next(e)
134    if (spawned.deny !== undefined) return spawned
135
136    const { model, agentId } = spawned
137    const family = familyOf(model)
138    const [startedAt] = await Promise.all([
139      $.clock.now(),
140      update($, budget, current => {
141        const line = current[family]
142        return { ...current, [family]: { model: modelFor(line?.model, model), cap: line?.cap, spent: (line?.spent ?? 0) + 1 } }
143      }),
144    ])
145    if (agentId) {
146      await update($, agents, list => [...list, { id: agentId, model, description: e.description, startedAt }].slice(-200))
147    }
148
149    return spawned
150  }).catch(($, e, next) => next(e))
151
152  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
153    const { Box, Text, Button } = $.ui.resolve(e)
154    const [lines, spawned, scrolled, listed, now] = await Promise.all([
155      read($, budget),
156      read($, agents),
157      read($, doneOffset),
158      $.agent.list(),
159      $.clock.now(),
160    ])
161    // Ended agents leave `$.agent.list()` after a while, so a missing one is done too.
162    const status = new Map(listed.map(one => [one.id, one.status]))
163    const statusOf = (id: string) => status.get(id) ?? 'completed'
164    const active: FleetAgent[] = []
165    const finished: FleetAgent[] = []
166    for (const one of spawned) (ACTIVE.has(statusOf(one.id)) ? active : finished).push(one)
167    finished.reverse()
168    const rows = Object.values(lines).filter(line => line.cap !== undefined || line.spent > 0)
169    if (rows.length === 0 && spawned.length === 0) return <Text dimColor>No subagents yet.</Text>
170
171    const models = new Set([...rows.map(line => line.model), ...spawned.map(one => one.model)])
172    const labelWidth = Math.max(0, ...[...models].map(model => labelOf(model).length))
173
174    // The done list takes the rows left under the budget and the running agents, and scrolls past that.
175    const room = Math.max(3, e.props.scroll.bodyRows - rows.length - active.length - 1)
176    const isScrolling = finished.length > room
177    const size = isScrolling ? room - 2 : finished.length
178    const maxOffset = finished.length - size
179    const clamp = (n: number) => Math.max(0, Math.min(maxOffset, n))
180    const offset = clamp(scrolled)
181    const scroll = (by: number) => update($, doneOffset, n => clamp(clamp(n) + by))
182
183    return (
184      <Box flexDirection="column">
185        {rows.map(line => {
186          const tone = line.cap === undefined || line.spent < line.cap ? undefined : line.spent > line.cap ? 'error' : 'warning'
187          return (
188            <Text key={`budget-${line.model}`} color={tone} wrap="truncate-end">
189              {`${labelOf(line.model).padEnd(labelWidth)}  ${line.spent}${line.cap === undefined ? '' : `/${line.cap}`}`}
190            </Text>
191          )
192        })}
193        {active.map(one => {
194          const isRunning = statusOf(one.id) === 'running'
195          return (
196            <Box key={`agent-${one.id}`} flexDirection="row">
197              <Text color={isRunning ? 'success' : undefined} dimColor={!isRunning}>●</Text>
198              <Text>{` ${labelOf(one.model).padEnd(labelWidth)}  `}</Text>
199              <Box flexGrow={1}>
200                <Text wrap="truncate-end">{one.description}</Text>
201              </Box>
202              <Box flexShrink={0}>
203                <Text>{`  ${elapsed(now - one.startedAt)}`}</Text>
204              </Box>
205            </Box>
206          )
207        })}
208        {finished.length > 0 && <Text dimColor>done: {finished.length}</Text>}
209        {isScrolling && (
210          <Button key="done-up" label={`▲ ${offset} more`} hotkey="k" plain dimColor={offset === 0} onPress={() => scroll(-size)} />
211        )}
212        {finished.slice(offset, offset + size).map(one => {
213          const isFailed = FAILED.has(statusOf(one.id))
214          return (
215            <Text key={`done-${one.id}`} color={isFailed ? 'error' : undefined} dimColor={!isFailed} wrap="truncate-end">
216              {`${isFailed ? '✗' : ' '} ${labelOf(one.model).padEnd(labelWidth)}  ${one.description}`}
217            </Text>
218          )
219        })}
220        {isScrolling && (
221          <Button key="done-down" label={`▼ ${maxOffset - offset} more`} hotkey="j" plain dimColor={offset === maxOffset} onPress={() => scroll(size)} />
222        )}
223      </Box>
224    )
225  })
226}
227
types/index.d.ts 23 lines
1// One budget row: a model family's cap and how many agents it spawned since the budget was set.
2export type BudgetLine = { model: string; cap?: number; spent: number }
3
4// Keyed by model family (`opus`, `sonnet`, ...).
5export type Budget = Record<string, BudgetLine>
6
7// What Claude passes to `set_agent_budget`: caps keyed by model id or alias.
8export type BudgetArgs = { mode: 'new' | 'add'; caps: Record<string, number> }
9
10// A spawned subagent; its live status comes from `$.agent.list()` while drawing.
11export type FleetAgent = { id: string; model: string; description: string; startedAt: number }
12
13declare module 'claude-code' {
14  interface PluginState {
15    // `doneOffset`: how many finished agents the done list has scrolled past.
16    fleet: { budget: Budget; agents: FleetAgent[]; doneOffset: number }
17  }
18
19  interface McpToolInputs {
20    mcp__fleet__set_agent_budget: BudgetArgs
21  }
22}
23