SLOPSHOPPER

subagent-audit

Transparency for native subagents: audit records, git-based file attribution, scope checks, chat receipts and a /subagents pane.

newpaneguardcommandprocesstimer
v0.3.0MITupdated 2026-10-08lphilbrooks/subagent-audit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagent-audit
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ 0 subagent runs · 0 running · 0 flagged │ ┃ Show flagged only ⏺ Read(src/auth.ts) │ ┃ No subagent runs in this session yet. ⎿ 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 │ │ › /subagents │ ⎿ subagent-audit: No subagent runs recorded in this session yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
0 subagent runs · 0 running · 0 flagged Show flagged only No subagent runs in this session yet.
README

subagent-audit

A Claude Code mod that adds transparency to native subagents. Claude Code already saves each subagent's raw transcript; this mod adds what the transcript doesn't tell you at a glance:

  • Which files a subagent really changed, including edits made through Bash, found by comparing git status (with file content hashes) when the subagent starts and when it ends. Runs that overlapped another subagent, or ran in the background, are marked shared instead of claiming files that may not be theirs.
  • Scope checks. Agents that are read-only by design (Explore, Plan, claude-code-guide) are held to it: a successful write, a Bash/MCP call the engine did not mark read-only, or a git change is reported as a violation.
  • A receipt in the chat when each subagent finishes: type, time, tool calls, files changed, violations, denied calls, output tokens. It is a notice row the model never reads, so it costs no usage, and it is saved in the session transcript.
  • /subagents, a pane listing this session's subagents (finished ones too, which the built-in views drop after about 30 seconds) with scope verdict, git changes, flagged calls, a tool timeline, the task and the result.
  • A durable, redacted record of every run in ~/.claude/agent-runs/, independent of the transcript store's cleanup.

No model calls and no network. Git runs locally.

What you see

When a subagent finishes, a notice like this appears in the chat:

subagent-audit: Explore "find the parser" completed · 1m 58s · 5 tool calls · 2 files changed (git) · out 14k
  changed: src/parser.ts, notes.md
  scope: read-only agent, 2 violations: Write succeeded; git: src/parser.ts changed

Type /subagents to open the pane. It lists this session's subagents, newest first, with a status mark, duration, tool and file counts and a warning count. Select a row for its detail: scope verdict, files changed in git (and whether the attribution is exclusive or shared), files edited with the edit tools, flagged calls, the tool timeline, the task and the result, plus where its audit record and Claude Code's own transcript live. "Show flagged only" filters the list to runs with warnings. In a session that draws no pane (claude -p, the VS Code chat panel) the command answers with the same list as text.

What gets recorded

One folder per run in ~/.claude/agent-runs/ (or $CLAUDE_CONFIG_DIR/agent-runs/ if that is set):

FileContents
run.jsonSpawn parameters (type, description, requested and resolved model, permission mode, background/fork, parent, working directory), every tool call (time, duration, outcome, truncated input and output preview), files changed (edit tools and git), Bash/MCP calls the engine did not mark read-only, the scope verdict, each turn with token usage and its answer, and the final status
prompt.mdThe task the subagent was given
result.mdIts latest final report

Statuses: running, completed, aborted, refused, error, denied (spawn refused), unfinished (still running when the session ended).

Folder names look like 2026-10-08_143200_explore_find-the-parser_<agent-id-tail>, so they sort by time.

Settings

OptionDefaultEffect
receiptsonWrite the finish receipt into the chat
gitAttributiononCompare git state at start and end. Turning it off removes the git file list and the git-based scope checks

Requirements

Claude Code 2.1.287 or later, in the terminal or the desktop app's Code tab.

Install

/plugin marketplace add lphilbrooks/subagent-audit
/plugin install subagent-audit@subagent-audit

To try it from a local clone: claude --plugin-dir <path to this folder>.

Privacy

  • Prompts, tool inputs (cut to 600 characters), tool output previews (300 characters) and answers are written to disk. Treat the folder like a transcript.
  • Common secrets are replaced with [redacted…] before anything is stored: API keys and tokens with well-known prefixes, JWTs, private keys, Authorization/Bearer values, credentials inside URLs, and values after password, secret, token, api_key and similar names, including inside JSON-escaped strings. This is pattern matching and will miss some secrets.
  • The git comparison reads file contents only to hash them; nothing from the files is stored except their paths. Untracked files count, ignored files do not.
  • Before a subagent starts, the mod waits up to 1.5 seconds for the first git snapshot (usually well under 0.1 s); a slower repository simply goes without a delta for that run.
  • Files are created with your default permissions. There is no automatic retention: delete old run folders yourself.
  • Keep ~/.claude/agent-runs out of cloud sync and Git.

Limits

  • The git delta sees changes inside the repository the subagent works in, not files elsewhere on disk, network calls or processes it started. Subagents running at the same time cannot be told apart, so those runs are marked shared. filesChanged covers Edit, Write and NotebookEdit calls; possibleMutations lists Bash and MCP calls the engine did not mark read-only.
  • Appending a receipt into the subagent's own transcript is not possible once it has finished, so receipts go into the main chat.
  • The pane after a reload or resume shows only this session's runs recorded by 0.3.0 or later (older records carry no session id).
  • At most 1000 tool entries are kept per run (toolsDropped counts the rest). Runs with more than 200 entries are rewritten at most every 10 seconds.
  • Remote workflow agents never report a finished turn, so they end as unfinished.
  • After a hot reload the mod finds a running subagent's folder again from its name; a truncated run.json cannot be recovered and the run continues in a new folder.
  • run.json is rewritten whole on each flush; writes are not atomic.

Develop

npm run validate    # claude plugin validate .
npm test            # claude plugin test .
npm run typecheck   # tsc against the typings the engine writes into .claude-plugin/types/

The typings appear after the mod has loaded once from a folder you own (for example via --plugin-dir).

Licence

MIT

Source 8 files
hooks/register.tsx 637 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { buildFiles, diffSnapshots, parseStatus } from './gitdelta'
5import type { Snapshot } from './gitdelta'
6import { receiptText } from './receipt'
7import { clip, redact, scrub, text } from './redact'
8import { evaluateScope } from './scope'
9import type { Api, Run, ToolEntry } from './types'
10import { buildPane, listText } from './view'
11
12// One folder per subagent run under ~/.claude/agent-runs/ (or $CLAUDE_CONFIG_DIR/agent-runs):
13//   run.json  full machine-readable record
14//   prompt.md the task the subagent was given
15//   result.md the subagent's latest final report
16// On top of that record the mod:
17//   - compares git state at the subagent's start and end, to attribute file changes
18//     (including edits made through Bash) and to flag violations by read-only agents;
19//   - writes a one-notice receipt into the chat when a subagent finishes (the model never
20//     reads it, so it costs no usage);
21//   - offers /subagents, a pane listing this session's subagents with their full audit detail.
22// Local only: no model calls, no network (git runs on this machine). Secrets are
23// pattern-redacted before anything is stored; that is best effort, not a guarantee.
24//
25// The hooks only observe: each returns exactly what `next()` returned, and recording
26// happens after that, off the engine's path, inside try/catch. The one exception is the
27// git snapshot taken before a subagent starts, which waits at most SNAP_BUDGET_MS.
28
29const PANE = 'subagent-audit'
30const FLUSH_MS = 2000
31const END_WAIT_MS = 2000
32const SNAP_BUDGET_MS = 1500
33const SNAP_TIMEOUT_MS = 4000
34const MAX_SNAP_FILES = 300
35const MAX_GIT_FILES = 200
36const MAX_DISK_SCAN = 80
37const MAX_FIELD = 2000
38const MAX_INPUT = 600
39const MAX_PREVIEW = 300
40const MAX_TOOLS = 1000
41const MAX_FILES = 500
42const MAX_LIVE_RUNS = 200
43// Past this many tool entries a run is rewritten at most every SLOW_FLUSH_MS, so a
44// very long run does not rewrite an ever larger run.json every few seconds.
45const SLOW_AFTER_TOOLS = 200
46const SLOW_FLUSH_MS = 10000
47const CHANGERS = new Set(['Edit', 'Write', 'NotebookEdit'])
48
49type ToolOutcome = { deny?: string; isError?: boolean; text?: string; isReadOnly?: boolean }
50type Started = { deny?: string; model?: string; agentId?: string; teammateId?: string }
51
52const selectedAtom = atom({ plugin: 'subagent-audit', key: 'selected' } as const, '')
53const flaggedAtom = atom({ plugin: 'subagent-audit', key: 'onlyFlagged' } as const, false)
54
55// Module state. A hot reload clears it; runFor rehydrates a run from disk.
56const runs = new Map<string, Run>()
57const dirty = new Set<string>()
58const usedFolders = new Set<string>()
59const promptWritten = new Set<string>()
60const resultWritten = new Map<string, string>()
61const chains = new Map<string, Promise<void>>()
62const lastFlush = new Map<string, number>()
63const snapshots = new Map<string, Snapshot>()
64const pendingSnapshots = new Map<string, Snapshot>()
65let base: string | undefined
66let sessionId: string | undefined
67let timerArmed = false
68let diskLoaded = false
69let counter = 0
70let config = { receipts: true, gitAttribution: true }
71
72const pad = (n: number) => String(n).padStart(2, '0')
73
74const stamp = (d: Date) =>
75  `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
76
77const slug = (s: string) =>
78  s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'task'
79
80const idTail = (agentId: string) => slug(agentId).slice(-12)
81
82const toSlash = (p: string) => p.split(String.fromCharCode(92)).join('/')
83
84function normPath(p: string, cwd: unknown): string {
85  const slashed = toSlash(p)
86  const isAbsolute = /^(?:[A-Za-z]:)?\//.test(slashed)
87  return !isAbsolute && typeof cwd === 'string' && cwd !== '' ? `${toSlash(cwd).replace(/\/$/, '')}/${slashed}` : slashed
88}
89
90async function clockNow($: Api): Promise<number> {
91  try {
92    return await $.clock.now()
93  } catch {
94    return Date.now()
95  }
96}
97
98async function sessionIdOf($: Api): Promise<string | undefined> {
99  if (sessionId === undefined) {
100    try {
101      sessionId = await $.session.id()
102    } catch {
103      return undefined
104    }
105  }
106  return sessionId
107}
108
109async function baseDir($: Api): Promise<string | undefined> {
110  if (base !== undefined) return base
111  const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
112  if (configDir) {
113    base = `${toSlash(configDir).replace(/\/$/, '')}/agent-runs`
114    return base
115  }
116  const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
117  if (!home) return undefined
118  base = `${toSlash(home).replace(/\/$/, '')}/.claude/agent-runs`
119  return base
120}
121
122function redraw($: Api): void {
123  try {
124    $.ui.invalidate('ui.render')
125  } catch {
126    // nothing is drawing
127  }
128}
129
130// ---- git attribution -------------------------------------------------------
131
132async function takeSnapshot($: Api, cwd: string | undefined): Promise<Snapshot | undefined> {
133  try {
134    const init = { cwd, timeoutMs: SNAP_TIMEOUT_MS }
135    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], init)
136    if (top.exitCode !== 0) return undefined
137    const root = top.stdout.trim()
138    const status = await $.process.run(['git', '-c', 'core.quotepath=off', 'status', '--porcelain=v1', '-uall'], {
139      cwd: root,
140      timeoutMs: SNAP_TIMEOUT_MS,
141    })
142    if (status.exitCode !== 0) return undefined
143    const entries = parseStatus(status.stdout)
144    const hashable = entries.filter(e => !e.xy.includes('D')).slice(0, MAX_SNAP_FILES)
145    let hashed: string[] = []
146    if (hashable.length > 0) {
147      const res = await $.process.run(['git', 'hash-object', '--stdin-paths'], {
148        cwd: root,
149        stdin: `${hashable.map(e => e.path).join('\n')}\n`,
150        timeoutMs: SNAP_TIMEOUT_MS,
151      })
152      if (res.exitCode === 0) hashed = res.stdout.split('\n')
153    }
154    return {
155      root,
156      files: buildFiles(entries, hashable, hashed),
157      truncated: entries.length > MAX_SNAP_FILES,
158    }
159  } catch {
160    return undefined
161  }
162}
163
164async function snapshotWithin($: Api, cwd: string | undefined): Promise<Snapshot | undefined> {
165  if (!config.gitAttribution) return undefined
166  try {
167    return await Promise.race([takeSnapshot($, cwd), $.clock.sleep(SNAP_BUDGET_MS).then(() => undefined)])
168  } catch {
169    return undefined
170  }
171}
172
173function overlaps(a: Run, b: Run, now: number): boolean {
174  const aStart = a.startMs ?? 0
175  const bStart = b.startMs ?? 0
176  return aStart < (b.endMs ?? now) && bStart < (a.endMs ?? now)
177}
178
179// ---- persistence -----------------------------------------------------------
180
181// Writes for one run are chained so an older snapshot can never land after a
182// newer one; the snapshot is serialised when its turn comes, so it is the latest.
183// A failed write puts the run back in `dirty` so the timer retries it.
184function flush($: Api, run: Run): Promise<void> {
185  dirty.delete(run.agentId)
186  const prev = chains.get(run.folder) ?? Promise.resolve()
187  const next = prev
188    .then(async () => {
189      lastFlush.set(run.agentId, await clockNow($))
190      const root = await baseDir($)
191      if (root === undefined) return
192      const dir = `${root}/${run.folder}`
193      await $.fs.write(`${dir}/run.json`, JSON.stringify(run, null, 2))
194      if (!promptWritten.has(run.folder)) {
195        await $.fs.write(`${dir}/prompt.md`, text(run.spawn.prompt))
196        promptWritten.add(run.folder)
197      }
198      if (run.answer !== undefined && resultWritten.get(run.folder) !== run.answer) {
199        await $.fs.write(`${dir}/result.md`, run.answer)
200        resultWritten.set(run.folder, run.answer)
201      }
202    })
203    .catch(() => {
204      dirty.add(run.agentId)
205    })
206  chains.set(run.folder, next)
207  return next
208}
209
210async function flushDirty($: Api): Promise<void> {
211  for (const id of [...dirty]) {
212    const run = runs.get(id)
213    if (!run) {
214      dirty.delete(id)
215      continue
216    }
217    const isSlow = run.tools.length > SLOW_AFTER_TOOLS
218    if (isSlow && (await clockNow($)) - (lastFlush.get(id) ?? 0) < SLOW_FLUSH_MS) continue
219    await flush($, run)
220  }
221  redraw($)
222}
223
224// One timer for the module. session.start arms it; so does the first tool call,
225// for a module reloaded without a session.start.
226function armTimer($: Api): void {
227  if (timerArmed) return
228  timerArmed = true
229  $.clock.every(FLUSH_MS, () => { void flushDirty($) })
230}
231
232function forget(run: Run): void {
233  runs.delete(run.agentId)
234  snapshots.delete(run.agentId)
235  promptWritten.delete(run.folder)
236  resultWritten.delete(run.folder)
237  chains.delete(run.folder)
238  lastFlush.delete(run.agentId)
239}
240
241function open(agentId: string, spawn: Record<string, unknown>, status: Run['status'], note?: string): Run {
242  if (runs.size >= MAX_LIVE_RUNS) {
243    for (const old of [...runs.values()]) {
244      if (old.status !== 'running' && !dirty.has(old.agentId)) forget(old)
245      if (runs.size < MAX_LIVE_RUNS) break
246    }
247  }
248  const now = new Date()
249  const stem = `${stamp(now)}_${slug(text(spawn.subagentType) || 'agent')}_${slug(text(spawn.description))}_${idTail(agentId)}`
250  let folder = stem
251  for (let n = 2; usedFolders.has(folder); n++) folder = `${stem}-${n}`
252  usedFolders.add(folder)
253  const run: Run = {
254    agentId,
255    folder,
256    session: sessionId,
257    status,
258    startedAt: now.toISOString(),
259    startMs: now.getTime(),
260    spawn,
261    tools: [],
262    toolsDropped: 0,
263    filesChanged: [],
264    possibleMutations: [],
265    turns: [],
266    note,
267  }
268  runs.set(agentId, run)
269  return run
270}
271
272function parseRun(raw: string, name: string): Run | undefined {
273  try {
274    const run = JSON.parse(raw) as Run
275    if (typeof run.agentId !== 'string') return undefined
276    run.folder = name
277    return run
278  } catch {
279    return undefined
280  }
281}
282
283function adopt(run: Run): void {
284  usedFolders.add(run.folder)
285  promptWritten.add(run.folder)
286  if (run.answer !== undefined) resultWritten.set(run.folder, run.answer)
287  runs.set(run.agentId, run)
288}
289
290// After a reload the module has forgotten its runs: find the run's folder on
291// disk by the agent id in its name and carry on writing to it. The folder name
292// on disk is trusted over the one inside run.json; a truncated run.json is skipped.
293async function rehydrate($: Api, agentId: string): Promise<Run | undefined> {
294  const root = await baseDir($)
295  if (root === undefined) return undefined
296  const entries = await $.fs.list(root).catch(() => [])
297  const suffix = `_${idTail(agentId)}`
298  const names = entries.map(x => x.name).filter(n => n.includes(suffix)).sort().reverse()
299  for (const name of names) {
300    const raw = await $.fs.read(`${root}/${name}/run.json`).catch(() => undefined)
301    const run = raw === undefined ? undefined : parseRun(raw, name)
302    if (run && run.agentId === agentId) return run
303  }
304  return undefined
305}
306
307// A subagent's events can arrive before its spawn is recorded, or after a
308// reload cleared module state: rehydrate from disk, else a labelled stub run.
309async function runFor($: Api, agentId: string): Promise<Run> {
310  const known = runs.get(agentId)
311  if (known) return known
312  const loaded = await rehydrate($, agentId)
313  const raced = runs.get(agentId)
314  if (raced) return raced
315  if (loaded) {
316    adopt(loaded)
317    return loaded
318  }
319  return open(agentId, { subagentType: 'unknown', description: 'seen-without-spawn' }, 'running',
320    'Events seen without a recorded spawn (the spawn hook was skipped, or the folder was removed).')
321}
322
323// The pane lists this session's subagents. After a reload or a resume the module
324// has none in memory, so the first look reads this session's recent runs from disk.
325async function loadSessionRuns($: Api): Promise<void> {
326  if (diskLoaded) return
327  diskLoaded = true
328  try {
329    const root = await baseDir($)
330    const sid = await sessionIdOf($)
331    if (root === undefined || sid === undefined) return
332    const entries = await $.fs.list(root).catch(() => [])
333    const names = entries.map(x => x.name).sort().reverse().slice(0, MAX_DISK_SCAN)
334    for (const name of names) {
335      if (usedFolders.has(name)) continue
336      const raw = await $.fs.read(`${root}/${name}/run.json`).catch(() => undefined)
337      const run = raw === undefined ? undefined : parseRun(raw, name)
338      if (run && run.session === sid && !runs.has(run.agentId)) adopt(run)
339    }
340  } catch {
341    // listing is a convenience
342  }
343}
344
345function sessionRuns(): Run[] {
346  return [...runs.values()]
347    .filter(r => r.session === undefined || r.session === sessionId)
348    .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
349}
350
351// ---- recording -------------------------------------------------------------
352
353function spawnRecord(e: Record<string, unknown>): Record<string, unknown> {
354  return {
355    prompt: redact(text(e.prompt)),
356    description: scrub(text(e.description), MAX_INPUT),
357    subagentType: e.subagentType,
358    requestedModel: e.model,
359    parentModel: e.parentModel,
360    provider: e.provider,
361    parentAgentId: e.parentAgentId,
362    permissionMode: e.permissionMode,
363    background: e.background,
364    fork: e.fork,
365    isTeammate: e.isTeammate,
366    name: e.name === undefined ? undefined : scrub(text(e.name), MAX_INPUT),
367    cwd: e.cwd === undefined ? undefined : scrub(text(e.cwd), MAX_INPUT),
368    workflow: e.workflow,
369    toolUseId: e.tool_use_id,
370  }
371}
372
373async function recordSpawn(
374  $: Api,
375  e: Record<string, unknown>,
376  started: Started | undefined,
377  error: unknown,
378  before: Snapshot | undefined,
379): Promise<void> {
380  try {
381    await sessionIdOf($)
382    let spawn: Record<string, unknown>
383    try {
384      spawn = spawnRecord(e)
385    } catch {
386      spawn = { subagentType: e.subagentType, toolUseId: e.tool_use_id, note: 'spawn details could not be recorded' }
387    }
388    const toolUse = text(e.tool_use_id)
389    if (error !== undefined) {
390      const run = open(`failed-${++counter}-${toolUse}`, spawn, 'error', scrub(`spawn failed: ${text(error)}`, MAX_INPUT))
391      run.endedAt = run.startedAt
392      run.endMs = run.startMs
393      void flush($, run)
394    } else if (started?.deny !== undefined) {
395      const run = open(`denied-${++counter}-${toolUse}`, spawn, 'denied', scrub(started.deny, MAX_INPUT))
396      run.endedAt = run.startedAt
397      run.endMs = run.startMs
398      void flush($, run)
399    } else if (started?.agentId !== undefined) {
400      const full = { ...spawn, resolvedModel: started.model, teammateId: started.teammateId }
401      if (before) snapshots.set(started.agentId, before)
402      const existing = runs.get(started.agentId)
403      if (existing) {
404        // Tool events beat the spawn record here: keep what they collected.
405        existing.spawn = full
406        existing.session = existing.session ?? sessionId
407        existing.note = undefined
408        void flush($, existing)
409      } else {
410        void flush($, open(started.agentId, full, 'running'))
411      }
412    }
413    redraw($)
414  } catch {
415    // logging must never get in the way of a spawn
416  }
417}
418
419async function recordTool(
420  $: Api,
421  agentId: string,
422  e: Record<string, unknown>,
423  result: ToolOutcome,
424  startedAt: number,
425): Promise<void> {
426  try {
427    armTimer($)
428    const endedAt = await clockNow($)
429    const run = await runFor($, agentId)
430    const { tool, tool_use_id: _id, agentId: _a, ...args } = e
431    const name = text(tool)
432    const compact: Record<string, unknown> = {}
433    for (const [k, v] of Object.entries(args)) compact[k] = typeof v === 'string' ? clip(v, MAX_FIELD) : v
434    const input = scrub(JSON.stringify(compact) ?? '', MAX_INPUT)
435    const outcome: ToolEntry['outcome'] =
436      result.deny !== undefined ? 'denied' : result.isError === true ? 'error' : 'ok'
437    const preview = result.deny ?? result.text
438
439    if (run.status !== 'running') {
440      run.status = 'running'
441      run.resumedAt = new Date(endedAt).toISOString()
442      run.endedAt = undefined
443      run.endMs = undefined
444    }
445    if (run.tools.length < MAX_TOOLS) {
446      run.tools.push({
447        at: new Date(startedAt).toISOString(),
448        tool: name,
449        ms: Math.max(0, endedAt - startedAt),
450        outcome,
451        input,
452        preview: typeof preview === 'string' ? scrub(preview, MAX_PREVIEW) : undefined,
453      })
454    } else {
455      run.toolsDropped += 1
456    }
457    if (outcome === 'ok') {
458      const rawPath = args.file_path ?? args.notebook_path
459      if (CHANGERS.has(name) && typeof rawPath === 'string') {
460        const file = scrub(normPath(rawPath, run.spawn.cwd), MAX_INPUT)
461        if (!run.filesChanged.includes(file) && run.filesChanged.length < MAX_FILES) run.filesChanged.push(file)
462      } else if (
463        (name === 'Bash' || name.startsWith('mcp__')) &&
464        result.isReadOnly !== true &&
465        run.possibleMutations.length < MAX_TOOLS
466      ) {
467        run.possibleMutations.push({ tool: name, input: clip(input, 200) })
468      }
469    }
470    dirty.add(agentId)
471  } catch {
472    // see recordSpawn
473  }
474}
475
476// Runs when a subagent's turn ends: attribute file changes with a second git
477// snapshot, judge scope, save, write the receipt, redraw.
478async function finalize($: Api, run: Run): Promise<void> {
479  try {
480    const before = snapshots.get(run.agentId)
481    if (before) {
482      const after = await snapshotWithin($, before.root)
483      if (after) {
484        const now = Date.now()
485        const others = [...runs.values()].filter(r => r !== run && overlaps(r, run, now))
486        const shared = run.spawn.background === true || others.length > 0
487        const changes = diffSnapshots(before, after)
488        run.gitDelta = {
489          files: changes.slice(0, MAX_GIT_FILES),
490          attribution: shared ? 'shared' : 'exclusive',
491          truncated: before.truncated || after.truncated || changes.length > MAX_GIT_FILES || undefined,
492        }
493      }
494    }
495    run.scope = evaluateScope(run)
496    await flush($, run)
497    redraw($)
498    if (config.receipts) {
499      await $.session
500        .append({ message: { type: 'system', content: [{ type: 'text', text: receiptText(run) }] } })
501        .catch(() => {})
502    }
503  } catch {
504    // see recordSpawn
505  }
506}
507
508async function recordTurn($: Api, e: Record<string, unknown>): Promise<void> {
509  try {
510    armTimer($)
511    const agentId = text(e.agentId)
512    const run = await runFor($, agentId)
513    const nowMs = Date.now()
514    const now = new Date(nowMs).toISOString()
515    const answer = redact(text(e.answer))
516    run.turns.push({
517      endedAt: now,
518      reason: text(e.reason),
519      durationMs: Number(e.durationMs) || 0,
520      usage: e.usage,
521      refusal: e.reason === 'refusal' ? scrub(JSON.stringify(e.refusal) ?? '', MAX_INPUT) : undefined,
522      answer,
523    })
524    run.endedAt = now
525    run.endMs = nowMs
526    run.answer = answer
527    run.status =
528      e.reason === 'answer' ? 'completed' : e.reason === 'aborted' ? 'aborted' : e.reason === 'refusal' ? 'refused' : 'error'
529    void finalize($, run)
530  } catch {
531    // see recordSpawn
532  }
533}
534
535// Anything still running when the session ends (a remote workflow agent never
536// raises turn.complete, a crashed run never finishes) is marked unfinished.
537// Waits for the writes, but never longer than END_WAIT_MS.
538async function endSession($: Api): Promise<void> {
539  try {
540    const nowMs = Date.now()
541    for (const run of runs.values()) {
542      if (run.status === 'running') {
543        run.status = 'unfinished'
544        run.endedAt = new Date(nowMs).toISOString()
545        run.endMs = nowMs
546        run.scope = evaluateScope(run)
547        dirty.add(run.agentId)
548      }
549    }
550    for (const id of [...dirty]) {
551      const run = runs.get(id)
552      if (run) void flush($, run)
553    }
554    await Promise.race([Promise.all([...chains.values()]), $.clock.sleep(END_WAIT_MS)])
555  } catch {
556    // see recordSpawn
557  }
558}
559
560async function openPane($: Api): Promise<string> {
561  await sessionIdOf($)
562  await loadSessionRuns($)
563  try {
564    await $.ui.open({ id: PANE, title: 'Subagents', focus: true })
565  } catch {
566    // a surface that draws no pane gets the text reply alone
567  }
568  return listText(sessionRuns())
569}
570
571export const register: Register = (on, options) => {
572  config = {
573    receipts: options?.receipts !== false,
574    gitAttribution: options?.gitAttribution !== false,
575  }
576
577  on('session.start', async ($, e, next) => {
578    armTimer($)
579    await $.command.register({
580      name: 'subagents',
581      description: 'Browse this session\'s subagent runs: scope, git changes, tool calls, result',
582    })
583    return next(e)
584  })
585
586  on('command.run', { command: 'subagents' }, async $ => ({ text: await openPane($) }))
587
588  on('agent.spawn', async ($, e, next) => {
589    const before = await snapshotWithin($, typeof e.cwd === 'string' ? e.cwd : undefined)
590    let started
591    try {
592      started = await next(e)
593    } catch (err) {
594      void recordSpawn($, e as Record<string, unknown>, undefined, err, before)
595      throw err
596    }
597    void recordSpawn($, e as Record<string, unknown>, started, undefined, before)
598    return started
599  })
600
601  on('tool.call', async ($, e, next) => {
602    const agentId = e.agentId
603    if (agentId === undefined) return next(e)
604    const startedAt = await clockNow($)
605    const result = await next(e)
606    void recordTool($, agentId, e as Record<string, unknown>, result as ToolOutcome, startedAt)
607    return result
608  })
609
610  on('turn.complete', async ($, e, next) => {
611    const done = await next(e)
612    if (e.agentId !== undefined) void recordTurn($, e as Record<string, unknown>)
613    return done
614  })
615
616  on('session.end', async ($, e, next) => {
617    await endSession($)
618    return next(e)
619  })
620
621  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
622    const E = $.ui.resolve(e)
623    const selectedId = await read($, selectedAtom)
624    const onlyFlagged = await read($, flaggedAtom)
625    const list = sessionRuns()
626    return buildPane(E, {
627      runs: list,
628      selected: selectedId === '' ? undefined : list.find(r => r.agentId === selectedId),
629      onlyFlagged,
630      columns: e.props.bodyColumns ?? e.viewport?.columns ?? 80,
631      rows: e.viewport?.rows ?? 24,
632      select: id => { void update($, selectedAtom, () => id) },
633      toggleFlagged: () => { void update($, flaggedAtom, v => !v) },
634    })
635  })
636}
637
hooks/gitdelta.ts 44 lines
1import type { GitChange } from './types'
2
3// A snapshot maps each dirty path to "<XY status>:<content hash>". Comparing
4// two snapshots finds files whose state changed in between, including a second
5// edit to a file that was already modified.
6export type Snapshot = { root: string; files: Record<string, string>; truncated: boolean }
7
8export type StatusEntry = { path: string; xy: string }
9
10export function parseStatus(out: string): StatusEntry[] {
11  const entries: StatusEntry[] = []
12  for (const line of out.split('\n')) {
13    if (line.length < 4) continue
14    const xy = line.slice(0, 2)
15    let path = line.slice(3)
16    const arrow = path.indexOf(' -> ')
17    if (arrow !== -1) path = path.slice(arrow + 4)
18    if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1)
19    entries.push({ path, xy })
20  }
21  return entries
22}
23
24export function buildFiles(entries: StatusEntry[], hashable: StatusEntry[], hashed: string[]): Record<string, string> {
25  const hashOf = new Map<string, string>()
26  hashable.forEach((e, i) => hashOf.set(e.path, hashed[i] ?? '-'))
27  const files: Record<string, string> = {}
28  for (const e of entries) files[e.path] = `${e.xy}:${hashOf.get(e.path) ?? '-'}`
29  return files
30}
31
32export function diffSnapshots(before: Snapshot, after: Snapshot): GitChange[] {
33  const out: GitChange[] = []
34  for (const [path, state] of Object.entries(after.files)) {
35    const was = before.files[path]
36    if (was === undefined) out.push({ path, change: 'new' })
37    else if (was !== state) out.push({ path, change: 'changed' })
38  }
39  for (const path of Object.keys(before.files)) {
40    if (after.files[path] === undefined) out.push({ path, change: 'cleaned' })
41  }
42  return out.sort((a, b) => a.path.localeCompare(b.path))
43}
44
hooks/receipt.ts 73 lines
1import { deniedCalls, evaluateScope } from './scope'
2import type { Run } from './types'
3
4export function fmtDuration(ms: number): string {
5  const s = Math.round(ms / 1000)
6  if (s < 60) return `${s}s`
7  const m = Math.floor(s / 60)
8  return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
9}
10
11export function fmtTokens(n: number): string {
12  return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
13}
14
15type Usage = {
16  input_tokens?: number
17  output_tokens?: number
18  cache_read_input_tokens?: number
19  cache_creation_input_tokens?: number
20}
21
22export function totals(run: Run): { input: number; output: number; cached: number } {
23  let input = 0
24  let output = 0
25  let cached = 0
26  for (const t of run.turns) {
27    const u = (t.usage ?? {}) as Usage
28    input += (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
29    output += u.output_tokens ?? 0
30    cached += u.cache_read_input_tokens ?? 0
31  }
32  return { input, output, cached }
33}
34
35export function duration(run: Run): number | undefined {
36  return run.startMs !== undefined && run.endMs !== undefined ? run.endMs - run.startMs : undefined
37}
38
39const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
40
41// The notice written into the chat when a subagent finishes: up to four short
42// lines. The model never reads it.
43export function receiptText(run: Run): string {
44  const scope = run.scope ?? evaluateScope(run)
45  const type = String(run.spawn.subagentType ?? 'agent')
46  const what = String(run.spawn.description ?? '').slice(0, 60)
47  const ms = duration(run)
48  const t = totals(run)
49  const files = run.gitDelta ? run.gitDelta.files.length : run.filesChanged.length
50  const head = [
51    `subagent-audit: ${type}${what ? ` "${what}"` : ''} ${run.status}`,
52    ms !== undefined ? fmtDuration(ms) : undefined,
53    plural(run.tools.length, 'tool call'),
54    `${plural(files, 'file')} changed (${run.gitDelta ? 'git' : 'edit tools'})`,
55    `out ${fmtTokens(t.output)}`,
56  ].filter(Boolean)
57  const lines = [head.join(' · ')]
58  if (run.gitDelta && run.gitDelta.files.length > 0) {
59    const shown = run.gitDelta.files.slice(0, 5).map(f => f.path).join(', ')
60    const more = run.gitDelta.files.length > 5 ? ` +${run.gitDelta.files.length - 5} more` : ''
61    const shared = run.gitDelta.attribution === 'shared' ? ' (shared: others may have changed these)' : ''
62    lines.push(`  changed: ${shown}${more}${shared}`)
63  }
64  if (scope.violations.length > 0) {
65    lines.push(`  scope: ${scope.declared} agent, ${plural(scope.violations.length, 'violation')}: ${scope.violations.slice(0, 3).join('; ')}`)
66  } else if (run.possibleMutations.length > 0) {
67    lines.push(`  flagged: ${plural(run.possibleMutations.length, 'Bash/MCP call')} not marked read-only`)
68  }
69  const denied = deniedCalls(run)
70  if (denied > 0) lines.push(`  ${plural(denied, 'tool call')} denied`)
71  return lines.join('\n')
72}
73
hooks/redact.ts 25 lines
1const MAX_SCAN = 20000
2
3const SECRET_PATTERNS: [RegExp, string][] = [
4  [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{0,8000}?-----END [A-Z ]*PRIVATE KEY-----/g, '[redacted private key]'],
5  [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted jwt]'],
6  [/\b(?:sk-ant-|sk-|sk_live_|sk_test_|rk_live_|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|glpat-|xox[abprs]-|AKIA|ASIA|AIza|hf_|npm_|SG\.|ya29\.)[A-Za-z0-9_.-]{12,}/g, '[redacted token]'],
7  [/\b(Authorization(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?(?:Bearer|Basic|Token)\s+)[^\s"'\\]{6,}/gi, '$1[redacted]'],
8  [/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, 'Bearer [redacted]'],
9  [/\b([a-z][a-z0-9+.-]{1,20}:\/\/[^\s:@/]+:)[^\s@/]+@/gi, '$1[redacted]@'],
10  [/((?:password|passwd|passphrase|pwd|secret|token|api[_-]?key|apikey|credential|private[_-]?key|access[_-]?key)[A-Za-z0-9_]*(?:\\?["'])?\s*[=:]\s*(?:\\?["'])?)(?=[^\s"'\\&,;]*[A-Za-z])[^\s"'\\&,;]{6,}/gi, '$1[redacted]'],
11]
12
13export function redact(text: string): string {
14  let out = text
15  for (const [pattern, replacement] of SECRET_PATTERNS) out = out.replace(pattern, replacement)
16  return out
17}
18
19export const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}… [+${s.length - n} chars]` : s)
20
21// Clip first, then redact: a huge tool argument is never scanned whole.
22export const scrub = (s: string, n: number) => clip(redact(s.slice(0, MAX_SCAN)), n)
23
24export const text = (v: unknown): string => (typeof v === 'string' ? v : v === undefined || v === null ? '' : String(v))
25
hooks/scope.ts 33 lines
1import type { Run, Scope } from './types'
2
3// Agent types that are read-only by design: any write, flagged call or git
4// change made by one of them is reported as a violation.
5const READ_ONLY_TYPES = new Set(['explore', 'plan', 'claude-code-guide'])
6
7const WRITERS = new Set(['Edit', 'Write', 'NotebookEdit'])
8
9export function evaluateScope(run: Run): Scope {
10  const type = String(run.spawn.subagentType ?? '').toLowerCase()
11  const declared: Scope['declared'] = READ_ONLY_TYPES.has(type) ? 'read-only' : 'unrestricted'
12  const violations: string[] = []
13  if (declared === 'read-only') {
14    for (const t of run.tools) {
15      if (t.outcome === 'ok' && WRITERS.has(t.tool)) violations.push(`${t.tool} succeeded`)
16    }
17    for (const m of run.possibleMutations) violations.push(`${m.tool} call not marked read-only`)
18    if (run.gitDelta && run.gitDelta.attribution === 'exclusive') {
19      for (const f of run.gitDelta.files) violations.push(`git: ${f.path} ${f.change}`)
20    }
21  }
22  return { declared, violations: [...new Set(violations)].slice(0, 20) }
23}
24
25export const deniedCalls = (run: Run) => run.tools.filter(t => t.outcome === 'denied').length
26
27// How many things in this run deserve a second look: violations for a
28// read-only agent, flagged Bash/MCP calls for any other.
29export function flagCount(run: Run): number {
30  const scope = run.scope ?? evaluateScope(run)
31  return scope.declared === 'read-only' ? scope.violations.length : run.possibleMutations.length
32}
33
hooks/types.ts 97 lines
1export type Timer = { cancel: () => void }
2
3// The part of the engine's `$` this mod uses. Declared here so the helpers
4// that take `$` stay checkable without importing the engine's whole interface.
5export type Api = {
6  clock: {
7    now: () => number | Promise<number>
8    every: (ms: number, fn: () => void) => Timer
9    sleep: (ms: number) => Promise<void>
10  }
11  env: { get: (name: string) => Promise<string | undefined> }
12  fs: {
13    write: (path: string, text: string) => Promise<void>
14    read: (path: string) => Promise<string>
15    list: (path: string) => Promise<{ name: string }[]>
16  }
17  process: {
18    run: (
19      argv: readonly string[],
20      init?: { cwd?: string; stdin?: string; timeoutMs?: number },
21    ) => Promise<{ exitCode: number; stdout: string; stderr: string }>
22  }
23  session: {
24    id: () => Promise<string>
25    append: (args: {
26      message: { type: 'system'; content: { type: 'text'; text: string }[] }
27    }) => Promise<unknown>
28  }
29  ui: {
30    invalidate: (event: 'ui.render') => void
31    open: (pane: { id: string; title?: string; focus?: true }) => Promise<unknown>
32  }
33}
34
35export type ToolEntry = {
36  at: string
37  tool: string
38  ms: number
39  outcome: 'ok' | 'error' | 'denied'
40  input: string
41  preview?: string
42}
43
44export type Turn = {
45  endedAt: string
46  reason: string
47  durationMs: number
48  usage?: unknown
49  refusal?: string
50  answer?: string
51}
52
53export type RunStatus = 'running' | 'completed' | 'aborted' | 'error' | 'refused' | 'denied' | 'unfinished'
54
55export type GitChange = { path: string; change: 'new' | 'changed' | 'cleaned' }
56
57export type GitDelta = {
58  files: GitChange[]
59  // exclusive: nothing else was running while this subagent ran.
60  // shared: it ran in the background, or overlapped another subagent, so the
61  // listed files may belong to someone else.
62  attribution: 'exclusive' | 'shared'
63  truncated?: boolean
64}
65
66export type Scope = {
67  declared: 'read-only' | 'unrestricted'
68  violations: string[]
69}
70
71export type Run = {
72  agentId: string
73  folder: string
74  session?: string
75  status: RunStatus
76  startedAt: string
77  startMs?: number
78  endMs?: number
79  endedAt?: string
80  resumedAt?: string
81  spawn: Record<string, unknown>
82  tools: ToolEntry[]
83  toolsDropped: number
84  // Files changed through Edit, Write and NotebookEdit calls that succeeded.
85  filesChanged: string[]
86  // Bash and MCP calls that succeeded and that the engine did not mark read-only.
87  possibleMutations: { tool: string; input: string }[]
88  // Files whose git state differs between the subagent's start and its end,
89  // so edits made through Bash and other tools are caught too. Absent when the
90  // working directory is not a git repository or the check failed.
91  gitDelta?: GitDelta
92  scope?: Scope
93  turns: Turn[]
94  answer?: string
95  note?: string
96}
97
hooks/view.tsx 148 lines
1import { duration, fmtDuration, fmtTokens, totals } from './receipt'
2import { deniedCalls, evaluateScope, flagCount } from './scope'
3import type { Run, RunStatus } from './types'
4
5// The elements table `$.ui.resolve(e)` returns differs per surface; this view
6// uses only Box, Text and Button, which every surface that draws a pane has.
7// eslint-disable-next-line @typescript-eslint/no-explicit-any
8type Elements = { Box: any; Text: any; Button: any }
9
10export type PaneCtx = {
11  runs: Run[]
12  selected: Run | undefined
13  onlyFlagged: boolean
14  columns: number
15  rows: number
16  select: (id: string) => void
17  toggleFlagged: () => void
18}
19
20const GLYPH: Record<RunStatus, string> = {
21  running: '●',
22  completed: '✓',
23  aborted: '■',
24  error: '✗',
25  refused: '⊘',
26  denied: '⊘',
27  unfinished: '…',
28}
29
30const COLOR: Record<RunStatus, string | undefined> = {
31  running: 'yellow',
32  completed: 'green',
33  aborted: undefined,
34  error: 'red',
35  refused: 'red',
36  denied: 'red',
37  unfinished: undefined,
38}
39
40const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
41const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
42const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
43
44export function runLabel(run: Run): string {
45  const type = String(run.spawn.subagentType ?? 'agent')
46  const what = oneLine(String(run.spawn.description ?? ''))
47  const ms = duration(run)
48  const files = run.gitDelta ? run.gitDelta.files.length : run.filesChanged.length
49  const flags = flagCount(run)
50  return [
51    `${GLYPH[run.status]} ${type}${what ? ` · ${what}` : ''}`,
52    ms !== undefined ? fmtDuration(ms) : run.status,
53    plural(run.tools.length, 'tool'),
54    plural(files, 'file'),
55    flags > 0 ? `⚠ ${flags}` : undefined,
56  ]
57    .filter(Boolean)
58    .join(' · ')
59}
60
61// The plain-text list `/subagents` answers with, for surfaces that draw no pane.
62export function listText(runs: Run[]): string {
63  if (runs.length === 0) return 'No subagent runs recorded in this session yet.'
64  const running = runs.filter(r => r.status === 'running').length
65  const flagged = runs.filter(r => flagCount(r) > 0).length
66  const head = `${plural(runs.length, 'subagent run')} · ${running} running · ${flagged} flagged`
67  return [head, ...runs.slice(0, 15).map(runLabel)].join('\n')
68}
69
70export function buildPane(E: Elements, ctx: PaneCtx) {
71  const { Box, Text, Button } = E
72  const width = Math.max(30, ctx.columns - 2)
73
74  if (ctx.selected) {
75    const run = ctx.selected
76    const scope = run.scope ?? evaluateScope(run)
77    const t = totals(run)
78    const ms = duration(run)
79    const room = Math.max(6, ctx.rows - 22)
80    const git = run.gitDelta
81    const timeline = run.tools.slice(-Math.min(room, 14))
82    const t0 = run.startMs ?? Date.parse(run.startedAt)
83    const denied = deniedCalls(run)
84    return (
85      <Box flexDirection="column" paddingX={1}>
86        <Button key="back" plain onPress={() => ctx.select('')}>← All subagents</Button>
87        <Text bold color={COLOR[run.status]}>{cut(`${GLYPH[run.status]} ${run.spawn.subagentType ?? 'agent'} · ${oneLine(String(run.spawn.description ?? ''))}`, width)}</Text>
88        <Text dimColor>{cut([run.status, ms !== undefined ? fmtDuration(ms) : undefined, String(run.spawn.resolvedModel ?? run.spawn.requestedModel ?? ''), run.spawn.permissionMode ? `mode ${run.spawn.permissionMode}` : undefined, run.spawn.background ? 'background' : undefined].filter(Boolean).join(' · '), width)}</Text>
89        <Text dimColor>{cut(`tokens: in ${fmtTokens(t.input)} · out ${fmtTokens(t.output)} · cache read ${fmtTokens(t.cached)}`, width)}</Text>
90
91        <Text bold color={scope.violations.length > 0 ? 'red' : undefined}>
92          Scope: {scope.declared} agent{scope.violations.length > 0 ? ` · ${plural(scope.violations.length, 'violation')}` : ' · no violations'}
93        </Text>
94        {scope.violations.slice(0, 6).map((v: string) => <Text color="red">{cut(`  ${v}`, width)}</Text>)}
95        {denied > 0 && <Text dimColor>{`  ${plural(denied, 'tool call')} denied`}</Text>}
96
97        <Text bold>
98          Changed in git{git ? ` (${git.attribution})` : ''}
99        </Text>
100        {!git && <Text dimColor>  not available (not a git repository, or the check did not run)</Text>}
101        {git && git.files.length === 0 && <Text dimColor>  no file changes between start and end</Text>}
102        {git && git.attribution === 'shared' && git.files.length > 0 && <Text dimColor>  shared: others may have changed these</Text>}
103        {git && git.files.slice(0, 10).map(f => <Text>{cut(`  ${f.change.padEnd(7)} ${f.path}`, width)}</Text>)}
104        {git && git.files.length > 10 && <Text dimColor>{`  +${git.files.length - 10} more`}</Text>}
105
106        {run.filesChanged.length > 0 && <Text bold>Edited with Edit/Write ({run.filesChanged.length})</Text>}
107        {run.filesChanged.slice(0, 6).map((f: string) => <Text dimColor>{cut(`  ${f}`, width)}</Text>)}
108
109        {run.possibleMutations.length > 0 && <Text bold color="yellow">Flagged calls ({run.possibleMutations.length})</Text>}
110        {run.possibleMutations.slice(0, 6).map(m => <Text>{cut(`  ${m.tool}: ${oneLine(m.input)}`, width)}</Text>)}
111
112        <Text bold>Tool calls ({run.tools.length}{run.toolsDropped > 0 ? ` + ${run.toolsDropped} not kept` : ''})</Text>
113        {timeline.map(c => (
114          <Text color={c.outcome === 'ok' ? undefined : 'red'} dimColor={c.outcome === 'ok'}>
115            {cut(`  +${fmtDuration(Math.max(0, Date.parse(c.at) - t0)).padEnd(6)} ${c.tool.padEnd(8)} ${c.outcome === 'ok' ? '' : `${c.outcome} `}${oneLine(c.input)}`, width)}
116          </Text>
117        ))}
118
119        <Text bold>Task</Text>
120        <Text dimColor>{cut(oneLine(String(run.spawn.prompt ?? '')), width * 3)}</Text>
121        {run.answer && <Text bold>Result</Text>}
122        {run.answer && <Text>{cut(oneLine(run.answer), width * 4)}</Text>}
123
124        <Text dimColor>{cut(`record: ~/.claude/agent-runs/${run.folder}`, width)}</Text>
125        <Text dimColor>{cut(`native transcript: subagents/agent-${run.agentId}.jsonl`, width)}</Text>
126      </Box>
127    )
128  }
129
130  const visible = ctx.onlyFlagged ? ctx.runs.filter(r => flagCount(r) > 0) : ctx.runs
131  const running = ctx.runs.filter(r => r.status === 'running').length
132  const flagged = ctx.runs.filter(r => flagCount(r) > 0).length
133  const room = Math.max(3, ctx.rows - 5)
134  return (
135    <Box flexDirection="column" paddingX={1}>
136      <Text bold>{plural(ctx.runs.length, 'subagent run')} · {running} running · {flagged} flagged</Text>
137      <Button key="filter" plain dimColor onPress={ctx.toggleFlagged}>{ctx.onlyFlagged ? 'Showing flagged only · show all' : 'Show flagged only'}</Button>
138      {visible.length === 0 && <Text dimColor>{ctx.onlyFlagged ? 'Nothing flagged.' : 'No subagent runs in this session yet.'}</Text>}
139      {visible.slice(0, room).map(run => (
140        <Button key={`run:${run.agentId}`} plain onPress={() => ctx.select(run.agentId)}>
141          {cut(runLabel(run), width)}
142        </Button>
143      ))}
144      {visible.length > room && <Text dimColor>{`+${visible.length - room} older`}</Text>}
145    </Box>
146  )
147}
148
types/index.d.ts 13 lines
1// State the subagent-audit pane keeps for the session. Only the pane's own
2// selection lives here; the runs themselves are held by the module and on disk.
3export type PaneState = {
4  selected: string
5  onlyFlagged: boolean
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'subagent-audit': { selected: string; onlyFlagged: boolean }
11  }
12}
13