SLOPSHOPPER

subagent-guard

Keeps helper agents (subagents) from running up your usage: limit how many run at once, cap their model, and watch them work in a live pane.

newpaneguardcommandstatusprompt
v0.1.2MITupdated 2026-10-11SteveAnthonyFM/subagent-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagent-guard
│ ┃ Helper Agent Safeguard ✕ › fix the failing auth test and add an audit log call │ ┃ Helper Agent Safeguard │ ┃ Keeps background helpers from running up ⏺ Read(src/auth.ts) │ ┃ your usage. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ ⏺ Bash(bun test) │ ┃ │ 🦀🦀 │ ⎿ 3 pass, 1 fail │ ┃ │ Claude and 1 helper, ready when │ │ ┃ │ needed. │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ ╰────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╭────────────────────────────────────────╮ › /subagents │ ┃ │ Helper agents ● Allowed │ ⎿ subagent-guard: Opening the Helper Agent Safeguard pane. Now: 1 │ ┃ │ Claude may hand side tasks to helpers. │ │ ┃ │ │ │ ┃ │ [ Turn off ] │ │ ┃ ╰────────────────────────────────────────╯ │ ┃ │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ How many at a time │ │ ┃ │ Helpers working side by side. One │ │ ┃ │ keeps things calm and cheap. │ │ ┃ │ │ │ ┃ │ [ − ] 1 [ + ] of up to 8 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ subagent-guard: Helper agents: 1 at a time · up to Haiku

Draws

Pane · Helper Agent Safeguard
Helper Agent Safeguard Keeps background helpers from running up your usage. ╭────────────────────────────────────────────────────────╮ │ │ │ 🦀🦀 │ │ Claude and 1 helper, ready when needed. │ │ │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ Helper agents ● Allowed │ │ Claude may hand side tasks to helpers. │ │ │ │ [ Turn off ] │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ How many at a time │ │ Helpers working side by side. One keeps things calm │ │ and cheap. │ │ │ │ [ − ] 1 [ + ] of up to 8 │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ Strongest model a helper may use │ │ A helper that asks for more is stepped down to this. │ │ │ │ [ cap-haiku ] │ │ │ │ [ cap-sonnet ] │ │ │ │ [ cap-opus ] │ │ │ │ [ cap-fable ] │ ╰────────────────────────────────────────────────────────╯ ✓ Matches your saved default [ Save as my default ] [ Undo changes ] Nothing has been stopped yet this session. Quick look-up helpers (Explore, Plan) are never limited.
Pane · subagent-guard-detail
That helper is no longer in the list.
README

Claude Helper Agent Safeguard (Mod)

Keep Claude's background helpers from running up your usage, and watch them work.

When Claude hands a side task to a helper agent (a "subagent"), it can start several at once, on a powerful model, without you noticing. This mod puts you in charge: you choose how many helpers may work at the same time and the strongest model they may use. A small pane shows each helper working, live, with a plain-language summary of what it did when it finishes.

Unofficial community mod. Not affiliated with, endorsed by, or sponsored by Anthropic.

A quick look

Ready and waiting. Claude and its resting helpers sit in the pen at the top. Below are your three settings: whether helpers are allowed, how many at once, and the strongest model they may use.

<img src="docs/pane-idle.png" alt="The pane when no helper is running" width="380">

Out working. Each helper's crab leaves the pen and gets its own row, with what it's doing right now, how many steps it has taken, and a running clock.

<img src="docs/pane-working.png" alt="Three helpers working at once" width="380">

Finished. The crabs head home. Each row keeps its time and token count, and a click opens a plain-language account of what it did.

<img src="docs/pane-finished.png" alt="Three finished helpers with their time and tokens" width="380">

What it did. A short summary from the helper itself, then a timed, step-by-step list.

<img src="docs/pane-details.png" alt="The details view showing a helper's summary and its steps" width="480">

What it does

  • Limits how many helpers run at once. Default is 1; you can allow up to 8.
  • Caps the model helpers may use. Default is Haiku. A helper that asks for something stronger is stepped down to your cap.
  • Can turn helpers off completely. Claude then does everything itself, in one place.
  • Shows live activity. A crab for Claude, plus one hatted crab per helper. Each helper's row shows its task, model, what it is doing right now, time and tokens.
  • Explains what happened. Click "N steps taken ›" (or the task name) for a plain-language summary and a step-by-step list.
  • Remembers your choices. "Save as my default" keeps your settings for every new session on that device.
  • Stays out of the way. Quick look-up helpers (Explore, Plan) are never limited.

Install

You need the Claude Code command-line tool on the computer. If typing claude --version in a terminal (PowerShell on Windows) prints a version number, you have it. Otherwise, follow the install steps at <https://docs.claude.com/en/docs/claude-code/setup>.

Then run these two commands in a terminal, one after the other:

claude plugin marketplace add SteveAnthonyFM/subagent-guard
claude plugin install subagent-guard@subagent-guard

Fully quit and reopen the Claude desktop app. The pane opens beside your first message of each new session. On a wide window it may appear right away.

Works on macOS and Windows (installed and tested on both). Requires a recent version of Claude Code and the Claude desktop app, since it draws a side pane.

Use

Just work as you normally do. Open the pane any time with:

/subagents

You can also change things by typing:

CommandWhat it does
/subagents statusShow the current settings and how many helpers were stopped
/subagents on / offAllow or forbid helpers
/subagents max 2Allow up to 2 helpers at once (1 to 8)
/subagents model sonnetStrongest model a helper may use: haiku, sonnet, opus or fable
/subagents saveKeep the current settings as your default
/subagents resetGo back to your saved default

How it decides

  1. A helper is about to start. If helpers are turned off, or you're already at your limit, it is refused, and Claude is told to do the work itself.
  2. Otherwise, if the helper asked for (or would inherit) a model stronger than your cap, it is stepped down to the cap.
  3. Otherwise it starts as normal. Either way, it appears in the pane.

Good to know

  • It limits helpers that run at the same time, not the total. You can still have many, one after another.
  • A helper's own fixed model may not be visible. If a custom agent pins a stronger model in its own definition, the cap can't always see it.
  • Token counts appear when a helper finishes. The live line and step list update as it works.
  • The pane can't open before your first message in a narrow window. That's a Claude rule for panes opened without being asked.
  • Saved defaults are per device.

Privacy

No network access, no analytics, nothing leaves your computer. The mod keeps one small settings file (your saved default) in Claude's own plugin storage, and holds the activity list in memory for the current session only.

Update and remove

claude plugin marketplace update subagent-guard
claude plugin update subagent-guard@subagent-guard
claude plugin uninstall subagent-guard@subagent-guard

Troubleshooting

  • claude is not recognized. Install Claude Code (see Install), then open a new terminal window.
  • No pane appears. Send your first message, or type /subagents. Then run claude plugin list and check that subagent-guard shows "enabled".
  • Clicks need a second try while a helper runs. The pane redraws once a second while a helper works; if a click doesn't land, click again.

For developers

The mod is a single hooks module (hooks/register.tsx) with a state contract in types/index.d.ts. Check and test it with:

claude plugin validate .
claude plugin test .

See CHANGELOG.md for version history.

License

MIT. The pixel crab artwork is drawn in code for this mod.

Source 2 files
hooks/register.tsx 821 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { GuardSettings, ModelCap, Run, Step } from '../types'
4
5const PANE = 'subagent-guard'
6const DETAIL = 'subagent-guard-detail'
7const TIERS: readonly ModelCap[] = ['haiku', 'sonnet', 'opus', 'fable']
8const MAX_LIMIT = 8
9// Built-in read-only helpers are left alone, as asked.
10const EXEMPT: readonly string[] = ['Explore', 'Plan', 'statusline-setup', 'claude-code-guide']
11const ACTIVE = ['pending', 'running', 'waiting']
12const FACTORY: GuardSettings = { isEnabled: true, maxConcurrent: 1, modelCap: 'haiku' }
13
14const SETTINGS = { plugin: 'subagent-guard', key: 'settings' } as const
15const SAVED = { plugin: 'subagent-guard', key: 'saved' } as const
16const BLOCKED = { plugin: 'subagent-guard', key: 'blocked' } as const
17const RUNS = { plugin: 'subagent-guard', key: 'runs' } as const
18const SELECTED = { plugin: 'subagent-guard', key: 'selected' } as const
19
20// `$.state` is the session's home for these values (survives a hot reload and
21// redraws the pane). The module copy is a fallback for an engine without it.
22const mem: {
23  settings: GuardSettings
24  saved: GuardSettings
25  blocked: number
26  runs: Run[]
27  selected: string | null
28} = {
29  settings: FACTORY,
30  saved: FACTORY,
31  blocked: 0,
32  runs: [],
33  selected: null,
34}
35
36// Run updates are applied one after another so parallel helpers never lose a step.
37let queue: Promise<unknown> = Promise.resolve()
38const enqueue = (job: () => Promise<unknown>) => {
39  queue = queue.then(job).catch(() => undefined)
40
41  return queue
42}
43
44const redraw = ($: EngineInterface) => {
45  try {
46    $.ui.invalidate('ui.render')
47  } catch {
48    // nothing is drawing
49  }
50}
51
52const getSettings = async ($: EngineInterface): Promise<GuardSettings> => {
53  try {
54    const held = await $.state.get(SETTINGS)
55    if (held.value !== undefined) {
56      return held.value
57    }
58  } catch {
59    // no $.state here: the module copy answers
60  }
61
62  return mem.settings
63}
64const putSettings = async ($: EngineInterface, v: GuardSettings) => {
65  mem.settings = v
66  try {
67    await $.state.set(SETTINGS, v)
68  } catch {
69    redraw($)
70  }
71}
72const getSaved = async ($: EngineInterface): Promise<GuardSettings> => {
73  try {
74    const held = await $.state.get(SAVED)
75    if (held.value !== undefined) {
76      return held.value
77    }
78  } catch {
79    // module copy
80  }
81
82  return mem.saved
83}
84const putSaved = async ($: EngineInterface, v: GuardSettings) => {
85  mem.saved = v
86  try {
87    await $.state.set(SAVED, v)
88  } catch {
89    redraw($)
90  }
91}
92const getBlocked = async ($: EngineInterface): Promise<number> => {
93  try {
94    const held = await $.state.get(BLOCKED)
95    if (held.value !== undefined) {
96      return held.value
97    }
98  } catch {
99    // module copy
100  }
101
102  return mem.blocked
103}
104const countBlock = async ($: EngineInterface) => {
105  const n = (await getBlocked($)) + 1
106  mem.blocked = n
107  try {
108    await $.state.set(BLOCKED, n)
109  } catch {
110    redraw($)
111  }
112  await showStatus($, await getSettings($))
113}
114
115const HATS = ['#8B5A2B', '#2E9E6B', '#F2F2F2', '#F2B632', '#4A7FD6', '#B06AD1', '#D9534F', '#3FB8B8']
116const BODY = '#D97757'
117const EYE = '#1F1F1F'
118
119// A pixel crab on an 11 x 9 grid; no hat is Claude, a hat is a helper.
120// Bobbing is two still frames swapped by the pane's own tick: plain images, so nothing reloads.
121type Crab = { hat?: string; isBobbing: boolean }
122
123const crabScene = (crabs: readonly Crab[], frame: number, isSolo: boolean) => {
124  const px = 4
125  const w = 11
126  const gap = 2
127  const rect = (x: number, y: number, c: string, ww = 1) =>
128    `<rect x="${x * px}" y="${y * px}" width="${ww * px}" height="${px}" fill="${c}"/>`
129  let out = ''
130  crabs.forEach((crab, i) => {
131    const body = isSolo ? '#8a8a8a' : BODY
132    const lift = crab.isBobbing && !isSolo && (frame + i) % 2 === 0 ? 0 : 1
133    let g = ''
134    if (crab.hat !== undefined) {
135      g += rect(3, lift, crab.hat, 5) + rect(2, lift + 1, crab.hat, 7)
136    }
137    g += rect(2, lift + 2, body, 7) + rect(2, lift + 3, body, 7)
138    g += rect(0, lift + 4, body, 11) + rect(0, lift + 5, body, 11)
139    g += rect(2, lift + 6, body, 7)
140    g += rect(3, lift + 3, EYE) + rect(7, lift + 3, EYE)
141    for (const x of [2, 4, 6, 8]) {
142      g += rect(x, lift + 7, body) + rect(x, lift + 8, body)
143    }
144    out += `<g transform="translate(${i * (w + gap) * px} 0)">${g}</g>`
145  })
146  const width = (crabs.length * w + (crabs.length - 1) * gap) * px
147  const height = 10 * px
148
149  return {
150    width,
151    height,
152    source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" shape-rendering="crispEdges">${out}</svg>`,
153  }
154}
155
156const getRuns = async ($: EngineInterface): Promise<Run[]> => {
157  try {
158    const held = await $.state.get(RUNS)
159    if (held.value !== undefined) {
160      return held.value
161    }
162  } catch {
163    // module copy
164  }
165
166  return mem.runs
167}
168const putRuns = async ($: EngineInterface, v: Run[]) => {
169  mem.runs = v
170  try {
171    await $.state.set(RUNS, v)
172  } catch {
173    redraw($)
174  }
175}
176const patchRun = async ($: EngineInterface, id: string, fn: (r: Run) => Run) => {
177  const runs = await getRuns($)
178  if (runs.some(r => r.id === id)) {
179    await putRuns($, runs.map(r => (r.id === id ? fn(r) : r)))
180  }
181}
182
183const getSelected = async ($: EngineInterface): Promise<string | null> => {
184  try {
185    const held = await $.state.get(SELECTED)
186    if (held.value !== undefined) {
187      return held.value
188    }
189  } catch {
190    // module copy
191  }
192
193  return mem.selected
194}
195const showDetail = async ($: EngineInterface, id: string) => {
196  mem.selected = id
197  try {
198    await $.state.set(SELECTED, id)
199  } catch {
200    redraw($)
201  }
202  await $.ui.open({ id: DETAIL, title: 'What the helper did', focus: true })
203}
204
205const shorten = (text: string, n: number) => {
206  const one = text.replace(/\s+/g, ' ').trim()
207
208  return one.length > n ? `${one.slice(0, n - 1)}\u2026` : one
209}
210const baseName = (path: unknown) => String(path ?? '').split('/').filter(Boolean).pop() ?? 'a file'
211
212// One step in plain words, from what the helper's tool call said.
213const stepWords = (tool: string, input: Record<string, unknown>): string => {
214  switch (tool) {
215    case 'Read':
216      return `Read ${baseName(input.file_path)}`
217    case 'Edit':
218    case 'MultiEdit':
219      return `Edited ${baseName(input.file_path)}`
220    case 'Write':
221      return `Wrote ${baseName(input.file_path)}`
222    case 'Bash':
223      return `Ran a command: ${shorten(String(input.command ?? ''), 90)}`
224    case 'Grep':
225      return `Searched files for \u201c${shorten(String(input.pattern ?? ''), 60)}\u201d`
226    case 'Glob':
227      return `Looked for files matching ${shorten(String(input.pattern ?? ''), 60)}`
228    case 'WebSearch':
229      return `Searched the web for \u201c${shorten(String(input.query ?? ''), 60)}\u201d`
230    case 'WebFetch':
231      return `Opened a web page: ${shorten(String(input.url ?? ''), 70)}`
232    default:
233      return tool.startsWith('mcp__') ? 'Used a connected tool' : `Used ${tool}`
234  }
235}
236
237const TOOL_WORDS: Record<string, string> = {
238  Read: 'Reading files',
239  Edit: 'Editing a file',
240  Write: 'Writing a file',
241  NotebookEdit: 'Editing a notebook',
242  Bash: 'Running a command',
243  Grep: 'Searching files',
244  Glob: 'Looking for files',
245  WebSearch: 'Searching the web',
246  WebFetch: 'Reading a web page',
247}
248const toolWords = (tool: string) =>
249  TOOL_WORDS[tool] ?? (tool.startsWith('mcp__') ? 'Using a connected tool' : `Using ${tool}`)
250
251const clockText = (ms: number) => {
252  const total = Math.max(0, Math.round(ms / 1000))
253
254  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
255}
256const kTokens = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
257const kindWords = (t: string) => (t === 'general-purpose' ? 'General helper' : t)
258
259// The pane's heartbeat: it swaps a working crab's two frames and ticks the clocks. Each
260// beat redraws the pane, so it runs only while a helper is working and stops when idle.
261const BEAT_MS = 1000
262let isBeating = false
263
264const watch = ($: EngineInterface) => {
265  if (isBeating) {
266    return
267  }
268  isBeating = true
269  const beat = () => {
270    redraw($)
271    if (mem.runs.some(r => r.status === 'running')) {
272      $.clock.after(BEAT_MS, beat)
273    } else {
274      isBeating = false
275    }
276  }
277  $.clock.after(BEAT_MS, beat)
278}
279
280const clampMax = (n: number) => Math.min(MAX_LIMIT, Math.max(1, Math.round(n)))
281
282const normalize = (raw: unknown): GuardSettings => {
283  const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
284
285  return {
286    isEnabled: typeof r.isEnabled === 'boolean' ? r.isEnabled : FACTORY.isEnabled,
287    maxConcurrent:
288      typeof r.maxConcurrent === 'number' && Number.isFinite(r.maxConcurrent)
289        ? clampMax(r.maxConcurrent)
290        : FACTORY.maxConcurrent,
291    modelCap: TIERS.includes(r.modelCap as ModelCap) ? (r.modelCap as ModelCap) : FACTORY.modelCap,
292  }
293}
294
295const tierOf = (model: string | undefined): number => {
296  const m = (model ?? '').toLowerCase()
297
298  return TIERS.findIndex(t => m.includes(t))
299}
300
301const isSame = (a: GuardSettings, b: GuardSettings) =>
302  a.isEnabled === b.isEnabled && a.maxConcurrent === b.maxConcurrent && a.modelCap === b.modelCap
303
304const NAMES: Record<ModelCap, string> = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus', fable: 'Fable' }
305const BLURBS: Record<ModelCap, string> = {
306  haiku: 'fastest and lightest on usage',
307  sonnet: 'balanced speed and ability',
308  opus: 'most capable, uses more',
309  fable: 'top tier, uses the most',
310}
311
312const modelName = (m: string) => {
313  const i = tierOf(m)
314
315  return i === -1 ? m : NAMES[TIERS[i] as ModelCap]
316}
317
318const plural = (n: number) => (n === 1 ? '1 helper' : `${n} helpers`)
319
320const describe = (s: GuardSettings) =>
321  s.isEnabled
322    ? `${plural(s.maxConcurrent)} at a time, up to ${NAMES[s.modelCap]}`
323    : 'helper agents are off'
324
325const showStatus = async ($: EngineInterface, s: GuardSettings) => {
326  const n = await getBlocked($)
327  const stopped = n > 0 ? ` \u00b7 ${n} stopped` : ''
328  $.ui.status(
329    s.isEnabled
330      ? `Helper agents: ${s.maxConcurrent} at a time \u00b7 up to ${NAMES[s.modelCap]}${stopped}`
331      : `Helper agents: off${stopped}`,
332  )
333}
334
335const change = async ($: EngineInterface, patch: Partial<GuardSettings>) => {
336  const next = normalize({ ...(await getSettings($)), ...patch })
337  await putSettings($, next)
338  await showStatus($, next)
339}
340
341const saveDefault = async ($: EngineInterface) => {
342  const s = await getSettings($)
343  await $.store.set('saved', s)
344  await putSaved($, s)
345}
346
347export const register: Register = on => {
348  // Spawns admitted by the guard that `agent.list()` may not show yet.
349  let inflight = 0
350
351  on('session.start', async ($, e, next) => {
352    const remembered = normalize(await $.store.get('saved'))
353    await putSaved($, remembered)
354
355    // A hot reload fires session.start again: keep this session's override.
356    let isFresh = true
357    try {
358      isFresh = (await $.state.get(SETTINGS)).value === undefined
359    } catch {
360      isFresh = true
361    }
362    if (isFresh) {
363      await putSettings($, remembered)
364    }
365    await showStatus($, await getSettings($))
366
367    await $.command.register({
368      name: 'subagents',
369      description: 'Subagent safeguard: pane, or "max N", "model <tier>", "on|off", "save", "reset", "status"',
370    })
371    void $.ui.open({ id: PANE, title: 'Helper Agent Safeguard' })
372
373    return next(e)
374  })
375
376  // A pane opened unasked can wait undrawn (narrow window, or a surface still attaching).
377  // A pane opened behind the person's own prompt is placed at any width, so the first
378  // prompt of the session seats it, once; closing it afterwards is respected.
379  let isSeated = false
380
381  on('prompt.submit', async ($, e, next) => {
382    if (!isSeated) {
383      isSeated = true
384      void $.ui.open({ id: PANE, title: 'Helper Agent Safeguard' })
385    }
386
387    return next(e)
388  })
389
390  on('agent.spawn', async ($, e, next) => {
391    let out = e
392    if (!EXEMPT.includes(e.subagentType)) {
393      const s = await getSettings($)
394      if (!s.isEnabled) {
395        await countBlock($)
396
397        return {
398          deny: 'subagent-guard: subagents are disabled for this session. Do this work directly in the main thread.',
399        }
400      }
401
402      const agents = await $.agent.list()
403      // No await between this count and the increment, so parallel spawns see each other.
404      const active = agents.filter(a => !EXEMPT.includes(a.type) && ACTIVE.includes(a.status)).length
405      if (active + inflight >= s.maxConcurrent) {
406        await countBlock($)
407
408        return {
409          deny: `subagent-guard: subagent limit reached (${active + inflight} running, max ${s.maxConcurrent}). Do this work directly in the main thread, or wait for the running subagent to finish.`,
410        }
411      }
412
413      // Pull a heavier requested (or inherited) model down to the cap.
414      const cap = TIERS.indexOf(s.modelCap)
415      const requested = e.model === undefined || e.model === 'inherit' ? e.parentModel : e.model
416      const tier = tierOf(requested)
417      const isOver = !e.fork && (tier === -1 || tier > cap)
418      out = isOver ? { ...e, model: s.modelCap } : e
419      inflight += 1
420    }
421
422    const isGuarded = !EXEMPT.includes(e.subagentType)
423    try {
424      const started = await next(out)
425      if (started.deny === undefined && started.agentId !== undefined) {
426        const id = started.agentId
427        const run: Run = {
428          id,
429          label: (out.description || out.prompt).slice(0, 60),
430          type: out.subagentType,
431          model: started.model,
432          status: 'running',
433          startedAt: await $.clock.now(),
434          tools: 0,
435          hat: HATS[0] ?? BODY,
436          steps: [],
437        }
438        await enqueue(async () => {
439          const runs = await getRuns($)
440          const inUse = runs.filter(r => r.status === 'running').map(r => r.hat)
441          run.hat = HATS.find(h => !inUse.includes(h)) ?? HATS[runs.length % HATS.length] ?? BODY
442          await putRuns($, [...runs.filter(r => r.id !== id), run].slice(-24))
443        })
444        watch($)
445      }
446
447      return started
448    } finally {
449      if (isGuarded) {
450        inflight -= 1
451      }
452    }
453  })
454
455  on('tool.call', async ($, e, next) => {
456    const id = e.agentId
457    if (id !== undefined) {
458      const tool = e.tool
459      const text = stepWords(tool, e as unknown as Record<string, unknown>)
460      void enqueue(async () => {
461        const at = await $.clock.now()
462        await patchRun($, id, r => ({
463          ...r,
464          tools: r.tools + 1,
465          lastTool: toolWords(tool),
466          steps: [...r.steps, { text, at: at - r.startedAt } as Step].slice(-60),
467        }))
468      })
469    }
470
471    return next(e)
472  })
473
474  on('turn.complete', async ($, e, next) => {
475    const id = e.agentId
476    if (id !== undefined) {
477      const now = await $.clock.now()
478      const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
479      const tokens = e.usage ? e.usage.input_tokens + e.usage.output_tokens : undefined
480      void enqueue(() =>
481        patchRun($, id, r => ({
482          ...r,
483          status,
484          endedAt: now,
485          summary: e.answer.trim() === '' ? r.summary : shorten(e.answer, 1400),
486          tokens: tokens === undefined ? r.tokens : (r.tokens ?? 0) + tokens,
487        })),
488      )
489    }
490
491    return next(e)
492  })
493
494  on('command.run', { command: 'subagents' }, async ($, e) => {
495    const args = e.args.trim().toLowerCase()
496    const [word, value] = args.split(/\s+/)
497
498    if (args === '') {
499      // The pane may still be seating itself when open() answers, so never report failure here.
500      await $.ui.open({ id: PANE, title: 'Helper Agent Safeguard' })
501      const s = await getSettings($)
502
503      return {
504        text: `Opening the Helper Agent Safeguard pane. Now: ${describe(s)}. (Typed shortcuts: /subagents max N, model <tier>, on, off, save, reset.)`,
505      }
506    }
507    if (word === 'status') {
508      const s = await getSettings($)
509      const d = await getSaved($)
510      const n = await getBlocked($)
511
512      return {
513        text: `Now: ${describe(s)}. Saved default: ${describe(d)}. Blocked this session: ${n}.`,
514      }
515    }
516    if (word === 'on' || word === 'off') {
517      await change($, { isEnabled: word === 'on' })
518    } else if (word === 'max' && value !== undefined && Number.isFinite(Number(value))) {
519      await change($, { maxConcurrent: Number(value) })
520    } else if (word === 'model' && TIERS.includes(value as ModelCap)) {
521      await change($, { modelCap: value as ModelCap })
522    } else if (word === 'save') {
523      await saveDefault($)
524      return { text: `Saved as your default: ${describe(await getSettings($))}.` }
525    } else if (word === 'reset') {
526      await change($, await getSaved($))
527    } else {
528      return { text: 'Usage: /subagents [status | on | off | max <1-8> | model <haiku|sonnet|opus|fable> | save | reset]' }
529    }
530
531    return { text: `Now: ${describe(await getSettings($))} (this session only; /subagents save to keep).` }
532  })
533
534  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
535    const { Box, Text, Button, ...rest } = $.ui.resolve(e)
536    const Svg = 'Svg' in rest ? rest.Svg : undefined
537    const s = await getSettings($)
538    const d = await getSaved($)
539    const n = await getBlocked($)
540    const isSaved = isSame(s, d)
541    const isSolo = !s.isEnabled
542    const runs = await getRuns($)
543    const now = await $.clock.now()
544    const running = runs.filter(r => r.status === 'running')
545    const frame = Math.floor(now / BEAT_MS) % 2
546    const finished = runs.filter(r => r.status !== 'running')
547
548    // The pen holds Claude and the helpers who are not out working.
549    const inUse = running.map(r => r.hat)
550    const penCount = isSolo ? 0 : Math.max(0, s.maxConcurrent - running.length)
551    const pen: Crab[] = HATS.filter(h => !inUse.includes(h))
552      .slice(0, penCount)
553      .map(hat => ({ hat, isBobbing: false }))
554    const scene = crabScene([{ isBobbing: false }, ...pen], frame, isSolo)
555    const caption =
556      running.length > 0
557        ? `${plural(running.length)} out working${pen.length > 0 ? `, ${pen.length} resting here.` : '.'}`
558        : isSolo
559          ? 'Claude is working solo.'
560          : s.maxConcurrent === 1
561            ? 'Claude and 1 helper, ready when needed.'
562            : `Claude and ${s.maxConcurrent} helpers, ready when needed.`
563
564    const glyph = (r: Run) =>
565      r.status === 'running'
566        ? { mark: '● Working', color: 'claude' }
567        : r.status === 'done'
568          ? { mark: '✓ Done', color: 'success' }
569          : r.status === 'stopped'
570            ? { mark: '■ Stopped', color: 'warning' }
571            : { mark: '✕ Problem', color: 'error' }
572    const row = (r: Run) => {
573      const g = glyph(r)
574      const spent = clockText((r.endedAt ?? now) - r.startedAt)
575      const detail = r.status === 'running' ? (r.lastTool ?? 'Getting started…') : 'Finished'
576      const stepsLabel = r.tools === 1 ? '1 step taken' : `${r.tools} steps taken`
577      const crab = crabScene([{ hat: r.hat, isBobbing: true }], frame, false)
578
579      return (
580        <Box key={`run-${r.id}`} flexDirection="row" gap={2} marginBottom={1}>
581          {r.status === 'running' && Svg ? (
582            <Svg source={crab.source} width={crab.width} height={crab.height} alt="A helper crab at work" />
583          ) : null}
584          <Box flexDirection="column" flexGrow={1}>
585            <Box justifyContent="space-between">
586              <Button
587                key={`title-${r.id}`}
588                plain
589                label={r.label}
590                onPress={() => showDetail($, r.id)}
591              />
592              <Text color={g.color}>{g.mark}</Text>
593            </Box>
594            <Text dimColor>
595              {kindWords(r.type)} ({modelName(r.model)})
596            </Text>
597            <Text dimColor>{detail}</Text>
598            <Box justifyContent="space-between">
599              <Button
600                key={`steps-${r.id}`}
601                plain
602                dimColor
603                label={`${stepsLabel} ›`}
604                onPress={() => showDetail($, r.id)}
605              />
606              <Text dimColor>
607                {r.tokens === undefined ? '' : `${kTokens(r.tokens)} tokens · `}
608                {spent}
609              </Text>
610            </Box>
611            <Text color={g.color} dimColor={r.status !== 'running'}>
612              {'━'.repeat(r.status === 'running' ? 12 : 30)}
613            </Text>
614          </Box>
615        </Box>
616      )
617    }
618
619    return (
620      <Box flexDirection="column" paddingX={1} gap={1}>
621        <Box flexDirection="column">
622          <Text bold color="claude">
623            Helper Agent Safeguard
624          </Text>
625          <Text dimColor>Keeps background helpers from running up your usage.</Text>
626        </Box>
627
628        <Box borderStyle="round" borderColor="subtle" paddingX={1} paddingY={1} flexDirection="column" alignItems="center">
629          {Svg ? (
630            <Svg source={scene.source} width={scene.width} height={scene.height} alt={`Claude with ${pen.length} resting helper crabs`} />
631          ) : (
632            <Text>{'\u{1F980}'.repeat(pen.length + 1)}</Text>
633          )}
634          <Text dimColor>{caption}</Text>
635        </Box>
636
637        {runs.length > 0 && (
638          <Box borderStyle="round" borderColor={running.length > 0 ? 'claude' : 'subtle'} paddingX={1} flexDirection="column">
639            {running.length > 0 && (
640              <Box flexDirection="column">
641                <Text bold>Running {'·'} {String(running.length)}</Text>
642                <Text> </Text>
643                {running.map(row)}
644              </Box>
645            )}
646            {finished.length > 0 && (
647              <Box flexDirection="column">
648                <Text bold dimColor>
649                  Finished {'·'} {String(finished.length)}
650                </Text>
651                <Text> </Text>
652                {finished.map(row)}
653                <Box>
654                  <Button
655                    key="clear"
656                    label="Clear finished"
657                    onPress={() =>
658                      enqueue(async () => {
659                        const all = await getRuns($)
660                        await putRuns($, all.filter(r => r.status === 'running'))
661                      })
662                    }
663                  />
664                </Box>
665              </Box>
666            )}
667          </Box>
668        )}
669
670        <Box borderStyle="round" borderColor={s.isEnabled ? 'success' : 'warning'} paddingX={1} flexDirection="column">
671          <Box justifyContent="space-between">
672            <Text bold>Helper agents</Text>
673            <Text bold color={s.isEnabled ? 'success' : 'warning'}>
674              {s.isEnabled ? '● Allowed' : '○ Turned off'}
675            </Text>
676          </Box>
677          <Text dimColor>
678            {s.isEnabled
679              ? 'Claude may hand side tasks to helpers.'
680              : 'Claude will do everything itself, in one place.'}
681          </Text>
682          <Box marginTop={1}>
683            <Button
684              key="toggle"
685              variant={s.isEnabled ? 'secondary' : 'primary'}
686              label={s.isEnabled ? 'Turn off' : 'Turn on'}
687              onPress={async () => change($, { isEnabled: !(await getSettings($)).isEnabled })}
688            />
689          </Box>
690        </Box>
691
692        <Box borderStyle="round" borderColor="subtle" paddingX={1} flexDirection="column">
693          <Text bold>How many at a time</Text>
694          <Text dimColor>Helpers working side by side. One keeps things calm and cheap.</Text>
695          <Box marginTop={1} gap={2} alignItems="center">
696            <Button
697              key="max-dec"
698              label=" − "
699              onPress={async () => change($, { maxConcurrent: (await getSettings($)).maxConcurrent - 1 })}
700            />
701            <Text bold color="claude">
702              {String(s.maxConcurrent)}
703            </Text>
704            <Button
705              key="max-inc"
706              label=" + "
707              onPress={async () => change($, { maxConcurrent: (await getSettings($)).maxConcurrent + 1 })}
708            />
709            <Text dimColor>of up to {String(MAX_LIMIT)}</Text>
710          </Box>
711        </Box>
712
713        <Box borderStyle="round" borderColor="subtle" paddingX={1} flexDirection="column">
714          <Text bold>Strongest model a helper may use</Text>
715          <Text dimColor>A helper that asks for more is stepped down to this.</Text>
716          <Box flexDirection="column" marginTop={1} gap={1}>
717            {TIERS.map(tier => {
718              const isPicked = tier === s.modelCap
719
720              return (
721                <Button key={`cap-${tier}`} onPress={() => change($, { modelCap: tier })}>
722                  <Text bold={isPicked} color={isPicked ? 'claude' : undefined}>
723                    {isPicked ? '● ' : '○ '}
724                    {NAMES[tier]}
725                  </Text>
726                  <Text dimColor> ({BLURBS[tier]})</Text>
727                </Button>
728              )
729            })}
730          </Box>
731        </Box>
732
733        <Box flexDirection="column">
734          <Text color={isSaved ? 'success' : 'warning'}>
735            {isSaved ? '✓ Matches your saved default' : '• Changed for this session only'}
736          </Text>
737          <Box marginTop={1} gap={1}>
738            <Button
739              key="save"
740              variant={isSaved ? 'secondary' : 'primary'}
741              label="Save as my default"
742              onPress={() => saveDefault($)}
743            />
744            <Button
745              key="reset"
746              label="Undo changes"
747              dimColor={isSaved}
748              onPress={async () => change($, await getSaved($))}
749            />
750          </Box>
751        </Box>
752
753        <Box flexDirection="column">
754          <Text dimColor>
755            {n === 0
756              ? 'Nothing has been stopped yet this session.'
757              : `Stopped ${plural(n)} from starting this session.`}
758          </Text>
759          <Text dimColor>Quick look-up helpers (Explore, Plan) are never limited.</Text>
760        </Box>
761      </Box>
762    )
763  })
764
765  on('ui.render', { component: 'Pane', requestId: DETAIL }, async ($, e) => {
766    const { Box, Text } = $.ui.resolve(e)
767    const id = await getSelected($)
768    const runs = await getRuns($)
769    const now = await $.clock.now()
770    const r = runs.find(x => x.id === id)
771
772    if (r === undefined) {
773      return (
774        <Box paddingX={1}>
775          <Text dimColor>That helper is no longer in the list.</Text>
776        </Box>
777      )
778    }
779
780    const isRunning = r.status === 'running'
781    const spent = clockText((r.endedAt ?? now) - r.startedAt)
782    const shown = r.steps.slice(-40)
783
784    return (
785      <Box flexDirection="column" paddingX={1} gap={1}>
786        <Box flexDirection="column">
787          <Text bold color="claude">
788            {r.label}
789          </Text>
790          <Text dimColor>
791            {kindWords(r.type)} ({modelName(r.model)}) {'·'} {spent}
792            {r.tokens === undefined ? '' : ` · ${kTokens(r.tokens)} tokens`}
793          </Text>
794          <Text color={isRunning ? 'claude' : r.status === 'done' ? 'success' : r.status === 'stopped' ? 'warning' : 'error'}>
795            {isRunning ? '● Still working' : r.status === 'done' ? '✓ Finished' : r.status === 'stopped' ? '■ Stopped early' : '✕ Ran into a problem'}
796          </Text>
797        </Box>
798
799        <Box borderStyle="round" borderColor="subtle" paddingX={1} flexDirection="column">
800          <Text bold>In short</Text>
801          <Text>
802            {r.summary ?? (isRunning ? 'The helper is still working. Its summary appears here when it finishes.' : 'The helper did not leave a written summary.')}
803          </Text>
804        </Box>
805
806        <Box borderStyle="round" borderColor="subtle" paddingX={1} flexDirection="column">
807          <Text bold>What it did, step by step</Text>
808          {shown.length === 0 && <Text dimColor>No steps recorded yet.</Text>}
809          {shown.map((step, i) => (
810            <Box key={`step-${i}`} gap={1}>
811              <Text dimColor>{clockText(step.at)}</Text>
812              <Text>{step.text}</Text>
813            </Box>
814          ))}
815          {r.steps.length > shown.length && <Text dimColor>Earlier steps are not shown.</Text>}
816        </Box>
817      </Box>
818    )
819  })
820}
821
types/index.d.ts 49 lines
1export type ModelCap = 'haiku' | 'sonnet' | 'opus' | 'fable'
2
3export type GuardSettings = {
4  isEnabled: boolean
5  maxConcurrent: number
6  modelCap: ModelCap
7}
8
9export type RunStatus = 'running' | 'done' | 'failed' | 'stopped'
10
11/** One thing a helper did, in plain words, and when (ms after it started). */
12export type Step = { text: string; at: number }
13
14/** One helper agent's run, as the pane's activity list shows it. */
15export type Run = {
16  id: string
17  label: string
18  type: string
19  model: string
20  status: RunStatus
21  startedAt: number
22  endedAt?: number
23  tools: number
24  lastTool?: string
25  tokens?: number
26  /** Hat colour, so a helper keeps its look while it works and back in the pen. */
27  hat: string
28  steps: Step[]
29  /** The helper's own closing report, once it has one. */
30  summary?: string
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'subagent-guard': {
36      /** This session's live settings: the saved default plus any override. */
37      settings: GuardSettings
38      /** The remembered default, mirrored from `$.store` for the pane. */
39      saved: GuardSettings
40      /** Spawns refused so far this session. */
41      blocked: number
42      /** Helper agents seen this session, oldest first. */
43      runs: Run[]
44      /** The helper whose details are open in the detail pane. */
45      selected: string | null
46    }
47  }
48}
49