Reads the subagents, agent teams and Workflows the session launched from its transcript files and draws them in a docked panel as a flow diagram, agent details…

English | 한국어
Draws the subagents, agent teams, Workflows, and background shells the session launched in the /trace docked panel, as a flow diagram, agent details, and a file access list.
The panel text and command replies are in English or Korean. See Language. UI labels below are quoted as the English UI shows them.
claude plugin marketplace add YeomYuJun/claude-mods
claude plugin install agent-trace@claude-mods
The full steps are in the repository README.
/trace opens and closes the panel. In the fullscreen layout with a terminal of 144 columns or more, it opens by itself when the main session's first Agent, Task, or Workflow call finishes. If you closed it yourself in this session, it does not reopen (it does again after /clear).
d changes node density.Opens when you click a node.
Stop agent button stops a running agent or an idle team member.Copy transcript path button copies the path of that agent's transcript file.Esc goes back to the flow, and pressing it again closes the panel.Possible conflict.The main session is read from $.session; subagents and Workflows are read from session transcript files under the config folder (CLAUDE_CONFIG_DIR, or ~/.claude if unset). Transcript files larger than 4 MiB are not read, and the node gets transcript too large, summary only.
The panel text, the /trace description and the command replies follow the plugin's language option: auto (default), en or ko. Change it in /config; the plugin reloads with the new value.
auto takes the first of these that names Korean or English, and falls back to English:
language key in the settings files, if one is set ("korean", "english"). The Claude Code settings reference does not list this keyLC_ALL environment variableLANG environment variable (ko_KR.UTF-8)On Windows LC_ALL and LANG are usually unset, so without such a key the plugin shows English. A change to the settings key takes effect in the next session.
claude plugin test "<repo-path>/agent-trace"
claude plugin validate "<repo-path>/agent-trace"hooks/register.ts 276 lines1import type { AgentInfo, EngineInterface, FsEntry, On, PluginOptions, SessionMessage, Timer } from 'claude-code'
2
3import Collect from './collect'
4import Messages, { type Language } from './messages'
5import type { StopNote, TraceModel } from './model'
6import Names from './names'
7import PaneState, { type PaneAction } from './pane-state'
8import Reader, { type ReadCache } from './reader'
9import Views from './views'
10
11type Host = {
12 now: () => Promise<number>
13 every: (ms: number, fn: () => void) => Timer
14 envConfigDir: () => Promise<string | undefined>
15 envUserProfile: () => Promise<string | undefined>
16 envHome: () => Promise<string | undefined>
17 sessionId: () => Promise<string>
18 messages: () => Promise<SessionMessage[]>
19 usage: () => Promise<{ context: { tokens?: number } }>
20 model: () => Promise<string | undefined>
21 agents: () => Promise<AgentInfo[]>
22 fsExists: (path: string) => Promise<boolean>
23 fsList: (path: string) => Promise<FsEntry[]>
24 fsRead: (path: string) => Promise<string>
25 invalidate: () => void
26 taskStop: (target: string, label: string) => Promise<StopNote>
27 openDocked: () => Promise<void>
28}
29
30// TaskStop 은 결과를 { message, task_id, ... } 로 주고, 엔진의 text 는 그 JSON 문자열이다.
31function messageOf(value: unknown): string | undefined {
32 if (typeof value === 'string') {
33 try {
34 return messageOf(JSON.parse(value) as unknown) ?? value
35 } catch {
36 return value
37 }
38 }
39 if (value && typeof value === 'object') {
40 const message = Reflect.get(value, 'message')
41 return typeof message === 'string' ? message : undefined
42 }
43 return undefined
44}
45
46// userConfig 의 language 가 auto 면 settings 의 language, LC_ALL, LANG 순으로 본다. $ 는 훅 안에만 있어 session.start 에서 부른다
47async function languageIn($: Pick<EngineInterface, 'settings' | 'env'>, option: unknown): Promise<Language> {
48 if (option === 'en' || option === 'ko') return option
49 const valueOf = async (read: () => Promise<unknown>) => {
50 try {
51 return await read()
52 } catch {
53 return undefined
54 }
55 }
56 return Messages.languageOf(option, [
57 await valueOf(async () => (await $.settings.read()).language),
58 await valueOf(() => $.env.get('LC_ALL')),
59 await valueOf(() => $.env.get('LANG')),
60 ])
61}
62
63// 엔진이 기록한 이 플러그인의 열린 패널. 모듈을 다시 불러와도(언어 변경 등) 패널은 떠 있고 이 기록에 남는다
64async function isPaneUp($: Pick<EngineInterface, 'ui'>, id: string): Promise<boolean> {
65 try {
66 return (await $.ui.panes()).some(pane => pane.id === id)
67 } catch {
68 return false
69 }
70}
71
72export function register(on: On, options: PluginOptions) {
73 let messages = Messages.messagesOf(Messages.languageOf(options.language))
74 let host: Host | undefined
75 let isOpen = false
76 let sessionDir: string | undefined
77 let cwd = ''
78 let state = PaneState.INITIAL
79 let maxTop = 0
80 let model: TraceModel | undefined
81 let poll: Timer | undefined
82 let hint: { isFullscreen: boolean; columns: number } | undefined
83 let closedByPerson = false
84 const cache: ReadCache = new Map()
85 const firstSeen = new Map<string, number>()
86 const nowTools = new Map<string, string>()
87 const stopNotes = new Map<string, StopNote>()
88 const stopped = new Map<string, number>()
89
90 async function dirOf(h: Host): Promise<string> {
91 const configDir = await h.envConfigDir()
92 const profile = (await h.envUserProfile()) ?? (await h.envHome()) ?? ''
93 return Reader.sessionDirOf({ configDir: configDir ?? `${profile}/.claude`, cwd, sessionId: await h.sessionId() })
94 }
95
96 async function refresh(h: Host) {
97 sessionDir ??= await dirOf(h)
98 const [messages, usage, sessionModel, live, now] = await Promise.all([
99 h.messages(), h.usage(), h.model(), h.agents(), h.now(),
100 ])
101 const rootId = await h.sessionId()
102 const read = await Reader.readSession({ exists: path => h.fsExists(path), list: path => h.fsList(path), read: path => h.fsRead(path) }, sessionDir, cache)
103 model = Collect.traceOf({
104 rootId, rootLabel: 'main', model: sessionModel, contextTokens: usage.context.tokens,
105 main: Collect.mainOf({ rootId, messages, firstSeen, now }),
106 agents: read.agents, workflows: read.workflows, live, nowTools, stopped, now,
107 })
108 h.invalidate()
109 const isRunning = model.nodes.some(node => node.kind !== 'session' && node.kind !== 'team' && node.status === 'running')
110 if (isRunning && !poll) poll = h.every(Names.POLL_MS, () => { if (host) void refresh(host) })
111 if (!isRunning && poll) { poll.cancel(); poll = undefined }
112 }
113
114 function withNowTools(current: TraceModel): TraceModel {
115 return {
116 ...current,
117 nodes: current.nodes.map(node => {
118 const withTool = node.status === 'running' && nowTools.has(node.id) ? { ...node, nowTool: nowTools.get(node.id) } : node
119 const stopNote = stopNotes.get(node.id)
120 return stopNote ? { ...withTool, stopNote } : withTool
121 }),
122 }
123 }
124
125 async function stop(id: string) {
126 const node = model?.nodes.find(item => item.id === id)
127 if (!host || !node) return
128 // 팀원을 기록 파일 이름의 id 로 부르면 TaskStop 이 검증 오류를 낸다. 이름이 있으면 이름으로 부른다.
129 const note = await host.taskStop(node.name ?? id, node.label)
130 stopNotes.set(id, note)
131 if (!note.isError) stopped.set(id, await host.now())
132 await refresh(host)
133 }
134
135 function act(action: PaneAction) {
136 state = PaneState.reduce(state, action)
137 host?.invalidate()
138 }
139
140 function reset() {
141 isOpen = false
142 state = PaneState.INITIAL
143 model = undefined
144 sessionDir = undefined
145 cache.clear()
146 firstSeen.clear()
147 nowTools.clear()
148 stopNotes.clear()
149 stopped.clear()
150 closedByPerson = false
151 poll?.cancel()
152 poll = undefined
153 }
154
155 on('session.start', async ($, e, next) => {
156 cwd = e.cwd
157 host = {
158 now: () => $.clock.now(),
159 every: (ms, fn) => $.clock.every(ms, fn),
160 envConfigDir: () => $.env.get('CLAUDE_CONFIG_DIR'),
161 envUserProfile: () => $.env.get('USERPROFILE'),
162 envHome: () => $.env.get('HOME'),
163 sessionId: () => $.session.id(),
164 messages: () => $.session.messages(),
165 usage: () => $.session.usage(),
166 model: () => $.session.model(),
167 agents: () => $.agent.list(),
168 fsExists: path => $.fs.exists(path),
169 fsList: path => $.fs.list(path),
170 fsRead: path => $.fs.read(path),
171 invalidate: () => $.ui.invalidate('ui.render'),
172 openDocked: async () => { await $.ui.open({ id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true }) },
173 taskStop: async (target, label) => {
174 const result = await $.tool.call({ tool: 'TaskStop', task_id: target, consent: `The user pressed "Stop agent" for ${label} in the agent-trace panel` })
175 if (result.deny !== undefined) return { text: result.deny, isError: true }
176 const text = messageOf(result.result) ?? messageOf(result.text) ?? ''
177 return { text: text.trim().split('\n')[0] ?? '', isError: result.isError === true }
178 },
179 }
180 messages = Messages.messagesOf(await languageIn($, options.language))
181 await $.command.register({ name: Names.COMMAND_NAME, description: messages.commandDescription })
182 // 다시 불러온 모듈은 패널을 닫힌 것으로 안다. 패널이 떠 있으면 열린 상태로 되돌리고 기록을 다시 읽는다
183 if (await isPaneUp($, Names.PANE_ID)) {
184 isOpen = true
185 closedByPerson = false
186 if (host) await refresh(host)
187 $.ui.invalidate('ui.render')
188 }
189 return next(e)
190 })
191
192 on('command.run', { command: Names.COMMAND_NAME }, async ($, e) => {
193 if (isOpen) {
194 await $.ui.close({ id: Names.PANE_ID })
195 isOpen = false
196 closedByPerson = true
197 poll?.cancel()
198 poll = undefined
199 return { text: messages.panelHidden }
200 }
201 if (host) await refresh(host)
202 const docked = e.presentation?.isFullscreen === true
203 await $.ui.open(docked
204 ? { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true }
205 : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true, focus: true, closeOnEscape: true, rows: 24 })
206 isOpen = true
207 closedByPerson = false
208 return { text: messages.panelShown }
209 })
210
211 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
212 const result = await next(e)
213 if (isOpen) await $.ui.close({ id: Names.PANE_ID })
214 reset()
215 return result
216 })
217
218 on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
219 if (state.detail !== undefined && e.origin.kind === 'person') {
220 act({ type: 'back' })
221 return { deny: messages.backToFlow }
222 }
223 const result = await next(e)
224 if (result.deny === undefined) {
225 if (e.origin.kind === 'person') closedByPerson = true
226 isOpen = false
227 poll?.cancel()
228 poll = undefined
229 }
230 return result
231 })
232
233 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
234 if (e.requestId !== Names.PANE_ID || !model) return next(e)
235 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
236 const now = await $.clock.now()
237 const view = Views.paneView({
238 kit: { Box, Text, Button, Markdown }, messages, model: withNowTools(model), state, width: e.props.bodyColumns, rows: e.props.scroll.bodyRows,
239 now, act, canStop: true, stop: id => { void stop(id) }, copy: text => { void $.ui.copy({ text, surface: e.surface }) },
240 })
241 maxTop = Math.max(0, view.maxTop)
242 return view.tree
243 })
244
245 on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => {
246 if (e.origin.kind !== 'person' || state.detail !== undefined || state.tab !== 'flow') return next(e)
247 act({ type: 'scroll', by: e.by, max: maxTop })
248 return {}
249 })
250
251 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
252 // 값이 빠진 그리기가 앞서 잰 전체 화면 여부를 지우지 않게 한다. 패널을 붙이는 곳은 터미널이다.
253 if (e.surface === 'terminal' && e.viewport) hint = { isFullscreen: e.viewport.isFullscreen ?? hint?.isFullscreen ?? false, columns: e.viewport.columns }
254 return next(e)
255 })
256
257 on('tool.call', async ($, e, next) => {
258 if (e.agentId) nowTools.set(e.agentId, e.tool)
259 try {
260 return await next(e)
261 } finally {
262 if (e.agentId && nowTools.get(e.agentId) === e.tool) nowTools.delete(e.agentId)
263 if (isOpen && !e.agentId && Names.REFRESH_TOOLS.includes(e.tool) && host) void refresh(host)
264 if (isOpen && e.agentId && !poll && host) void refresh(host)
265 const shouldOpen = !isOpen && !closedByPerson && !e.agentId && Names.AUTO_OPEN_TOOLS.includes(e.tool)
266 && hint?.isFullscreen === true && hint.columns >= Names.AUTO_OPEN_COLUMNS
267 if (shouldOpen && host) {
268 isOpen = true
269 // 이 tool.call 이 끝난 뒤에 여는 일이라 세션 시작 때 받은 $ 로 연다.
270 const opening = host
271 void refresh(opening).then(() => opening.openDocked()).catch(() => { isOpen = false })
272 }
273 }
274 })
275}
276hooks/collect/index.ts 7 lines1export * from './rows.js'
2export * from './agent-of.js'
3export * from './workflow-of.js'
4export * from './main-of.js'
5export * from './model-of.js'
6export * as default from '.'
7hooks/messages/index.ts 4 lines1export * from './types.js'
2export * from './language.js'
3export * as default from '.'
4hooks/model/index.ts 4 lines1export * from './types.js'
2export * from './status-of.js'
3export * as default from '.'
4hooks/names/index.ts 20 lines1export const PLUGIN_NAME = 'agent-trace'
2export const PANE_ID = 'trace'
3export const COMMAND_NAME = 'trace'
4export const PANE_TITLE = 'agent trace'
5
6export const POLL_MS = 2000
7export const DONE_IDLE_MS = 60_000
8export const MAX_READ_BYTES = 4 * 1024 * 1024
9export const COMPACT_OVER = 20
10export const MIN_COLUMN = 24
11export const GAP = 3
12export const INDENT = 4
13
14export const AUTO_OPEN_COLUMNS = 144
15export const AUTO_OPEN_TOOLS: readonly string[] = ['Agent', 'Task', 'Workflow']
16
17export const REFRESH_TOOLS: readonly string[] = ['Agent', 'Task', 'Workflow', 'SendMessage', 'TaskStop', 'Bash', 'PowerShell']
18
19export * as default from '.'
20hooks/pane-state/index.ts 3 lines1export * from './pane-state.js'
2export * as default from '.'
3hooks/reader/index.ts 4 lines1export * from './session-dir-of.js'
2export * from './read-session.js'
3export * as default from '.'
4hooks/views/index.ts 6 lines1export * from './flow-view.jsx'
2export * from './files-view.jsx'
3export * from './detail-view.jsx'
4export * from './pane-view.jsx'
5export * as default from '.'
6hooks/collect/rows.ts 92 lines1import type { FileOp } from '../model'
2
3export type Row = Record<string, unknown>
4export type Block = {
5 type?: string
6 id?: string
7 name?: string
8 input?: Record<string, unknown>
9 tool_use_id?: string
10 is_error?: boolean
11 text?: string
12}
13
14export function parseLines(text: string): Row[] {
15 const body = text.replace(/^/, '')
16 const lines = body.split('\n')
17 const complete = body.endsWith('\n') ? lines : lines.slice(0, -1)
18 const rows: Row[] = []
19 for (const line of complete) {
20 const trimmed = line.trim()
21 if (!trimmed) continue
22 try {
23 const value: unknown = JSON.parse(trimmed)
24 if (value && typeof value === 'object' && !Array.isArray(value)) rows.push(value as Row)
25 } catch {
26 // 기록 중인 줄이 아니라 깨진 줄이다. 그 줄만 버린다.
27 }
28 }
29 return rows
30}
31
32function messageOf(row: Row): Record<string, unknown> | undefined {
33 const message = row.message
34 return message && typeof message === 'object' ? message as Record<string, unknown> : undefined
35}
36
37export function blocksOf(row: Row): Block[] {
38 const content = messageOf(row)?.content
39 return Array.isArray(content) ? content.filter(block => block && typeof block === 'object') as Block[] : []
40}
41
42export function plainTextOf(row: Row): string | undefined {
43 const content = messageOf(row)?.content
44 if (typeof content === 'string') return content
45 const texts = blocksOf(row).filter(block => block.type === 'text' && typeof block.text === 'string').map(block => block.text)
46 return texts.length > 0 ? texts.join('\n') : undefined
47}
48
49export function atOf(row: Row): number | undefined {
50 const stamp = row.timestamp
51 if (typeof stamp !== 'string') return undefined
52 const at = Date.parse(stamp)
53 return Number.isNaN(at) ? undefined : at
54}
55
56export function usageTotalOf(row: Row): number | undefined {
57 const usage = messageOf(row)?.usage
58 if (!usage || typeof usage !== 'object') return undefined
59 const read = (key: string) => {
60 const value = (usage as Record<string, unknown>)[key]
61 return typeof value === 'number' ? value : 0
62 }
63 return read('input_tokens') + read('output_tokens') + read('cache_read_input_tokens') + read('cache_creation_input_tokens')
64}
65
66export function modelOf(row: Row): string | undefined {
67 const model = messageOf(row)?.model
68 return typeof model === 'string' ? model : undefined
69}
70
71const SUMMARY_KEYS = ['file_path', 'notebook_path', 'command', 'description', 'pattern', 'url', 'query', 'prompt']
72
73export function summaryOf(input: Record<string, unknown> | undefined): string {
74 if (!input) return ''
75 for (const key of SUMMARY_KEYS) {
76 const value = input[key]
77 if (typeof value === 'string' && value.trim()) return value.trim().split('\n')[0] ?? ''
78 }
79 return ''
80}
81
82const FILE_OPS: Record<string, FileOp> = { Read: 'read', Edit: 'edit', NotebookEdit: 'edit', Write: 'write' }
83
84export function fileOpOf(tool: string): FileOp | undefined {
85 return FILE_OPS[tool]
86}
87
88export function filePathOf(input: Record<string, unknown> | undefined): string | undefined {
89 const path = input?.file_path ?? input?.notebook_path
90 return typeof path === 'string' ? path : undefined
91}
92hooks/collect/agent-of.ts 105 lines1import Names from '../names'
2import type { FileAccess, ToolCall, TraceNode } from '../model'
3import { atOf, blocksOf, fileOpOf, filePathOf, modelOf, plainTextOf, summaryOf, usageTotalOf, type Row } from './rows.js'
4
5export type AgentMeta = {
6 agentType?: string
7 description?: string
8 name?: string
9 toolUseId?: string
10 spawnDepth?: number
11 parentAgentId?: string
12 requestShape?: string
13 model?: string
14 taskKind?: string
15 teamName?: string
16 stoppedByUser?: boolean
17}
18
19export type AgentFacts = { node: TraceNode; files: FileAccess[]; agentCallIds: string[]; sends: string[] }
20
21const AGENT_TOOLS = new Set(['Agent', 'Task'])
22const TEAMMATE_MESSAGE = /^\s*<teammate-message\b[^>]*>\s*([\s\S]*?)\s*<\/teammate-message>\s*$/
23
24export function promptOf(text: string): string {
25 return TEAMMATE_MESSAGE.exec(text)?.[1] ?? text
26}
27
28export function agentOf(input: { id: string; meta: AgentMeta; rows: Row[]; transcriptPath: string; now: number; tooLarge?: boolean }): AgentFacts {
29 const { id, meta, rows, transcriptPath, now } = input
30 const calls = new Map<string, ToolCall>()
31 const files: FileAccess[] = []
32 const agentCallIds: string[] = []
33 const sends: string[] = []
34 let prompt: string | undefined
35 let model = meta.model
36 let tokens: number | undefined
37 let startedAt: number | undefined
38 let endedAt: number | undefined
39 let lastWasFinalText = false
40
41 for (const row of rows) {
42 const at = atOf(row)
43 if (at !== undefined) {
44 startedAt ??= at
45 endedAt = at
46 }
47 if (row.type === 'user' && prompt === undefined) {
48 const text = plainTextOf(row)
49 if (text !== undefined && blocksOf(row).every(block => block.type !== 'tool_result')) prompt = promptOf(text)
50 }
51 if (row.type === 'assistant') {
52 model = modelOf(row) ?? model
53 tokens = usageTotalOf(row) ?? tokens
54 const uses = blocksOf(row).filter(block => block.type === 'tool_use')
55 lastWasFinalText = uses.length === 0
56 for (const use of uses) {
57 const name = use.name ?? '?'
58 const filePath = filePathOf(use.input)
59 calls.set(use.id ?? `${calls.size}`, { id: use.id ?? `${calls.size}`, name, summary: summaryOf(use.input), at, ...(filePath ? { filePath } : {}) })
60 const op = fileOpOf(name)
61 if (op && filePath) files.push({ path: filePath, nodeId: id, op, at })
62 if (AGENT_TOOLS.has(name) && use.id) agentCallIds.push(use.id)
63 if (name === 'SendMessage' && typeof use.input?.to === 'string') sends.push(use.input.to)
64 }
65 }
66 if (row.type === 'user') {
67 for (const block of blocksOf(row)) {
68 if (block.type !== 'tool_result' || !block.tool_use_id) continue
69 const call = calls.get(block.tool_use_id)
70 if (call) call.ok = block.is_error !== true
71 }
72 lastWasFinalText = false
73 }
74 }
75
76 const toolCalls = [...calls.values()]
77 const pending = [...toolCalls].reverse().find(call => call.ok === undefined)
78 const isIdle = endedAt !== undefined && now - endedAt > Names.DONE_IDLE_MS
79 const isTeammate = meta.taskKind === 'in_process_teammate'
80 // 팀원은 답을 낸 뒤에도 끝나지 않고 다음 메시지를 기다린다. 마지막 줄이 답이면 대기다.
81 const status = meta.stoppedByUser ? 'stopped' : !lastWasFinalText ? 'running' : isTeammate ? 'idle' : isIdle ? 'done' : 'running'
82
83 const node: TraceNode = {
84 id,
85 kind: isTeammate ? 'teammate' : 'subagent',
86 label: meta.description ?? meta.name ?? id,
87 ...(meta.name ? { name: meta.name } : {}),
88 depth: meta.spawnDepth ?? 1,
89 status,
90 agentType: meta.agentType,
91 model,
92 runMode: meta.requestShape === 'background' ? 'background' : 'foreground',
93 tokens,
94 nowTool: status === 'running' ? pending?.name : undefined,
95 prompt,
96 toolCalls,
97 startedAt,
98 endedAt,
99 transcriptPath,
100 toolUseId: meta.toolUseId,
101 ...(input.tooLarge ? { note: 'too-large' as const } : {}),
102 }
103 return { node, files, agentCallIds, sends }
104}
105hooks/collect/workflow-of.ts 73 lines1import { statusOf, type TraceNode } from '../model'
2import type { Row } from './rows.js'
3
4type Progress = {
5 type?: string
6 index?: number
7 title?: string
8 label?: string
9 phaseIndex?: number
10 agentId?: string
11 model?: string
12 state?: string
13 startedAt?: number
14 lastToolName?: string
15 promptPreview?: string
16}
17
18const text = (value: unknown) => typeof value === 'string' ? value : undefined
19const number = (value: unknown) => typeof value === 'number' ? value : undefined
20
21export function workflowOf(input: { json: Row; parentId: string; now: number }): TraceNode[] {
22 const { json, parentId } = input
23 const runId = text(json.runId) ?? 'workflow'
24 const progress = (Array.isArray(json.workflowProgress) ? json.workflowProgress : []) as Progress[]
25 const agents = progress.filter(item => item.type === 'workflow_agent' && item.agentId)
26 const phaseCount = Array.isArray(json.phases) ? json.phases.length : progress.filter(item => item.type === 'workflow_phase').length
27 const phasesDone = new Set<number>()
28 for (const phase of new Set(agents.map(agent => agent.phaseIndex ?? 0))) {
29 const inPhase = agents.filter(agent => (agent.phaseIndex ?? 0) === phase)
30 if (inPhase.length > 0 && inPhase.every(agent => statusOf(agent.state) === 'done')) phasesDone.add(phase)
31 }
32 const startedAt = number(json.startTime)
33 const duration = number(json.durationMs)
34 const status = statusOf(text(json.status)) ?? 'running'
35
36 const flow: TraceNode = {
37 id: runId,
38 kind: 'workflow',
39 label: text(json.workflowName) ?? runId,
40 parentId,
41 depth: 1,
42 status,
43 rawStatus: text(json.status),
44 model: text(json.defaultModel),
45 tokens: number(json.totalTokens),
46 toolCalls: [],
47 startedAt,
48 endedAt: status !== 'running' && startedAt !== undefined && duration ? startedAt + duration : undefined,
49 phase: { done: phasesDone.size, total: phaseCount },
50 }
51
52 const children = agents.map((agent): TraceNode => {
53 const agentStatus = statusOf(agent.state) ?? 'running'
54 return {
55 id: agent.agentId ?? `${runId}:${agent.index}`,
56 kind: 'workflowAgent',
57 label: agent.label ?? agent.agentId ?? '?',
58 parentId: runId,
59 depth: 2,
60 status: agentStatus,
61 rawStatus: agent.state,
62 model: agent.model,
63 runMode: 'background',
64 nowTool: agentStatus === 'running' ? agent.lastToolName : undefined,
65 prompt: agent.promptPreview,
66 toolCalls: [],
67 startedAt: agent.startedAt,
68 }
69 })
70
71 return [flow, ...children]
72}
73hooks/collect/main-of.ts 52 lines1import type { SessionMessage } from 'claude-code'
2
3import { statusOf, type FileAccess, type TraceNode } from '../model'
4import { fileOpOf, filePathOf, summaryOf } from './rows.js'
5
6export type MainFacts = { shells: TraceNode[]; notified: Map<string, string>; files: FileAccess[] }
7
8const SHELLS = new Set(['Bash', 'PowerShell'])
9const NOTIFICATION = /<task-notification>[\s\S]*?<\/task-notification>/g
10const TOOL_USE_ID = /<tool-use-id>([^<]+)<\/tool-use-id>/
11const STATUS = /<status>([^<]+)<\/status>/
12
13export function mainOf(input: { rootId: string; messages: SessionMessage[]; firstSeen: Map<string, number>; now: number }): MainFacts {
14 const { rootId, messages, firstSeen, now } = input
15 const notified = new Map<string, string>()
16 for (const message of messages) {
17 if (message.role !== 'user') continue
18 for (const block of message.text.match(NOTIFICATION) ?? []) {
19 const id = TOOL_USE_ID.exec(block)?.[1]
20 const status = STATUS.exec(block)?.[1]
21 if (id && status) notified.set(id.trim(), status.trim())
22 }
23 }
24
25 const shells: TraceNode[] = []
26 const files: FileAccess[] = []
27 for (const message of messages) {
28 for (const use of message.toolUses) {
29 if (SHELLS.has(use.tool) && use.input.run_in_background === true) {
30 if (!firstSeen.has(use.tool_use_id)) firstSeen.set(use.tool_use_id, now)
31 const raw = notified.get(use.tool_use_id)
32 shells.push({
33 id: use.tool_use_id,
34 kind: 'shell',
35 label: summaryOf(use.input),
36 parentId: rootId,
37 depth: 1,
38 status: statusOf(raw) ?? 'running',
39 rawStatus: raw,
40 toolCalls: [],
41 startedAt: firstSeen.get(use.tool_use_id),
42 toolUseId: use.tool_use_id,
43 })
44 }
45 const op = fileOpOf(use.tool)
46 const path = filePathOf(use.input)
47 if (op && path) files.push({ path, nodeId: rootId, op })
48 }
49 }
50 return { shells, notified, files }
51}
52