Execution profiler for Claude Code: model wait vs tools vs subagents, live, with OTLP export

A Claude Code mod that shows where an agent session's time goes: model wait, tool execution, subagents, test runs, repeated file reads, and engine overhead. It exports the session as an OTLP trace so you can open it in Jaeger or Grafana Tempo.
Requires Claude Code 2.1.287 or later (desktop 2.1.286). Event shapes follow the 2.1.292 type declarations; the end-to-end probe (claude -p), the Jaeger import and the tests ran on Claude Code 2.1.280 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
Every span and metric carries agent_profiler.observability. The mod never claims to measure "inference time"; it reports what the client can observe.
| Grade | Meaning | Example |
|---|---|---|
measured | Value measured and handed over by the engine | classic.PostToolUse.duration_ms (tool execution time) |
observed | Interval the mod timed in-process from event boundaries | turn.step send, first chunk, end; tool.call entry to return |
reported | Value reported by the API | usage.output_tokens, cache read/creation tokens |
inferred | Remainder computed from the values above | tool wait = wall - exec; engine overhead = turn - (request + tool) |
Time categories (one context is the main loop or one subagent loop):
turn (turn.start -> turn.complete) observed
+- model.request (turn.step: next call -> stream end) observed
| +- ttft : send -> first chunk (network + queueing + prefill, not separable)
| +- thinking.stream : first thinking chunk -> last thinking chunk
| +- output.stream : first text/tool chunk -> end
| + usage (tokens) reported
+- tool (tool.call: entry -> next return) observed
| +- exec = duration_ms measured
| +- wait = wall - exec inferred
| approval wait if tool.check said 'ask', otherwise tool.overhead
+- subagent (classic.SubagentStart -> classic.SubagentStop) observed
| +- (recursive) model.request / tool
+- engine.overhead = turn - (model.request U tool U blocking subagent) inferred
idle: turn.complete -> next turn.start (excluded from agent time)
TTFT and stream times are response-wait times observed at the client, not time the model spent computing. Engine retries are absorbed into one turn.step, so they appear as a longer TTFT.
/plugin install agent-profiler --marketplace YeonwooSung/my-claude-code-mods
During development, load the folder directly:
claude --plugin-dir ./agent-profiler
Mods are on by default from Claude Code 2.1.287 (desktop 2.1.286). Earlier builds need CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the environment of the claude process; 2.1.287+ ignores it.
| Command | What it does |
|---|---|
/profile | Markdown report of the whole session (time categories, tool/model stats, repeated reads, subagents, findings) |
/profile turn | The same report for the last turn only |
/profile-pane | Opens the live pane (in-flight requests and tools, category bars, recent findings) |
/profile-export | Writes a full snapshot of the session as OTLP JSON chunk files and POSTs each if an endpoint is configured; returns one line per file written and the POST result |
A status line (model 61% - tools 28% - running: Bash npm test 14s) is updated at most twice a second.
Set in the /plugin settings menu or in settings pluginConfigs.
| Key | Default | Meaning |
|---|---|---|
otlpEndpoint | "" | OTLP/HTTP base URL, e.g. http://localhost:4318. The mod POSTs to {endpoint}/v1/traces. Empty means file only. |
outputDir | ~/.claude/agent-profiler | Where trace files are written, one folder per session (see below). |
exportCommandText | false | Include turn prompts and Bash command lines in exported traces (files and POSTs), with obvious secrets redacted. |
Files go to <outputDir>/<sessionId>/. <loadId> is a base-36 timestamp taken when the mod loads, so a hot reload or --resume starts new file names and never overwrites earlier ones.
| File | Written | Contents |
|---|---|---|
<loadId>-<seq>.otlp.json (seq = 0001, 0002, ...) | after every turn.complete, and at session.end | Per-turn batches hold only the turns, requests, tools and subagents that finished since the previous batch. The session.end batch holds everything not written yet (open spans marked agent_profiler.incomplete) plus the session root span. |
<loadId>-full-<n>.otlp.json | by /profile-export | A full snapshot of what is in memory, root and open spans included. A later /profile-export in the same load rewrites these. |
Each file is at most 3.5 MiB; a larger batch or snapshot is split into several files (and several POSTs). If otlpEndpoint is set, each batch is POSTed after it is written. The session.end POST gets what is left of Claude Code's short exit budget (1.5 s at most) and is dropped past it; the file is written first. Nothing is written until the session id is known. A failed automatic write or POST is shown once as a toast.
The numbered batches together are the whole session; the full files are a separate snapshot, so send one set or the other, not both.
Per span: name, start/end time, parent, status, and these attributes.
service.name=claude-code, session.id, agent_profiler.version.agent_profiler.turn.reason; agent_profiler.turn.prompt (first 120 characters) only with exportCommandText.chat <model>): gen_ai.request.model, gen_ai.response.model, input/output token counts, cache read/creation tokens, finish reason, effort, agent_profiler.ttft_ms; child spans model.ttft, model.thinking_stream, model.output_stream.execute_tool <tool>): tool name and call id, agent_profiler.exec_ms, wait_ms, permission decision, fs.path and fs.range (file tools), search.pattern (Glob/Grep), bash.category, launched agent id; agent_profiler.bash.command only with exportCommandText; child spans tool.exec and tool.approval_wait / tool.overhead.invoke_agent <type>): agent id and type, whether it ran async.agent_profiler.category, agent_profiler.observability, agent_profiler.incomplete when still open.With exportCommandText on, prompts and commands are redacted first: Authorization: ... header values, Bearer <token>, --password <x> / --password=<x>, mysql ... -p<password>, and NAME=value where NAME contains KEY, TOKEN, SECRET or PASSWORD. This catches common shapes only, so leave the setting off when traces leave your machine and commands may carry secrets. File paths and search patterns are always exported.
traceId is the session UUID without dashes and spanId is a deterministic hash, so re-sending the same session yields the same spans.
Jaeger:
docker compose -f agent-profiler/examples/jaeger/docker-compose.yml up -d
# send a session's numbered batches (leave out the -full- snapshot files)
for f in $HOME/.claude/agent-profiler/<sessionId>/*.otlp.json; do
case "$f" in *-full-*) continue ;; esac
curl -sS -X POST -H 'content-type: application/json' --data @"$f" http://localhost:4318/v1/traces
done
To send the latest /profile-export snapshot instead, loop over *-full-*.otlp.json of one load id.
Open http://localhost:16686 in a browser and pick the service claude-code. Stop it with:
docker compose -f agent-profiler/examples/jaeger/docker-compose.yml down
Or set otlpEndpoint to http://localhost:4318 and the mod sends the trace itself.
Grafana Tempo accepts the same OTLP/HTTP endpoint (port 4318 by default), so use the same curl or otlpEndpoint value pointing at your Tempo (or OpenTelemetry Collector) host.
Span names follow the GenAI semantic conventions: chat <model>, execute_tool <tool>, invoke_agent <type>, with agent_profiler.* attributes for the rest.
| Environment | Hooks, recording, export | Pane / status line | /profile text |
|---|---|---|---|
Terminal claude | yes | yes | yes |
| Desktop Code tab | yes | yes | yes |
| VS Code chat panel | yes | no (drawing is not shown) | yes |
claude -p / SDK | yes | no | yes |
/profile and the pane start over from the reload. Batches already written stay on disk, and the new load writes under a new load id.turn.start fires only for main-loop turns. Subagents have no turn spans of their own: their chat and execute_tool spans hang directly under invoke_agent.invoke_agent span is still a child of execute_tool Agent but outlives it, and the completion notification starts an extra main turn.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Other versions may differ.cd agent-profiler
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test
claude plugin validate .
(The variable is only needed before 2.1.287.)
Type check (from the repository root):
npx -y -p typescript@5 tsc -p agent-profiler --noEmit
tsconfig.json includes .claude-plugin/types, where Claude Code writes claude-code/index.d.ts each time it loads the mod (the folder is git-ignored). Before the first load, copy the declarations there yourself, e.g. from the plugin-authoring skill's types/claude-code.d.ts to agent-profiler/.claude-plugin/types/claude-code/index.d.ts.
hooks/register.tsx 360 lines1import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
2
3import { summarize, type Scope, type Summary } from './core/analyze.ts'
4import { MAX_EXPORT_BYTES, pendingKeys, splitRequest } from './core/export.ts'
5import { toOtlp, type OtlpRequest } from './core/otlp.ts'
6import { Profiler, summarizeInput, toolOutcome } from './core/profiler.ts'
7import { paneLines, renderReport, statusLine } from './core/report.ts'
8
9const VERSION = '0.1.0'
10const PANE = 'agent-profiler'
11const COMMANDS = [
12 { name: 'profile', description: "Show where this session's time went (model wait, tools, subagents, tests, re-reads); 'turn' for the last turn" },
13 { name: 'profile-pane', description: 'Open the live Agent Profiler pane' },
14 { name: 'profile-export', description: "Write this session's trace as OTLP JSON and send it to the configured endpoint" },
15]
16// Names this module load's files, so a hot reload or --resume never overwrites earlier batches.
17const LOAD_ID = Date.now().toString(36)
18
19let profiler = new Profiler()
20let lastPaint = 0
21let paintQueuedAt = 0
22let anonymous = 0
23let batchSeq = 0
24let ticker: Timer | undefined
25// Records already written as a per-turn batch, for the current profiler.
26let exported = new Set<string>()
27const toasted = new Set<string>()
28const summaries = new Map<Scope, { profiler: Profiler; version: number; sum: Summary }>()
29
30// Recording must never break the session: every bookkeeping step goes through here.
31function safe<T>(fn: () => T): void {
32 try {
33 fn()
34 } catch {
35 // dropped on purpose
36 }
37}
38
39// The status line, the pane and the ticker share one summary per profiler version.
40function cachedSummary(scope: Scope = 'session'): Summary {
41 const hit = summaries.get(scope)
42 if (hit && hit.profiler === profiler && hit.version === profiler.version) return hit.sum
43 const sum = summarize(profiler.snapshot(Date.now()), scope)
44 summaries.set(scope, { profiler, version: profiler.version, sum })
45 return sum
46}
47
48function paintNow($: EngineInterface): void {
49 const now = Date.now()
50 lastPaint = now
51 safe(() => {
52 $.ui.status(statusLine(cachedSummary(), profiler.running(now)))
53 $.ui.invalidate('ui.render')
54 })
55}
56
57// Hooks never paint on their own path: a paint is queued behind the event (at most one pending).
58function paint($: EngineInterface, force = false): void {
59 const now = Date.now()
60 if (paintQueuedAt && now - paintQueuedAt < 2_000) return // a refused timer must not block paints forever
61 if (!force && now - lastPaint < 500) return
62 paintQueuedAt = now
63 try {
64 $.clock.after(0, () => {
65 paintQueuedAt = 0
66 paintNow($)
67 })
68 } catch {
69 paintQueuedAt = 0
70 }
71}
72
73function useProfiler(next: Profiler): void {
74 profiler = next
75 exported = new Set()
76}
77
78// Failures of automatic saves surface once each, as a toast; the status line is the profiler's own.
79function warn($: EngineInterface, message: string): void {
80 if (toasted.has(message)) return
81 toasted.add(message)
82 safe(() => $.ui.toast(`agent-profiler: ${message}`))
83}
84
85async function sessionIdOf($: EngineInterface, p: Profiler): Promise<string | undefined> {
86 if (p.sessionId !== 'unknown') return p.sessionId
87 try {
88 p.adoptSessionId(await $.session.id())
89 } catch {
90 // still unknown
91 }
92 return p.sessionId === 'unknown' ? undefined : p.sessionId
93}
94
95async function outputDir($: EngineInterface, options: PluginOptions): Promise<string> {
96 const dir = (String(options.outputDir ?? '').trim() || '~/.claude/agent-profiler').replace(/\/+$/, '')
97 if (!/^~(?=\/|$)/.test(dir)) return dir
98 return dir.replace(/^~/, (await $.env.get('HOME')) ?? '.')
99}
100
101function endpointOf(options: PluginOptions): string {
102 return String(options.otlpEndpoint ?? '').trim().replace(/\/+$/, '')
103}
104
105// One POST; with a deadline it gives up when the deadline passes or the signal aborts.
106async function post($: EngineInterface, endpoint: string, body: string, deadline?: { at: number; signal?: AbortSignal }): Promise<string | undefined> {
107 const request = $.http.fetch(`${endpoint}/v1/traces`, { method: 'POST', headers: { 'content-type': 'application/json' }, body })
108 request.catch(() => undefined) // a late failure after a timeout is nobody's business
109 try {
110 let answer
111 if (deadline) {
112 const left = deadline.at - Date.now()
113 if (left <= 0) return 'no time left before exit'
114 const timeout = $.clock.sleep(left, deadline.signal ? { signal: deadline.signal } : undefined).then(() => undefined)
115 answer = await Promise.race([request, timeout])
116 if (!answer) return `no answer within ${left}ms`
117 } else {
118 answer = await request
119 }
120 return answer.ok ? undefined : `${endpoint} answered ${answer.status}`
121 } catch (error) {
122 return String(error)
123 }
124}
125
126type ExportMode = 'batch' | 'final' | 'full'
127type ExportResult = { notes: string[]; failures: string[] }
128
129// batch: records finished since the last batch. final: everything not exported yet, open records
130// marked incomplete, and the session root. full: a whole snapshot (/profile-export), in chunk files.
131async function exportTrace($: EngineInterface, options: PluginOptions, mode: ExportMode, deadline?: { at: number; signal?: AbortSignal }): Promise<ExportResult> {
132 const result: ExportResult = { notes: [], failures: [] }
133 const p = profiler
134 const done = exported
135 const sessionId = await sessionIdOf($, p)
136 if (sessionId === undefined) {
137 result.failures.push('session id not known yet; nothing written')
138 return result
139 }
140 const dir = `${await outputDir($, options)}/${sessionId}`
141
142 // From snapshot to claim nothing awaits, so overlapping exports never pick the same records.
143 const snap = p.snapshot(Date.now())
144 const commandText = options.exportCommandText === true
145 let claimed: string[] = []
146 let request: OtlpRequest
147 if (mode === 'full') {
148 request = toOtlp(snap, VERSION, { commandText })
149 } else {
150 claimed = pendingKeys(snap, done, mode === 'final')
151 const keys = new Set(claimed)
152 for (const key of claimed) done.add(key)
153 request = toOtlp(snap, VERSION, { include: key => keys.has(key), root: mode === 'final', commandText })
154 }
155 const parts = splitRequest(request, MAX_EXPORT_BYTES)
156 if (parts.length === 0) {
157 if (mode === 'full') result.notes.push('nothing recorded yet')
158 return result
159 }
160
161 const bodies: string[] = []
162 try {
163 for (const [n, part] of parts.entries()) {
164 const name = mode === 'full' ? `${LOAD_ID}-full-${n + 1}` : `${LOAD_ID}-${String((batchSeq += 1)).padStart(4, '0')}`
165 const path = `${dir}/${name}.otlp.json`
166 const body = JSON.stringify(part)
167 await $.fs.write(path, body)
168 bodies.push(body)
169 result.notes.push(`wrote ${path}`)
170 }
171 } catch (error) {
172 for (const key of claimed) done.delete(key) // retried with the next batch
173 result.failures.push(`could not write to ${dir}: ${String(error)}`)
174 return result
175 }
176
177 const endpoint = endpointOf(options)
178 if (endpoint) {
179 let failed = 0
180 for (const body of bodies) {
181 const reason = await post($, endpoint, body, deadline)
182 if (reason !== undefined) {
183 failed += 1
184 result.failures.push(`POST failed: ${reason}`)
185 break
186 }
187 }
188 if (failed === 0) result.notes.push(`sent to ${endpoint}${bodies.length > 1 ? ` in ${bodies.length} requests` : ''}`)
189 }
190 return result
191}
192
193function autoExport($: EngineInterface, options: PluginOptions): void {
194 exportTrace($, options, 'batch').then(
195 r => r.failures.forEach(f => warn($, f)),
196 error => warn($, `could not save the trace: ${String(error)}`),
197 )
198}
199
200export const register: Register = (on, options) => {
201 on('session.start', async ($, e, next) => {
202 try {
203 useProfiler(new Profiler(await $.session.id()))
204 } catch {
205 useProfiler(new Profiler())
206 }
207 // Each step on its own: a refusal here must not keep the session from starting.
208 for (const command of COMMANDS) {
209 try {
210 await $.command.register(command)
211 } catch {
212 // the command stays unavailable
213 }
214 }
215 safe(() => ticker?.cancel())
216 ticker = undefined
217 try {
218 ticker = $.clock.every(1_000, () => {
219 if (profiler.running(Date.now()).length > 0) paintNow($)
220 })
221 } catch {
222 // no live refresh
223 }
224 return next(e)
225 })
226
227 on('turn.start', async ($, e, next) => {
228 const t = Date.now()
229 try {
230 const id = await $.session.id()
231 if (profiler.sessionId === 'unknown') profiler.adoptSessionId(id) // loaded mid-session
232 else if (id !== profiler.sessionId) useProfiler(new Profiler(id)) // a /clear starts a new session id
233 } catch {
234 // keep the current profiler
235 }
236 safe(() => profiler.turnStart(t, e.turnId, e.text))
237 paint($, true)
238 return next(e)
239 })
240
241 on('turn.complete', async ($, e, next) => {
242 safe(() => profiler.turnComplete(Date.now(), e.turnId, e.reason))
243 paint($, true)
244 safe(() => $.clock.after(0, () => autoExport($, options)))
245 return next(e)
246 })
247
248 on('turn.step', async function* ($, e, next) {
249 let id = ''
250 safe(() => {
251 id = profiler.stepStart(Date.now(), { turnId: e.turnId, index: e.index, model: e.model, effort: e.effort, agentId: e.agentId })
252 })
253 paint($)
254 try {
255 for await (const chunk of next(e)) {
256 safe(() => {
257 profiler.stepChunk(Date.now(), id, chunk.kind)
258 if (chunk.kind === 'stop') profiler.stepUsage(id, chunk.usage, chunk.stopReason)
259 })
260 yield chunk
261 }
262 } finally {
263 safe(() => profiler.stepEnd(Date.now(), id))
264 paint($)
265 }
266 })
267
268 on('tool.call', async ($, e, next) => {
269 anonymous += 1
270 const id = e.tool_use_id ?? `anonymous-${anonymous}`
271 const tool = String(e.tool)
272 safe(() => profiler.toolStart(Date.now(), { id, tool, agentId: e.agentId, input: summarizeInput(tool, e as unknown as Record<string, unknown>) }))
273 paint($)
274 try {
275 const answer = await next(e)
276 safe(() => profiler.toolEnd(Date.now(), id, toolOutcome(answer)))
277 return answer
278 } catch (error) {
279 safe(() => profiler.toolEnd(Date.now(), id, { denied: false, isError: true }))
280 throw error
281 } finally {
282 paint($)
283 }
284 }).catch(($, e, next) => next(e))
285
286 on('tool.check', async ($, e, next) => {
287 const verdict = await next(e)
288 const id = e.tool_use_id
289 if (id !== undefined) safe(() => profiler.toolCheck(id, verdict.decision))
290 return verdict
291 }).catch(($, e, next) => next(e))
292
293 on('classic.PostToolUse', async ($, e, next) => {
294 safe(() => profiler.toolDuration(e.tool_use_id, e.duration_ms))
295 return next(e)
296 })
297
298 on('classic.PostToolUseFailure', async ($, e, next) => {
299 safe(() => profiler.toolDuration(e.tool_use_id, e.duration_ms))
300 return next(e)
301 })
302
303 on('classic.SubagentStart', async ($, e, next) => {
304 safe(() => profiler.subagentStart(Date.now(), e.agent_id, e.agent_type))
305 paint($, true)
306 return next(e)
307 })
308
309 on('classic.SubagentStop', async ($, e, next) => {
310 safe(() => profiler.subagentStop(Date.now(), e.agent_id))
311 paint($, true)
312 return next(e)
313 })
314
315 on('session.end', async ($, e, next) => {
316 try {
317 // Files first; the POST gets what is left of the shared exit budget, 1.5s at most.
318 const budget = next.budget.remainingMs
319 const at = Date.now() + Math.max(0, Math.min(1_500, budget - 250))
320 await exportTrace($, options, 'final', { at, signal: next.signal })
321 } catch {
322 // nothing left to tell
323 }
324 return next(e)
325 })
326
327 on('command.run', { command: 'profile' }, async ($, e) => {
328 const scope = e.args.trim() === 'turn' ? 'turn' : 'session'
329 return { text: renderReport(summarize(profiler.snapshot(Date.now()), scope)) }
330 })
331
332 on('command.run', { command: 'profile-pane' }, async $ => {
333 await $.ui.open({ id: PANE, title: 'Agent Profiler' })
334 return { text: 'Agent Profiler pane opened (shown in the terminal and the desktop app).' }
335 })
336
337 on('command.run', { command: 'profile-export' }, async $ => {
338 try {
339 const r = await exportTrace($, options, 'full')
340 return { text: [...r.notes, ...r.failures].join('\n') }
341 } catch (error) {
342 return { text: `export failed: ${String(error)}` }
343 }
344 })
345
346 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
347 const { Box, Text } = $.ui.resolve(e)
348 const lines = paneLines(cachedSummary(), profiler.running(Date.now()), e.props.bodyColumns ?? 60)
349 return (
350 <Box flexDirection="column">
351 {lines.map(line => (
352 <Text bold={line.bold} dimColor={line.dim}>
353 {line.text || ' '}
354 </Text>
355 ))}
356 </Box>
357 )
358 })
359}
360hooks/core/analyze.ts 344 lines1import { classifyCommand, type BashCategory } from './classify.ts'
2import { clip, fmtMs, pct } from './format.ts'
3import { MAIN, isAgentTool, toolLabel } from './profiler.ts'
4import type { Snapshot, ToolRec } from './types.ts'
5
6export type Interval = [number, number]
7export type Category = 'model' | 'subagent' | 'tool' | 'engine'
8export type Scope = 'session' | 'turn'
9
10export type Summary = {
11 scope: Scope
12 turns: number
13 wallMs: number
14 exclusive: Record<Category, number>
15 model: {
16 requests: number
17 ttftAvg: number
18 ttftP95: number
19 thinkingMs: number
20 outputMs: number
21 inputTokens: number
22 cacheReadTokens: number
23 outputTokens: number
24 tokensPerSec: number | null
25 }
26 tools: {
27 calls: number
28 sumMs: number
29 unionMs: number
30 execMs: number
31 permissionMs: number
32 // Approval wait as a share of agent time: the union of the waits inside main turns, so at most 1.
33 permissionShare: number
34 overheadMs: number
35 errors: number
36 denied: number
37 byTool: { tool: string; calls: number; ms: number }[]
38 slowest: { tool: string; label: string; ms: number; waitMs: number; ctx: string }[]
39 }
40 // testMs sums every test run; testShare is the union of test execution inside main turns over agent time (≤ 1).
41 bash: { byCategory: { category: BashCategory; calls: number; ms: number }[]; testMs: number; testShare: number }
42 reads: { path: string; reads: number; redundant: number }[]
43 agents: { id: string; agentType: string; wallMs: number; modelMs: number; toolMs: number; requests: number; tools: number; blocking: boolean; done: boolean }[]
44 findings: string[]
45 incomplete: number
46 droppedTurns: number
47 droppedSpans: number
48}
49
50const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
51const PRIORITY: Category[] = ['model', 'subagent', 'tool']
52
53// Sorted, disjoint cover of the intervals.
54export function mergeIntervals(intervals: Interval[]): Interval[] {
55 const sorted = intervals.filter(([a, b]) => b > a).sort((x, y) => x[0] - y[0])
56 const out: Interval[] = []
57 for (const [a, b] of sorted) {
58 const last = out[out.length - 1]
59 if (last && a <= last[1]) last[1] = Math.max(last[1], b)
60 else out.push([a, b])
61 }
62 return out
63}
64
65export function unionLength(intervals: Interval[]): number {
66 return mergeIntervals(intervals).reduce((sum, [a, b]) => sum + b - a, 0)
67}
68
69// Length of (∪ intervals) ∩ (∪ windows).
70export function overlapLength(intervals: Interval[], windows: Interval[]): number {
71 const xs = mergeIntervals(intervals)
72 const ws = mergeIntervals(windows)
73 let total = 0
74 for (let i = 0, j = 0; i < xs.length && j < ws.length; ) {
75 const x = xs[i] as Interval
76 const w = ws[j] as Interval
77 total += Math.max(0, Math.min(x[1], w[1]) - Math.max(x[0], w[0]))
78 if (x[1] < w[1]) i += 1
79 else j += 1
80 }
81 return total
82}
83
84// Sweep line over the window: at every moment the highest-priority active category gets the time,
85// and moments with nothing active are engine time. O(n log n) in the number of intervals.
86export function attribute(window: Interval, tagged: { cat: Category; iv: Interval }[]): Record<Category, number> {
87 const out: Record<Category, number> = { model: 0, subagent: 0, tool: 0, engine: 0 }
88 const [w0, w1] = window
89 if (w1 <= w0) return out
90 const events: { t: number; d: number; cat: Category }[] = []
91 for (const x of tagged) {
92 const a = Math.max(w0, x.iv[0])
93 const b = Math.min(w1, x.iv[1])
94 if (b > a) events.push({ t: a, d: 1, cat: x.cat }, { t: b, d: -1, cat: x.cat })
95 }
96 events.sort((p, q) => p.t - q.t)
97 const active: Record<Category, number> = { model: 0, subagent: 0, tool: 0, engine: 0 }
98 const top = (): Category => PRIORITY.find(cat => active[cat] > 0) ?? 'engine'
99 let at = w0
100 for (const ev of events) {
101 if (ev.t > at) {
102 out[top()] += ev.t - at
103 at = ev.t
104 }
105 active[ev.cat] += ev.d
106 }
107 if (w1 > at) out[top()] += w1 - at
108 return out
109}
110
111function groupBy<T>(items: T[], key: (item: T) => string | undefined): Map<string, T[]> {
112 const out = new Map<string, T[]>()
113 for (const item of items) {
114 const k = key(item)
115 if (k === undefined) continue
116 const list = out.get(k)
117 if (list) list.push(item)
118 else out.set(k, [item])
119 }
120 return out
121}
122
123function scopeAgents(s: Snapshot, mainTools: ToolRec[], scope: Scope, toolById: Map<string, ToolRec>): Set<string> {
124 if (scope === 'session') return new Set(s.agents.map(a => a.id))
125 const toolIds = new Set(mainTools.map(t => t.id))
126 const ids = new Set<string>()
127 for (let grew = true; grew; ) {
128 grew = false
129 for (const agent of s.agents) {
130 if (ids.has(agent.id) || !agent.parentToolId) continue
131 const parent = toolById.get(agent.parentToolId)
132 if (toolIds.has(agent.parentToolId) || (parent && ids.has(parent.ctx))) {
133 ids.add(agent.id)
134 grew = true
135 }
136 }
137 }
138 return ids
139}
140
141export function summarize(s: Snapshot, scope: Scope = 'session'): Summary {
142 const end = (x: { end?: number }) => x.end ?? s.now
143 let mainTurns = s.turns.filter(t => t.ctx === MAIN).sort((a, b) => a.start - b.start)
144 if (scope === 'turn') mainTurns = mainTurns.slice(-1)
145 const turnIds = new Set(mainTurns.map(t => t.id))
146 const inScope = (turnId: string | undefined) => scope === 'session' || (turnId !== undefined && turnIds.has(turnId))
147 const mainTools = s.tools.filter(t => t.ctx === MAIN && inScope(t.turnId))
148 const toolById = new Map(s.tools.map(t => [t.id, t]))
149 const agentIds = scopeAgents(s, mainTools, scope, toolById)
150 const steps = s.steps.filter(x => (x.ctx === MAIN ? inScope(x.turnId) : agentIds.has(x.ctx)))
151 const tools = [...mainTools, ...s.tools.filter(x => x.ctx !== MAIN && agentIds.has(x.ctx))]
152 const agents = s.agents.filter(a => agentIds.has(a.id))
153
154 const stepsByTurn = groupBy(steps, x => (x.ctx === MAIN ? x.turnId : undefined))
155 const toolsByTurn = groupBy(mainTools, x => x.turnId)
156 const exclusive: Record<Category, number> = { model: 0, subagent: 0, tool: 0, engine: 0 }
157 let wallMs = 0
158 for (const turn of mainTurns) {
159 const window: Interval = [turn.start, end(turn)]
160 wallMs += window[1] - window[0]
161 const tagged = [
162 ...(stepsByTurn.get(turn.id) ?? []).map(x => ({ cat: 'model' as Category, iv: [x.start, end(x)] as Interval })),
163 ...(toolsByTurn.get(turn.id) ?? []).map(x => ({
164 cat: (isAgentTool(x.tool) && x.launchedAsync !== true ? 'subagent' : 'tool') as Category,
165 iv: [x.start, end(x)] as Interval,
166 })),
167 ]
168 const part = attribute(window, tagged)
169 for (const cat of Object.keys(part) as Category[]) exclusive[cat] += part[cat]
170 }
171
172 const ttfts = steps.filter(x => x.firstContent !== undefined).map(x => (x.firstContent as number) - x.start).sort((a, b) => a - b)
173 let thinkingMs = 0
174 let outputMs = 0
175 let inputTokens = 0
176 let cacheReadTokens = 0
177 let outputTokens = 0
178 let streamTokens = 0
179 let streamMs = 0
180 for (const x of steps) {
181 if (x.thinkFirst !== undefined && x.thinkLast !== undefined) thinkingMs += x.thinkLast - x.thinkFirst
182 if (x.outFirst !== undefined) outputMs += end(x) - x.outFirst
183 if (x.usage) {
184 inputTokens += x.usage.input_tokens + x.usage.cache_read_input_tokens + x.usage.cache_creation_input_tokens
185 cacheReadTokens += x.usage.cache_read_input_tokens
186 outputTokens += x.usage.output_tokens
187 if (x.firstContent !== undefined) {
188 streamTokens += x.usage.output_tokens
189 streamMs += end(x) - x.firstContent
190 }
191 }
192 }
193
194 const plain = tools.filter(x => !isAgentTool(x.tool))
195 const timed = plain.map(x => {
196 const wall = end(x) - x.start
197 const exec = x.execMs !== undefined ? Math.min(x.execMs, wall) : wall
198 return { x, wall, exec, wait: Math.max(0, wall - exec) }
199 })
200 const testIntervals: Interval[] = []
201 const waitIntervals: Interval[] = []
202 let execMs = 0
203 let permissionMs = 0
204 let overheadMs = 0
205 let sumMs = 0
206 const byTool = new Map<string, { tool: string; calls: number; ms: number }>()
207 const byCategory = new Map<BashCategory, { category: BashCategory; calls: number; ms: number }>()
208 for (const { x, wall, exec, wait } of timed) {
209 sumMs += wall
210 execMs += exec
211 if (x.decision === 'ask') {
212 permissionMs += wait
213 waitIntervals.push([x.start, x.start + wait])
214 } else {
215 overheadMs += wait
216 }
217 const row = byTool.get(x.tool) ?? { tool: x.tool, calls: 0, ms: 0 }
218 row.calls += 1
219 row.ms += wall
220 byTool.set(x.tool, row)
221 if (x.tool === 'Bash') {
222 const category = classifyCommand(x.input.command ?? '')
223 const cat = byCategory.get(category) ?? { category, calls: 0, ms: 0 }
224 cat.calls += 1
225 cat.ms += exec
226 byCategory.set(category, cat)
227 if (category === 'test') testIntervals.push([x.start + wait, x.start + wall])
228 }
229 }
230 const testMs = byCategory.get('test')?.ms ?? 0
231 const windows = mainTurns.map(t => [t.start, end(t)] as Interval)
232 const share = (intervals: Interval[]) => (wallMs > 0 ? overlapLength(intervals, windows) / wallMs : 0)
233
234 const seen = new Map<string, { path: string; reads: number; redundant: number; ranges: Set<string> }>()
235 for (const x of [...tools].sort((a, b) => a.start - b.start)) {
236 const path = x.input.path
237 if (!path) continue
238 if (x.tool === 'Read') {
239 const row = seen.get(path) ?? { path, reads: 0, redundant: 0, ranges: new Set<string>() }
240 const range = x.input.range ?? ''
241 if (row.ranges.has(range)) row.redundant += 1
242 row.ranges.add(range)
243 row.reads += 1
244 seen.set(path, row)
245 } else if (WRITE_TOOLS.has(x.tool)) {
246 seen.get(path)?.ranges.clear()
247 }
248 }
249
250 const stepsByCtx = groupBy(s.steps, x => x.ctx)
251 const toolsByCtx = groupBy(s.tools, x => x.ctx)
252 const summary: Summary = {
253 scope,
254 turns: mainTurns.length,
255 wallMs,
256 exclusive,
257 model: {
258 requests: steps.length,
259 ttftAvg: ttfts.length ? Math.round(ttfts.reduce((a, b) => a + b, 0) / ttfts.length) : 0,
260 ttftP95: ttfts[Math.max(0, Math.ceil(ttfts.length * 0.95) - 1)] ?? 0,
261 thinkingMs,
262 outputMs,
263 inputTokens,
264 cacheReadTokens,
265 outputTokens,
266 tokensPerSec: streamMs > 0 && streamTokens > 0 ? streamTokens / (streamMs / 1_000) : null,
267 },
268 tools: {
269 calls: plain.length,
270 sumMs,
271 unionMs: unionLength(plain.map(x => [x.start, end(x)] as Interval)),
272 execMs,
273 permissionMs,
274 permissionShare: share(waitIntervals),
275 overheadMs,
276 errors: plain.filter(x => x.isError).length,
277 denied: plain.filter(x => x.denied).length,
278 byTool: [...byTool.values()].sort((a, b) => b.ms - a.ms),
279 slowest: [...timed]
280 .sort((a, b) => b.wall - a.wall)
281 .slice(0, 5)
282 .map(({ x, wall, wait }) => ({ tool: x.tool, label: toolLabel(x), ms: wall, waitMs: wait, ctx: x.ctx })),
283 },
284 bash: { byCategory: [...byCategory.values()].sort((a, b) => b.ms - a.ms), testMs, testShare: share(testIntervals) },
285 reads: [...seen.values()]
286 .filter(r => r.reads >= 2)
287 .sort((a, b) => b.redundant - a.redundant || b.reads - a.reads)
288 .slice(0, 10)
289 .map(({ path, reads, redundant }) => ({ path, reads, redundant })),
290 agents: agents
291 .map(agent => {
292 const parent = agent.parentToolId ? toolById.get(agent.parentToolId) : undefined
293 const ownSteps = stepsByCtx.get(agent.id) ?? []
294 const ownTools = toolsByCtx.get(agent.id) ?? []
295 return {
296 id: agent.id,
297 agentType: agent.agentType,
298 wallMs: end(agent) - agent.start,
299 modelMs: unionLength(ownSteps.map(x => [x.start, end(x)] as Interval)),
300 toolMs: unionLength(ownTools.map(x => [x.start, end(x)] as Interval)),
301 requests: ownSteps.length,
302 tools: ownTools.length,
303 blocking: parent !== undefined && parent.launchedAsync !== true,
304 done: agent.end !== undefined,
305 }
306 })
307 .sort((a, b) => b.wallMs - a.wallMs),
308 findings: [],
309 incomplete: [...mainTurns, ...steps, ...tools, ...agents].filter(x => x.end === undefined).length,
310 droppedTurns: s.droppedTurns,
311 droppedSpans: s.droppedSpans,
312 }
313 summary.findings = buildFindings(summary)
314 return summary
315}
316
317function buildFindings(sum: Summary): string[] {
318 const wall = sum.wallMs
319 const out: string[] = []
320 if (wall < 1_000) return out
321 if (sum.exclusive.model / wall >= 0.7) {
322 out.push(`Waiting for the model is ${pct(sum.exclusive.model, wall)} of agent time (TTFT p95 ${fmtMs(sum.model.ttftP95)}); tools are not the bottleneck.`)
323 }
324 if (sum.bash.testShare >= 0.25) {
325 const runs = sum.bash.byCategory.find(c => c.category === 'test')?.calls ?? 0
326 out.push(`Tests took ${pct(sum.bash.testShare, 1)} of agent time (${fmtMs(sum.bash.testMs)} over ${runs} run(s)).`)
327 }
328 if (sum.tools.permissionShare >= 0.2) {
329 out.push(`Waiting for approval took ${pct(sum.tools.permissionShare, 1)} of agent time (${fmtMs(sum.tools.permissionMs)}); allow rules for repeated commands would remove it.`)
330 }
331 for (const r of sum.reads) {
332 if (r.redundant >= 2) out.push(`${clip(r.path, 80)} was read ${r.reads}× (${r.redundant}× with no edit in between).`)
333 }
334 for (const a of sum.agents) {
335 if (a.blocking && a.wallMs / wall >= 0.5) out.push(`Subagent ${a.agentType} blocked the main loop for ${pct(a.wallMs, wall)} (${fmtMs(a.wallMs)}).`)
336 }
337 const top = sum.tools.slowest[0]
338 if (top && top.ms / wall >= 0.3) out.push(`One call took ${pct(top.ms, wall)}: ${clip(top.label, 60)} (${fmtMs(top.ms)}).`)
339 if (sum.exclusive.engine / wall >= 0.25) {
340 out.push(`${pct(sum.exclusive.engine, wall)} of the time is outside model requests and tools (hooks, context building, compaction).`)
341 }
342 return out
343}
344hooks/core/export.ts 54 lines1import type { OtlpRequest, OtlpSpan } from './otlp.ts'
2import { recordKey } from './otlp.ts'
3import { MAIN } from './profiler.ts'
4import type { Snapshot } from './types.ts'
5
6// $.fs.write refuses more than 4 MiB; stay well under it.
7export const MAX_EXPORT_BYTES = Math.floor(3.5 * 1024 * 1024)
8
9// Keys of the records not exported yet: finished ones only for a per-turn batch, open ones too
10// (exported as incomplete) for the final batch at session end.
11export function pendingKeys(s: Snapshot, exported: ReadonlySet<string>, withOpen: boolean): string[] {
12 const out: string[] = []
13 const take = (key: string, finished: boolean) => {
14 if (!exported.has(key) && (withOpen || finished)) out.push(key)
15 }
16 for (const t of s.turns) if (t.ctx === MAIN) take(recordKey('turn', t.id), t.end !== undefined)
17 for (const a of s.agents) take(recordKey('agent', a.id), a.end !== undefined)
18 for (const x of s.steps) take(recordKey('step', x.id), x.end !== undefined)
19 for (const x of s.tools) take(recordKey('tool', x.id), x.end !== undefined)
20 return out
21}
22
23function byteLength(text: string): number {
24 return new TextEncoder().encode(text).length
25}
26
27// Splits a request into requests whose JSON stays within maxBytes (a single span larger than that
28// goes alone). Every part keeps the resource and scope; no spans means no parts.
29export function splitRequest(req: OtlpRequest, maxBytes: number): OtlpRequest[] {
30 const resource = req.resourceSpans[0]
31 const scope = resource?.scopeSpans[0]
32 if (!resource || !scope || scope.spans.length === 0) return []
33 const wrap = (spans: OtlpSpan[]): OtlpRequest => ({
34 resourceSpans: [{ resource: resource.resource, scopeSpans: [{ scope: scope.scope, spans }] }],
35 })
36 if (byteLength(JSON.stringify(req)) <= maxBytes) return [req]
37 const envelope = byteLength(JSON.stringify(wrap([])))
38 const parts: OtlpRequest[] = []
39 let current: OtlpSpan[] = []
40 let size = envelope
41 for (const span of scope.spans) {
42 const bytes = byteLength(JSON.stringify(span)) + 1 // the comma between spans
43 if (current.length > 0 && size + bytes > maxBytes) {
44 parts.push(wrap(current))
45 current = []
46 size = envelope
47 }
48 current.push(span)
49 size += bytes
50 }
51 if (current.length > 0) parts.push(wrap(current))
52 return parts
53}
54hooks/core/otlp.ts 210 lines1import { classifyCommand } from './classify.ts'
2import { spanId, traceIdFromSession } from './ids.ts'
3import { MAIN, isAgentTool } from './profiler.ts'
4import { redactSecrets } from './redact.ts'
5import type { Snapshot } from './types.ts'
6
7type AttrValue = { stringValue: string } | { intValue: string } | { doubleValue: number } | { boolValue: boolean }
8export type OtlpAttr = { key: string; value: AttrValue }
9export type OtlpSpan = {
10 traceId: string
11 spanId: string
12 parentSpanId?: string
13 name: string
14 kind: number
15 startTimeUnixNano: string
16 endTimeUnixNano: string
17 attributes: OtlpAttr[]
18 status: { code: number; message?: string }
19}
20export type OtlpRequest = {
21 resourceSpans: { resource: { attributes: OtlpAttr[] }; scopeSpans: { scope: { name: string; version: string }; spans: OtlpSpan[] }[] }[]
22}
23type Attrs = Record<string, string | number | boolean | undefined>
24
25export type OtlpOptions = {
26 // Which records to emit, by recordKey(); a record's phase spans (ttft, exec, wait, ...) go with it. Default: all.
27 include?: (key: string) => boolean
28 // Emit the session root span. Default true; batches leave it to the final export.
29 root?: boolean
30 // Export turn prompts and Bash command lines (redacted). Default false: both are left out.
31 commandText?: boolean
32}
33
34export type RecordKind = 'turn' | 'step' | 'tool' | 'agent'
35
36export function recordKey(kind: RecordKind, id: string): string {
37 return `${kind}:${id}`
38}
39
40const INTERNAL = 1
41const CLIENT = 3
42const STATUS_OK = 1
43const STATUS_ERROR = 2
44
45export function nanos(ms: number): string {
46 return (BigInt(Math.round(ms)) * 1_000_000n).toString()
47}
48
49function attrs(values: Attrs): OtlpAttr[] {
50 const out: OtlpAttr[] = []
51 for (const [key, v] of Object.entries(values)) {
52 if (v === undefined || v === '') continue
53 if (typeof v === 'string') out.push({ key, value: { stringValue: v } })
54 else if (typeof v === 'boolean') out.push({ key, value: { boolValue: v } })
55 else if (Number.isInteger(v)) out.push({ key, value: { intValue: String(v) } })
56 else out.push({ key, value: { doubleValue: v } })
57 }
58 return out
59}
60
61export function toOtlp(s: Snapshot, version: string, options: OtlpOptions = {}): OtlpRequest {
62 const traceId = traceIdFromSession(s.sessionId)
63 const include = options.include ?? (() => true)
64 const want = (kind: RecordKind, id: string) => include(recordKey(kind, id))
65 const text = (value: string | undefined) => (options.commandText === true && value ? redactSecrets(value) : undefined)
66 const spans: OtlpSpan[] = []
67 const end = (x: { end?: number }) => x.end ?? s.now
68 const open = (x: { end?: number }) => (x.end === undefined ? true : undefined)
69 const add = (kind: string, key: string, name: string, parent: string | undefined, start: number, stop: number, values: Attrs, extra: { spanKind?: number; error?: string } = {}): string => {
70 const id = spanId(kind, key)
71 spans.push({
72 traceId,
73 spanId: id,
74 ...(parent ? { parentSpanId: parent } : {}),
75 name,
76 kind: extra.spanKind ?? INTERNAL,
77 startTimeUnixNano: nanos(start),
78 endTimeUnixNano: nanos(Math.max(start, stop)),
79 attributes: attrs(values),
80 status: extra.error ? { code: STATUS_ERROR, message: extra.error } : { code: STATUS_OK },
81 })
82 return id
83 }
84
85 // Span ids are deterministic, so a span can name a parent emitted in another batch.
86 const root = spanId('session', s.sessionId)
87 if (options.root !== false) {
88 const sessionStart = [...s.turns, ...s.steps, ...s.tools, ...s.agents].reduce((min, x) => Math.min(min, x.start), s.started ?? s.now)
89 add('session', s.sessionId, 'claude-code session', undefined, sessionStart, s.now, {
90 'session.id': s.sessionId,
91 'agent_profiler.observability': 'observed',
92 })
93 }
94
95 const mainTurns = new Set<string>()
96 for (const t of s.turns) {
97 if (t.ctx !== MAIN) continue
98 mainTurns.add(t.id)
99 if (!want('turn', t.id)) continue
100 add('turn', t.id, 'turn', root, t.start, end(t), {
101 'agent_profiler.category': 'turn',
102 'agent_profiler.observability': 'observed',
103 'agent_profiler.turn.reason': t.reason,
104 'agent_profiler.turn.prompt': text(t.prompt),
105 'agent_profiler.incomplete': open(t),
106 })
107 }
108
109 const agents = new Set(s.agents.map(a => a.id))
110 const tools = new Map(s.tools.map(t => [t.id, t]))
111 const parentOf = (ctx: string, turnId?: string): string =>
112 ctx === MAIN
113 ? turnId !== undefined && mainTurns.has(turnId)
114 ? spanId('turn', turnId)
115 : root
116 : agents.has(ctx)
117 ? spanId('agent', ctx)
118 : root
119
120 for (const a of s.agents) {
121 if (!want('agent', a.id)) continue
122 const parentTool = a.parentToolId ? tools.get(a.parentToolId) : undefined
123 add('agent', a.id, `invoke_agent ${a.agentType}`, parentTool ? spanId('tool', parentTool.id) : root, a.start, end(a), {
124 'gen_ai.operation.name': 'invoke_agent',
125 'gen_ai.agent.id': a.id,
126 'gen_ai.agent.name': a.agentType,
127 'agent_profiler.category': 'subagent',
128 'agent_profiler.observability': 'observed',
129 'agent_profiler.async': parentTool ? parentTool.launchedAsync === true : undefined,
130 'agent_profiler.incomplete': open(a),
131 })
132 }
133
134 for (const x of s.steps) {
135 if (!want('step', x.id)) continue
136 const ttft = x.firstContent !== undefined ? x.firstContent - x.start : undefined
137 const id = add('step', x.id, `chat ${x.model}`, parentOf(x.ctx, x.turnId), x.start, end(x), {
138 'gen_ai.operation.name': 'chat',
139 'gen_ai.request.model': x.model,
140 'gen_ai.response.model': x.usage?.model,
141 'gen_ai.usage.input_tokens': x.usage?.input_tokens,
142 'gen_ai.usage.output_tokens': x.usage?.output_tokens,
143 'gen_ai.response.finish_reasons': x.stopReason,
144 'gen_ai.agent.id': x.ctx === MAIN ? undefined : x.ctx,
145 'agent_profiler.usage.cache_read_input_tokens': x.usage?.cache_read_input_tokens,
146 'agent_profiler.usage.cache_creation_input_tokens': x.usage?.cache_creation_input_tokens,
147 'agent_profiler.effort': x.effort,
148 'agent_profiler.ttft_ms': ttft,
149 'agent_profiler.category': 'model',
150 'agent_profiler.observability': 'observed',
151 'agent_profiler.incomplete': open(x),
152 }, { spanKind: CLIENT })
153 if (x.firstContent !== undefined) {
154 add('step.ttft', x.id, 'model.ttft', id, x.start, x.firstContent, {
155 'agent_profiler.observability': 'observed',
156 'agent_profiler.note': 'network + queueing + prefill, not separable from the client',
157 })
158 }
159 if (x.thinkFirst !== undefined && x.thinkLast !== undefined) {
160 add('step.thinking', x.id, 'model.thinking_stream', id, x.thinkFirst, x.thinkLast, { 'agent_profiler.observability': 'observed' })
161 }
162 if (x.outFirst !== undefined) {
163 add('step.output', x.id, 'model.output_stream', id, x.outFirst, end(x), { 'agent_profiler.observability': 'observed' })
164 }
165 }
166
167 for (const x of s.tools) {
168 if (!want('tool', x.id)) continue
169 const wall = end(x) - x.start
170 const exec = x.execMs !== undefined ? Math.min(x.execMs, wall) : undefined
171 const wait = exec !== undefined ? wall - exec : undefined
172 const id = add('tool', x.id, `execute_tool ${x.tool}`, parentOf(x.ctx, x.turnId), x.start, end(x), {
173 'gen_ai.operation.name': 'execute_tool',
174 'gen_ai.tool.name': x.tool,
175 'gen_ai.tool.call.id': x.id,
176 'gen_ai.agent.id': x.ctx === MAIN ? undefined : x.ctx,
177 // An async Agent call returns at once and does not block the turn, as in summarize().
178 'agent_profiler.category': isAgentTool(x.tool) && x.launchedAsync !== true ? 'subagent' : 'tool',
179 'agent_profiler.observability': 'observed',
180 'agent_profiler.exec_ms': exec,
181 'agent_profiler.wait_ms': wait,
182 'agent_profiler.permission': x.decision,
183 'agent_profiler.fs.path': x.input.path,
184 'agent_profiler.fs.range': x.input.range,
185 'agent_profiler.search.pattern': x.input.pattern,
186 'agent_profiler.bash.command': text(x.input.command),
187 'agent_profiler.bash.category': x.tool === 'Bash' ? classifyCommand(x.input.command ?? '') : undefined,
188 'agent_profiler.launched_agent': x.launchedAgentId,
189 'agent_profiler.incomplete': open(x),
190 }, { error: x.denied ? 'denied' : x.isError ? 'tool error' : undefined })
191 if (exec !== undefined && wait !== undefined && x.end !== undefined) {
192 if (wait > 0) {
193 add('tool.wait', x.id, x.decision === 'ask' ? 'tool.approval_wait' : 'tool.overhead', id, x.start, x.start + wait, {
194 'agent_profiler.observability': 'inferred',
195 })
196 }
197 add('tool.exec', x.id, 'tool.exec', id, x.start + wait, x.end, { 'agent_profiler.observability': 'measured' })
198 }
199 }
200
201 return {
202 resourceSpans: [
203 {
204 resource: { attributes: attrs({ 'service.name': 'claude-code', 'session.id': s.sessionId, 'agent_profiler.version': version }) },
205 scopeSpans: [{ scope: { name: 'agent-profiler', version }, spans }],
206 },
207 ],
208 }
209}
210hooks/core/profiler.ts 368 lines1import type { AgentRec, Outcome, Snapshot, StepRec, ToolInputSummary, ToolRec, TurnRec, Usage } from './types.ts'
2
3export const MAIN = 'main'
4
5export type RunningItem = { kind: 'model' | 'tool' | 'agent'; label: string; ctx: string; ms: number }
6
7export function isAgentTool(tool: string): boolean {
8 return tool === 'Agent' || tool === 'Task'
9}
10
11export function toolLabel(tool: ToolRec): string {
12 const detail = tool.input.command ?? tool.input.path ?? tool.input.pattern ?? tool.input.subagentType ?? ''
13 return detail ? `${tool.tool} ${detail}` : tool.tool
14}
15
16function text(value: unknown, n: number): string | undefined {
17 return typeof value === 'string' ? value.slice(0, n) : undefined
18}
19
20function defined<T extends object>(value: T): T {
21 return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T
22}
23
24export function summarizeInput(tool: string, input: Record<string, unknown>): ToolInputSummary {
25 switch (tool) {
26 case 'Bash':
27 return defined({ command: text(input.command, 512), description: text(input.description, 120) })
28 case 'Read': {
29 const paged = input.offset !== undefined || input.limit !== undefined
30 return defined({ path: text(input.file_path, 512), range: paged ? `${input.offset ?? ''}:${input.limit ?? ''}` : undefined })
31 }
32 case 'Write':
33 case 'Edit':
34 case 'MultiEdit':
35 return defined({ path: text(input.file_path, 512) })
36 case 'NotebookEdit':
37 return defined({ path: text(input.notebook_path, 512) })
38 case 'Glob':
39 case 'Grep':
40 return defined({ pattern: text(input.pattern, 200), path: text(input.path, 512) })
41 case 'Agent':
42 case 'Task':
43 return defined({ subagentType: text(input.subagent_type, 80), description: text(input.description, 120) })
44 default:
45 return {}
46 }
47}
48
49export function toolOutcome(answer: unknown): Outcome {
50 const value = (answer ?? {}) as { deny?: unknown; isError?: unknown; result?: unknown }
51 const result = (value.result ?? {}) as { agentId?: unknown; status?: unknown }
52 return {
53 denied: typeof value.deny === 'string',
54 isError: value.isError === true,
55 launchedAgentId: typeof result.agentId === 'string' ? result.agentId : undefined,
56 launchedAsync: result.status === 'async_launched',
57 }
58}
59
60export class Profiler {
61 sessionId: string
62 private readonly maxSpans: number
63 private readonly turns = new Map<string, TurnRec>()
64 private readonly steps = new Map<string, StepRec>()
65 private readonly tools = new Map<string, ToolRec>()
66 private readonly agents = new Map<string, AgentRec>()
67 private readonly openTurns = new Map<string, string[]>()
68 private readonly pendingExec = new Map<string, number>()
69 private readonly openSteps = new Set<string>()
70 private readonly openTools = new Set<string>()
71 private readonly openAgents = new Set<string>()
72 private droppedTurns = 0
73 private droppedSpans = 0
74 private started: number | undefined
75 // Bumped on every change, so callers can cache what they derive from a snapshot.
76 version = 0
77
78 constructor(sessionId = 'unknown', maxSpans = 20_000) {
79 this.sessionId = sessionId
80 this.maxSpans = maxSpans
81 }
82
83 // A profiler created before the session id was known (a reload mid-session) takes it on later.
84 adoptSessionId(id: string): void {
85 if (this.sessionId !== 'unknown' || !id) return
86 this.sessionId = id
87 this.version += 1
88 }
89
90 turnStart(t: number, turnId: string, prompt = '', ctx = MAIN): void {
91 if (this.turns.has(turnId)) return
92 this.turns.set(turnId, { id: turnId, ctx, start: t, prompt: prompt.slice(0, 120) })
93 this.seen(t)
94 this.stack(ctx).push(turnId)
95 this.version += 1
96 }
97
98 turnComplete(t: number, turnId: string, reason?: string): void {
99 const turn = this.turns.get(turnId)
100 if (!turn) return
101 if (turn.end === undefined) turn.end = t
102 if (reason !== undefined) turn.reason = reason
103 this.unstack(turn.ctx, turnId)
104 this.version += 1
105 }
106
107 stepStart(t: number, args: { turnId: string; index: number; model: string; effort?: string | number; agentId?: string }): string {
108 const ctx = args.agentId ?? MAIN
109 const turn = this.turns.get(args.turnId)
110 if (!turn) {
111 this.turnStart(t, args.turnId, '', ctx)
112 } else if (turn.ctx !== ctx) {
113 // The engine may raise turn.start for a subagent's loop too; the step tells us whose turn it is.
114 this.unstack(turn.ctx, turn.id)
115 turn.ctx = ctx
116 if (turn.end === undefined) this.stack(ctx).push(turn.id)
117 }
118 let id = `${args.turnId}#${args.index}`
119 for (let n = 1; this.steps.has(id); n += 1) id = `${args.turnId}#${args.index}.${n}`
120 const step: StepRec = { id, ctx, turnId: args.turnId, index: args.index, model: args.model, start: t }
121 if (args.effort !== undefined) step.effort = String(args.effort)
122 this.steps.set(id, step)
123 this.openSteps.add(id)
124 this.seen(t)
125 this.version += 1
126 this.trim()
127 return id
128 }
129
130 stepChunk(t: number, stepId: string, kind: string): void {
131 const step = this.steps.get(stepId)
132 if (!step || step.end !== undefined || kind === 'engine' || kind === 'stop') return
133 if (kind !== 'thinking' && step.outFirst !== undefined) return
134 if (step.firstContent === undefined) step.firstContent = t
135 if (kind === 'thinking') {
136 if (step.thinkFirst === undefined) step.thinkFirst = t
137 step.thinkLast = t
138 } else {
139 step.outFirst = t
140 }
141 this.version += 1
142 }
143
144 stepUsage(stepId: string, usage: Usage | null | undefined, stopReason?: string | null): void {
145 const step = this.steps.get(stepId)
146 if (!step) return
147 if (usage) step.usage = { ...usage }
148 if (stopReason) step.stopReason = stopReason
149 this.version += 1
150 }
151
152 stepEnd(t: number, stepId: string): void {
153 const step = this.steps.get(stepId)
154 if (!step || step.end !== undefined) return
155 step.end = t
156 this.openSteps.delete(stepId)
157 this.version += 1
158 }
159
160 toolStart(t: number, args: { id: string; tool: string; agentId?: string; input: ToolInputSummary }): void {
161 if (this.tools.has(args.id)) return
162 const ctx = args.agentId ?? MAIN
163 const tool: ToolRec = { id: args.id, ctx, tool: args.tool, input: args.input, start: t }
164 const turnId = this.currentTurn(ctx)
165 if (turnId !== undefined) tool.turnId = turnId
166 const exec = this.pendingExec.get(args.id)
167 if (exec !== undefined) {
168 tool.execMs = exec
169 this.pendingExec.delete(args.id)
170 }
171 this.tools.set(args.id, tool)
172 this.openTools.add(args.id)
173 this.seen(t)
174 this.version += 1
175 this.trim()
176 }
177
178 toolCheck(id: string, decision: string): void {
179 const tool = this.tools.get(id)
180 if (!tool) return
181 tool.decision = decision
182 this.version += 1
183 }
184
185 toolDuration(id: string, ms: unknown): void {
186 if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return
187 const tool = this.tools.get(id)
188 if (tool) {
189 tool.execMs = ms
190 this.version += 1
191 } else if (this.pendingExec.size < 1_000) this.pendingExec.set(id, ms)
192 }
193
194 toolEnd(t: number, id: string, outcome: Outcome): void {
195 const tool = this.tools.get(id)
196 if (!tool || tool.end !== undefined) return
197 tool.end = t
198 this.openTools.delete(id)
199 this.version += 1
200 tool.denied = outcome.denied
201 tool.isError = outcome.isError
202 if (outcome.launchedAgentId) {
203 tool.launchedAgentId = outcome.launchedAgentId
204 tool.launchedAsync = outcome.launchedAsync
205 const agent = this.agents.get(outcome.launchedAgentId)
206 if (agent) agent.parentToolId = id
207 }
208 }
209
210 subagentStart(t: number, agentId: string, agentType: string): void {
211 if (this.agents.has(agentId)) return
212 const claimed = new Set([...this.agents.values()].map(agent => agent.parentToolId))
213 let parent: ToolRec | undefined
214 for (const tool of this.tools.values()) {
215 if (!isAgentTool(tool.tool) || claimed.has(tool.id)) continue
216 if (tool.launchedAgentId !== undefined && tool.launchedAgentId !== agentId) continue
217 if (tool.end !== undefined && t - tool.end > 2_000) continue
218 if (!parent || tool.start > parent.start) parent = tool
219 }
220 const agent: AgentRec = { id: agentId, agentType, start: t }
221 if (parent) agent.parentToolId = parent.id
222 this.agents.set(agentId, agent)
223 this.openAgents.add(agentId)
224 this.seen(t)
225 this.version += 1
226 }
227
228 subagentStop(t: number, agentId: string): void {
229 const agent = this.agents.get(agentId)
230 if (!agent) return
231 if (agent.end === undefined) agent.end = t
232 this.openAgents.delete(agentId)
233 // Whatever the agent left open ended with it, so running() and the ticker settle.
234 for (const id of [...this.openSteps]) {
235 const step = this.steps.get(id)
236 if (step?.ctx === agentId) this.stepEnd(t, id)
237 }
238 for (const id of [...this.openTools]) {
239 const tool = this.tools.get(id)
240 if (tool?.ctx !== agentId) continue
241 tool.end = t
242 this.openTools.delete(id)
243 }
244 this.version += 1
245 for (const turnId of [...(this.openTurns.get(agentId) ?? [])]) this.turnComplete(t, turnId, 'agent-stop')
246 }
247
248 running(now: number): RunningItem[] {
249 // Walks only the open spans, so the once-a-second ticker stays cheap however long the session.
250 const items: RunningItem[] = []
251 for (const id of this.openSteps) {
252 const step = this.steps.get(id)
253 if (step) items.push({ kind: 'model', label: `model ${step.model}`, ctx: step.ctx, ms: now - step.start })
254 }
255 for (const id of this.openTools) {
256 const tool = this.tools.get(id)
257 if (tool) items.push({ kind: 'tool', label: toolLabel(tool), ctx: tool.ctx, ms: now - tool.start })
258 }
259 for (const id of this.openAgents) {
260 const agent = this.agents.get(id)
261 if (agent) items.push({ kind: 'agent', label: `agent ${agent.agentType}`, ctx: agent.id, ms: now - agent.start })
262 }
263 return items.sort((a, b) => b.ms - a.ms)
264 }
265
266 snapshot(now: number): Snapshot {
267 return {
268 sessionId: this.sessionId,
269 now,
270 turns: [...this.turns.values()].map(turn => ({ ...turn })),
271 steps: [...this.steps.values()].map(step => ({ ...step, ...(step.usage ? { usage: { ...step.usage } } : {}) })),
272 tools: [...this.tools.values()].map(tool => ({ ...tool, input: { ...tool.input } })),
273 agents: [...this.agents.values()].map(agent => ({ ...agent })),
274 ...(this.started !== undefined ? { started: this.started } : {}),
275 droppedTurns: this.droppedTurns,
276 droppedSpans: this.droppedSpans,
277 }
278 }
279
280 private currentTurn(ctx: string): string | undefined {
281 const open = this.openTurns.get(ctx)
282 return open && open.length > 0 ? open[open.length - 1] : undefined
283 }
284
285 private stack(ctx: string): string[] {
286 let open = this.openTurns.get(ctx)
287 if (!open) {
288 open = []
289 this.openTurns.set(ctx, open)
290 }
291 return open
292 }
293
294 private unstack(ctx: string, turnId: string): void {
295 const open = this.openTurns.get(ctx)
296 const at = open ? open.indexOf(turnId) : -1
297 if (open && at >= 0) open.splice(at, 1)
298 }
299
300 private seen(t: number): void {
301 if (this.started === undefined || t < this.started) this.started = t
302 }
303
304 // Past the cap, drop whole finished main turns, oldest first, with their requests and tools and the
305 // subagents launched from them (and those subagents' own work). Dropping a turn's spans without the
306 // turn would show their time as engine overhead.
307 private trim(): void {
308 if (this.steps.size + this.tools.size <= this.maxSpans) return
309 const target = Math.floor(this.maxSpans * 0.9)
310 const byTurn = new Map<string, { steps: string[]; tools: string[] }>()
311 const byCtx = new Map<string, { steps: string[]; tools: string[]; turns: string[] }>()
312 const slot = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
313 let value = map.get(key)
314 if (!value) {
315 value = make()
316 map.set(key, value)
317 }
318 return value
319 }
320 for (const step of this.steps.values()) {
321 if (step.ctx === MAIN) slot(byTurn, step.turnId, () => ({ steps: [], tools: [] })).steps.push(step.id)
322 else slot(byCtx, step.ctx, () => ({ steps: [], tools: [], turns: [] })).steps.push(step.id)
323 }
324 for (const tool of this.tools.values()) {
325 if (tool.ctx !== MAIN) slot(byCtx, tool.ctx, () => ({ steps: [], tools: [], turns: [] })).tools.push(tool.id)
326 else if (tool.turnId !== undefined) slot(byTurn, tool.turnId, () => ({ steps: [], tools: [] })).tools.push(tool.id)
327 }
328 for (const turn of this.turns.values()) {
329 if (turn.ctx !== MAIN) slot(byCtx, turn.ctx, () => ({ steps: [], tools: [], turns: [] })).turns.push(turn.id)
330 }
331 const launched = new Map<string, string[]>()
332 for (const agent of this.agents.values()) {
333 if (agent.parentToolId !== undefined) slot(launched, agent.parentToolId, () => [] as string[]).push(agent.id)
334 }
335
336 for (const turn of [...this.turns.values()]) {
337 if (this.steps.size + this.tools.size <= target) return
338 if (turn.ctx !== MAIN || turn.end === undefined) continue
339 const own = byTurn.get(turn.id) ?? { steps: [], tools: [] }
340 const steps = [...own.steps]
341 const tools = [...own.tools]
342 const turns = [turn.id]
343 const agents: string[] = []
344 for (let i = 0; i < tools.length; i += 1) {
345 for (const agentId of launched.get(tools[i] as string) ?? []) {
346 if (agents.includes(agentId)) continue
347 agents.push(agentId)
348 const work = byCtx.get(agentId)
349 if (!work) continue
350 steps.push(...work.steps)
351 tools.push(...work.tools)
352 turns.push(...work.turns)
353 }
354 }
355 const busy =
356 steps.some(id => this.openSteps.has(id)) || tools.some(id => this.openTools.has(id)) || agents.some(id => this.openAgents.has(id))
357 if (busy) continue
358 for (const id of steps) this.steps.delete(id)
359 for (const id of tools) this.tools.delete(id)
360 for (const id of agents) this.agents.delete(id)
361 for (const id of turns) this.turns.delete(id)
362 this.droppedTurns += 1
363 this.droppedSpans += steps.length + tools.length + agents.length
364 this.version += 1
365 }
366 }
367}
368hooks/core/report.ts 105 lines1import type { Category, Summary } from './analyze.ts'
2import { bar, clip, fmtCount, fmtMs, pct } from './format.ts'
3import type { RunningItem } from './profiler.ts'
4
5const ORDER: Category[] = ['model', 'tool', 'subagent', 'engine']
6const LABEL: Record<Category, string> = {
7 model: 'Model response wait',
8 tool: 'Tools',
9 subagent: 'Subagents (blocking)',
10 engine: 'Engine/other (inferred)',
11}
12const SHORT: Record<Category, string> = { model: 'model', tool: 'tools', subagent: 'subagents', engine: 'engine/other' }
13
14export type PaneLine = { text: string; bold?: boolean; dim?: boolean }
15
16export function renderReport(sum: Summary): string {
17 const wall = sum.wallMs
18 const lines = [`Agent Profiler · ${sum.scope === 'turn' ? 'last turn' : 'session'} · ${sum.turns} turn(s) · ${fmtMs(wall)} agent time`]
19 if (sum.turns === 0) {
20 lines.push('No turns recorded yet.')
21 return lines.join('\n')
22 }
23 if (sum.scope === 'session' && sum.droppedTurns > 0) {
24 lines.push(`Covers the last ${sum.turns} turn(s); ${sum.droppedTurns} older turn(s) dropped (already exported).`)
25 }
26 lines.push('', 'Where the time went (exclusive; adds up to agent time)')
27 for (const cat of ORDER) {
28 const ms = sum.exclusive[cat]
29 lines.push(` ${LABEL[cat].padEnd(24)} ${fmtMs(ms).padStart(7)} ${pct(ms, wall).padStart(4)} ${bar(wall ? ms / wall : 0)}`)
30 }
31
32 const m = sum.model
33 lines.push(
34 '',
35 `Model requests [observed] ${m.requests} · TTFT avg ${fmtMs(m.ttftAvg)} / p95 ${fmtMs(m.ttftP95)} · thinking stream ${fmtMs(m.thinkingMs)} · output stream ${fmtMs(m.outputMs)}`,
36 ` tokens [reported] in ${fmtCount(m.inputTokens)} (cache read ${fmtCount(m.cacheReadTokens)}) · out ${fmtCount(m.outputTokens)}${m.tokensPerSec === null ? '' : ` · ${Math.round(m.tokensPerSec)} tok/s`}`,
37 )
38
39 const t = sum.tools
40 const parallel = t.unionMs > 0 ? (t.sumMs / t.unionMs).toFixed(1) : '1.0'
41 lines.push(
42 '',
43 `Tools ${t.calls} call(s) · exec ${fmtMs(t.execMs)} [measured] · approval wait ${fmtMs(t.permissionMs)} · overhead ${fmtMs(t.overheadMs)} [inferred] · parallelism ${parallel}×${t.errors ? ` · ${t.errors} error(s)` : ''}${t.denied ? ` · ${t.denied} denied` : ''}`,
44 )
45 for (const row of t.slowest) {
46 const wait = row.waitMs >= 1_000 ? ` (wait ${fmtMs(row.waitMs)})` : ''
47 const where = row.ctx === 'main' ? '' : ` [agent ${row.ctx.slice(0, 8)}]`
48 lines.push(` ${fmtMs(row.ms).padStart(7)} ${clip(row.label, 70)}${wait}${where}`)
49 }
50
51 if (sum.bash.byCategory.length) {
52 lines.push('', 'Bash by category [measured]')
53 for (const row of sum.bash.byCategory) lines.push(` ${row.category.padEnd(8)} ${fmtMs(row.ms).padStart(7)} ${row.calls}×`)
54 }
55 if (sum.reads.length) {
56 lines.push('', 'Files read more than once')
57 for (const row of sum.reads) lines.push(` ${String(row.reads).padStart(3)}× ${clip(row.path, 70)}${row.redundant ? ` (${row.redundant}× unchanged)` : ''}`)
58 }
59 if (sum.agents.length) {
60 lines.push('', 'Subagents')
61 for (const a of sum.agents) {
62 lines.push(` ${fmtMs(a.wallMs).padStart(7)} ${a.agentType} ${a.blocking ? 'blocking' : 'background'} · model ${fmtMs(a.modelMs)} · tools ${fmtMs(a.toolMs)} · ${a.requests} req / ${a.tools} tool(s)${a.done ? '' : ' · running'}`)
63 }
64 }
65 if (sum.findings.length) {
66 lines.push('', 'Findings')
67 for (const finding of sum.findings) lines.push(` • ${finding}`)
68 }
69 lines.push(
70 '',
71 'observed = timed by this mod at event boundaries · measured = timed by Claude Code · reported = API usage · inferred = remainder.',
72 "Model time is how long the agent waited for the response, not the model's internal compute time.",
73 )
74 if (sum.incomplete) lines.push(`${sum.incomplete} span(s) still open.`)
75 return lines.join('\n')
76}
77
78export function statusLine(sum: Summary, running: RunningItem[]): string {
79 const wall = sum.wallMs
80 const parts = [`⏱ model ${pct(sum.exclusive.model, wall)}`, `tools ${pct(sum.exclusive.tool, wall)}`]
81 if (sum.exclusive.subagent > 0) parts.push(`agents ${pct(sum.exclusive.subagent, wall)}`)
82 const top = running.find(item => item.kind !== 'agent') ?? running[0]
83 if (top && top.ms >= 1_000) parts.push(`▶ ${clip(top.label, 40)} ${fmtMs(top.ms)}`)
84 return parts.join(' · ')
85}
86
87export function paneLines(sum: Summary, running: RunningItem[], width: number): PaneLine[] {
88 const w = Math.max(30, width)
89 const out: PaneLine[] = [{ text: `${fmtMs(sum.wallMs)} agent time · ${sum.turns} turn(s)`, bold: true }]
90 for (const cat of ORDER) {
91 const share = sum.wallMs ? sum.exclusive[cat] / sum.wallMs : 0
92 out.push({ text: `${SHORT[cat].padEnd(12)} ${bar(share, Math.min(20, w - 20))} ${pct(sum.exclusive[cat], sum.wallMs).padStart(4)}` })
93 }
94 out.push({ text: '' }, { text: 'Running', bold: true })
95 if (running.length === 0) out.push({ text: ' idle', dim: true })
96 for (const item of running.slice(0, 5)) out.push({ text: clip(` ${fmtMs(item.ms).padStart(6)} ${item.label}`, w) })
97 out.push({ text: '' }, { text: 'Slowest tools', bold: true })
98 for (const row of sum.tools.slowest) out.push({ text: clip(` ${fmtMs(row.ms).padStart(6)} ${row.label}`, w) })
99 if (sum.findings.length) {
100 out.push({ text: '' }, { text: 'Findings', bold: true })
101 for (const finding of sum.findings.slice(0, 4)) out.push({ text: clip(`• ${finding}`, w * 2) })
102 }
103 return out
104}
105hooks/core/classify.ts 182 lines1export type BashCategory = 'test' | 'build' | 'install' | 'git' | 'search' | 'other'
2
3// Blank out single/double quoted strings and $(...) bodies before splitting
4function blankOutStringLiterals(command: string): string {
5 let result = ''
6 let i = 0
7
8 while (i < command.length) {
9 // Single-quoted string: no escaping inside
10 if (command[i] === "'") {
11 result += "'"
12 i++
13 while (i < command.length && command[i] !== "'") {
14 result += ' '
15 i++
16 }
17 if (i < command.length) {
18 result += "'"
19 i++
20 }
21 }
22 // Double-quoted string: escaping possible
23 else if (command[i] === '"') {
24 result += '"'
25 i++
26 while (i < command.length && command[i] !== '"') {
27 if (command[i] === '\\' && i + 1 < command.length) {
28 result += ' '
29 i += 2
30 } else {
31 result += ' '
32 i++
33 }
34 }
35 if (i < command.length) {
36 result += '"'
37 i++
38 }
39 }
40 // $(...) substitution
41 else if (command[i] === '$' && i + 1 < command.length && command[i + 1] === '(') {
42 result += ' '
43 i += 2
44 let depth = 1
45 while (i < command.length && depth > 0) {
46 if (command[i] === '(') {
47 depth++
48 } else if (command[i] === ')') {
49 depth--
50 }
51 result += depth === 0 ? ')' : ' '
52 i++
53 }
54 } else {
55 result += command[i]
56 i++
57 }
58 }
59
60 return result
61}
62
63// Normalise: strip leading env vars and wrappers
64function normalise(segment: string): string {
65 if (typeof segment !== 'string') return ''
66
67 let s = segment.trim()
68 const wrappers = ['pnpm exec', 'poetry run', 'yarn dlx', 'uv run', 'npx', 'bunx', 'env', 'time', 'sudo']
69
70 let changed = true
71 while (changed) {
72 changed = false
73
74 // Strip leading env var assignments (VAR=value ...)
75 const envMatch = s.match(/^([A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/)
76 if (envMatch) {
77 s = s.slice(envMatch[0].length).trim()
78 changed = true
79 continue
80 }
81
82 // Strip leading wrappers
83 for (const wrapper of wrappers) {
84 if (s.startsWith(wrapper + ' ')) {
85 s = s.slice(wrapper.length + 1).trim()
86 changed = true
87 break
88 }
89 }
90 }
91
92 return s
93}
94
95// Classify gradle/gradlew/mvn commands by analyzing task tokens
96function classifyGradleMvn(segment: string): BashCategory {
97 const tokens = segment.split(/\s+/)
98
99 for (let i = 0; i < tokens.length; i++) {
100 const token = tokens[i] ?? ''
101
102 // Skip if it starts with - (flag or option)
103 if (token.startsWith('-')) {
104 // Special case: if this is -x and next token is test, skip both
105 if (token === '-x' && i + 1 < tokens.length && tokens[i + 1] === 'test') {
106 i++ // skip the test token too
107 }
108 continue
109 }
110
111 // Check if this is a test task
112 if (token === 'test' || token === 'verify' || token === 'check' || token.endsWith(':test')) {
113 return 'test'
114 }
115 }
116
117 return 'build'
118}
119
120const RULES: [BashCategory, RegExp][] = [
121 ['test', /^(jest|vitest|mocha|pytest|tox|nox|rspec|phpunit|ctest|nosetests|bats|karma|ava|jasmine)\b/],
122 ['test', /^cypress\s+run\b/],
123 ['test', /^playwright\s+test\b/],
124 ['test', /^(npm|bun)\s+(run\s+)?test\b/],
125 ['test', /^npm\s+t\b/],
126 ['test', /^(pnpm|yarn)\s+((-r|--filter\s+\S+)\s+)?(run\s+)?test\b/],
127 ['test', /^(go|cargo|deno|dotnet|mix|swift)\s+test\b/],
128 ['test', /^cargo\s+nextest\s+run\b/],
129 ['test', /^node\s+--test\b/],
130 ['test', /^(python|python3)\s+-m\s+(unittest|pytest)\b/],
131 ['test', /^make\s+(test|check)\b/],
132 ['test', /^claude\s+plugin\s+test\b/],
133 ['install', /^(npm|pnpm|yarn|bun)\s+(install|i|add|ci)\b/],
134 ['install', /^yarn\s*$/],
135 ['install', /^(pip|pip3)\s+install\b/],
136 ['install', /^uv\s+(pip|sync|add)\b/],
137 ['install', /^poetry\s+install\b/],
138 ['install', /^brew\s+install\b/],
139 ['install', /^(apt|apt-get)\s+install\b/],
140 ['install', /^cargo\s+(add|install|fetch)\b/],
141 ['install', /^go\s+(get|mod\s+download)\b/],
142 ['install', /^bundle\s+install\b/],
143 ['build', /^(tsc|webpack|esbuild|rollup|make|cmake|ninja|bazel)\b/],
144 ['build', /^(npm|pnpm|yarn|bun)\s+(run\s+)?build\b/],
145 ['build', /^vite\s+build\b/],
146 ['build', /^(cargo|go|swift|dotnet)\s+build\b/],
147 ['build', /^docker\s+build\b/],
148 ['git', /^(git|gh)\b/],
149 ['search', /^(rg|grep|ag|ack|find|fd|ls|tree|cat|head|tail|wc)\b/],
150]
151
152const RANK: BashCategory[] = ['test', 'build', 'install', 'git', 'search', 'other']
153
154function classifySegment(segment: string): BashCategory {
155 const normalized = normalise(segment)
156
157 // Special handling for gradle/mvn commands
158 const cmd = normalized.replace(/^\.\//, '').split(/\s+/)[0]
159 if (cmd === 'gradle' || cmd === 'gradlew' || cmd === 'mvn') {
160 return classifyGradleMvn(normalized)
161 }
162
163 return RULES.find(([, pattern]) => pattern.test(normalized))?.[0] ?? 'other'
164}
165
166export function classifyCommand(command: string): BashCategory {
167 if (typeof command !== 'string') return 'other'
168
169 const blanked = blankOutStringLiterals(command)
170 const segments = blanked
171 .split(/&&|\|\||;|\||\n|[&](?![&])/)
172 .map(s => s.trim())
173 .filter(Boolean)
174
175 let best: BashCategory = 'other'
176 for (const segment of segments) {
177 const category = classifySegment(segment)
178 if (RANK.indexOf(category) < RANK.indexOf(best)) best = category
179 }
180 return best
181}
182hooks/core/format.ts 28 lines1export function fmtMs(ms: number): string {
2 if (!Number.isFinite(ms) || ms < 0) return '-'
3 if (ms < 1_000) return `${Math.round(ms)}ms`
4 if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`
5 const minutes = Math.floor(ms / 60_000)
6 const seconds = Math.floor((ms % 60_000) / 1_000)
7 return `${minutes}m${String(seconds).padStart(2, '0')}s`
8}
9
10export function pct(part: number, whole: number): string {
11 // part * 100 first: (6050 / 10000) * 100 is 60.49999… in floating point.
12 return whole > 0 ? `${Math.round((part * 100) / whole)}%` : '0%'
13}
14
15export function clip(text: string, n: number): string {
16 const line = text.replace(/[\r\n\t]+/g, ' ')
17 return line.length <= n ? line : `${line.slice(0, Math.max(0, n - 1))}…`
18}
19
20export function bar(share: number, width = 10): string {
21 const filled = Math.max(0, Math.min(width, Math.round(share * width)))
22 return '█'.repeat(filled) + '░'.repeat(width - filled)
23}
24
25export function fmtCount(n: number): string {
26 return n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n)
27}
28hooks/core/types.ts 70 lines1export type Usage = {
2 input_tokens: number
3 output_tokens: number
4 cache_read_input_tokens: number
5 cache_creation_input_tokens: number
6 model?: string
7}
8
9export type ToolInputSummary = {
10 command?: string
11 description?: string
12 path?: string
13 range?: string
14 pattern?: string
15 subagentType?: string
16}
17
18export type TurnRec = { id: string; ctx: string; start: number; end?: number; reason?: string; prompt: string }
19
20export type StepRec = {
21 id: string
22 ctx: string
23 turnId: string
24 index: number
25 model: string
26 effort?: string
27 start: number
28 firstContent?: number
29 thinkFirst?: number
30 thinkLast?: number
31 outFirst?: number
32 end?: number
33 usage?: Usage
34 stopReason?: string
35}
36
37export type ToolRec = {
38 id: string
39 ctx: string
40 turnId?: string
41 tool: string
42 input: ToolInputSummary
43 start: number
44 end?: number
45 execMs?: number
46 decision?: string
47 isError?: boolean
48 denied?: boolean
49 launchedAgentId?: string
50 launchedAsync?: boolean
51}
52
53export type AgentRec = { id: string; agentType: string; start: number; end?: number; parentToolId?: string }
54
55export type Snapshot = {
56 sessionId: string
57 now: number
58 turns: TurnRec[]
59 steps: StepRec[]
60 tools: ToolRec[]
61 agents: AgentRec[]
62 // Earliest start ever recorded, including spans dropped since.
63 started?: number
64 // Whole main turns dropped past the span cap, and the requests, tools and agents that went with them.
65 droppedTurns: number
66 droppedSpans: number
67}
68
69export type Outcome = { denied: boolean; isError: boolean; launchedAgentId?: string; launchedAsync?: boolean }
70hooks/core/ids.ts 25 lines1const FNV_OFFSET = 0xcbf29ce484222325n
2const FNV_PRIME = 0x100000001b3n
3const MASK = 0xffffffffffffffffn
4
5export function fnv1a64(text: string): string {
6 let hash = FNV_OFFSET
7 for (const byte of new TextEncoder().encode(text)) {
8 hash ^= BigInt(byte)
9 hash = (hash * FNV_PRIME) & MASK
10 }
11 return hash.toString(16).padStart(16, '0')
12}
13
14// OTLP rejects an all-zero span id.
15export function spanId(kind: string, key: string): string {
16 const id = fnv1a64(`${kind}:${key}`)
17 return id === '0000000000000000' ? '0000000000000001' : id
18}
19
20export function traceIdFromSession(sessionId: string): string {
21 const hex = sessionId.replace(/-/g, '').toLowerCase()
22 if (/^[0-9a-f]{32}$/.test(hex) && !/^0+$/.test(hex)) return hex
23 return fnv1a64(`trace:${sessionId}`) + fnv1a64(`trace2:${sessionId}`)
24}
25hooks/core/redact.ts 15 lines1// Best-effort removal of obvious secrets from command lines and prompts before they leave the
2// machine. It catches common shapes only; exporting command text stays opt-in (exportCommandText).
3const VALUE = String.raw`("[^"]*"|'[^']*'|[^\s'"]+)`
4const RULES: [RegExp, string][] = [
5 [/(\bauthorization\s*:\s*)[^'"\n]+/gi, '$1[REDACTED]'],
6 [/(\bbearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[REDACTED]'],
7 [new RegExp(String.raw`(--password(?:=|\s+))` + VALUE, 'gi'), '$1[REDACTED]'],
8 [/(\bmysql\w*\b[^\n;&|]*?\s-p)(?=\S)("[^"]*"|'[^']*'|\S+)/g, '$1[REDACTED]'],
9 [new RegExp(String.raw`\b([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)=` + VALUE, 'gi'), '$1=[REDACTED]'],
10]
11
12export function redactSecrets(text: string): string {
13 return RULES.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), text)
14}
15