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

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:
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.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./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.~/.claude/agent-runs/, independent of the transcript store's cleanup.No model calls and no network. Git runs locally.
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.
One folder per run in ~/.claude/agent-runs/ (or $CLAUDE_CONFIG_DIR/agent-runs/ if that is set):
| File | Contents |
|---|---|
run.json | Spawn 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.md | The task the subagent was given |
result.md | Its 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.
| Option | Default | Effect |
|---|---|---|
receipts | on | Write the finish receipt into the chat |
gitAttribution | on | Compare git state at start and end. Turning it off removes the git file list and the git-based scope checks |
Claude Code 2.1.287 or later, in the terminal or the desktop app's Code tab.
/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>.
[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.~/.claude/agent-runs out of cloud sync and Git.filesChanged covers Edit, Write and NotebookEdit calls; possibleMutations lists Bash and MCP calls the engine did not mark read-only.toolsDropped counts the rest). Runs with more than 200 entries are rewritten at most every 10 seconds.unfinished.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.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).
MIT
hooks/register.tsx 637 lines1import { 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}
637hooks/gitdelta.ts 44 lines1import 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}
44hooks/receipt.ts 73 lines1import { 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}
73hooks/redact.ts 25 lines1const 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))
25hooks/scope.ts 33 lines1import 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}
33hooks/types.ts 97 lines1export 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}
97hooks/view.tsx 148 lines1import { 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}
148types/index.d.ts 13 lines1// 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