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.

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.
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">
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.
Just work as you normally do. Open the pane any time with:
/subagents
You can also change things by typing:
| Command | What it does |
|---|---|
/subagents status | Show the current settings and how many helpers were stopped |
/subagents on / off | Allow or forbid helpers |
/subagents max 2 | Allow up to 2 helpers at once (1 to 8) |
/subagents model sonnet | Strongest model a helper may use: haiku, sonnet, opus or fable |
/subagents save | Keep the current settings as your default |
/subagents reset | Go back to your saved default |
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.
claude plugin marketplace update subagent-guard
claude plugin update subagent-guard@subagent-guard
claude plugin uninstall subagent-guard@subagent-guard
claude is not recognized. Install Claude Code (see Install), then open a new terminal window./subagents. Then run claude plugin list and check that subagent-guard shows "enabled".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.
MIT. The pixel crab artwork is drawn in code for this mod.
hooks/register.tsx 821 lines1import 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}
821types/index.d.ts 49 lines1export 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