A side pane (/ops) with this session's subagents and background jobs, live, and a sound and a toast as each one ends.

Boczny panel z subagentami i zadaniami w tle bieżącej sesji, na żywo: status, czas, liczba wywołań narzędzi, zajęty kontekst i ostatnia czynność. Gdy coś się skończy, pojawia się powiadomienie i krótki dźwięk.
/ops otwiera i zamyka panel.1 do 9 otwierają szczegóły wiersza: u agenta jego zadanie, oś czasu wywołań narzędzi z wynikami, ostatnie wiadomości i raport, a u zadania w tle polecenie i końcówkę jego wyjścia.b wraca do listy, p i n przechodzą do poprzedniego i następnego, f rozwija cały prompt.c chowa zakończone wiersze.sounds/done.wav) gra przez API modów. W terminalu na macOS działa od razu. W terminalu na Linuksie i Windows Claude Code nie ma odtwarzacza, więc zostaje samo powiadomienie.Wymaga Claude Code 2.1.287+ w terminalu albo 2.1.286+ w aplikacji Desktop. Brak zależności.
hooks/register.tsx 555 lines1// mission-pane: a side pane (/ops) with this session's subagents and background jobs,
2// live. The list shows status, time, tool calls, context and last action; a row opens
3// its details: an agent's prompt, timeline of tool calls with results, messages and
4// report, or a job's command and the tail of its output. A sound and a toast as one ends.
5// It reads what Claude Code already holds: no model calls, no network.
6// Idea from agent-radar in hamzafer/claude-code-mods (MIT), grown into a pane.
7import type { AgentInfo, EngineInterface, Register, Timer } from 'claude-code'
8
9import { clip, cut, describeCall, elapsed, firstLine, isDone, isFailure, offset, oneLine, outputPath, parseNotification, short, tail } from './ops'
10import type { AgentStat, Job, Step } from './ops'
11
12const PANE = 'mission-pane'
13const TICK_MS = 1_000
14const KEEP_DONE = 10
15const MAX_STEPS = 300
16const PROMPT_LINES = 10
17const SOUND = 'sounds/done.wav'
18const SOUND_GAP_MS = 3_000
19const COLOR = { run: '#3fb950', wait: '#d29922', done: '#8b949e', fail: '#f85149', kind: '#58a6ff', action: '#d2a8ff', head: '#e6edf3' }
20
21type Row = {
22 key: string
23 id: string
24 isJob: boolean
25 depth: number
26 state: 'run' | 'wait' | 'done' | 'fail'
27 kind: string
28 label: string
29 time: string
30 tools?: number
31 ctx?: number
32 note?: string // the last action while running, the status once ended
33 endedAt: number
34}
35
36const isLive = (r: Row) => r.state === 'run' || r.state === 'wait'
37
38type View = { kind: 'list' } | { kind: 'agent'; id: string } | { kind: 'job'; id: string }
39
40// The module's state: a hot reload starts it over.
41const stats = new Map<string, AgentStat>() // per subagent, from its own loop's events
42const steps = new Map<string, Step[]>() // per subagent, its tool calls in order
43const reports = new Map<string, string>() // per subagent, its final answer
44const prompts = new Map<string, string>() // per subagent, the task it was given
45const promptsByLabel = new Map<string, string>() // Agent call description → prompt, until the id is known
46const jobs = new Map<string, Job>() // background shells, monitors, workflows
47let view: View = { kind: 'list' }
48let isPromptOpen = false
49let clearedAt = 0
50let hinted = false
51let lastSoundAt = 0
52let timer: Timer | undefined
53
54function stat(id: string, now: number): AgentStat {
55 const known = stats.get(id)
56 if (known) return known
57 const fresh: AgentStat = { startedAt: now, tools: 0 }
58 stats.set(id, fresh)
59 return fresh
60}
61
62function runningCount() {
63 return [...jobs.values()].filter(j => j.endedAt === undefined).length + [...stats.values()].filter(s => s.endedAt === undefined).length
64}
65
66// A job or agent ended: a toast, a sound (at most one every few seconds), a redraw.
67async function finished($: EngineInterface, label: string, ms: number, status: string) {
68 const now = await $.clock.now()
69 const failed = isFailure(status)
70 $.ui.toast(`${failed ? '✗' : '✓'} ${label} · ${elapsed(ms)}${failed ? ` · ${status}` : ''}`)
71 if (now - lastSoundAt >= SOUND_GAP_MS) {
72 lastSoundAt = now
73 void $.audio.play({ asset: SOUND }).catch(() => {})
74 }
75 $.ui.invalidate('ui.render')
76}
77
78async function startJob($: EngineInterface, job: Omit<Job, 'startedAt' | 'status'>) {
79 if (jobs.has(job.id)) return
80 jobs.set(job.id, { ...job, startedAt: await $.clock.now(), status: 'running' })
81 await hint($)
82 $.ui.invalidate('ui.render')
83}
84
85async function endJob($: EngineInterface, id: string, status: string) {
86 const job = jobs.get(id)
87 if (!job || job.endedAt !== undefined) return
88 const now = await $.clock.now()
89 job.endedAt = now
90 job.status = status
91 await finished($, `${job.kind} · ${clip(job.label, 40)}`, now - job.startedAt, status)
92}
93
94// With the pane closed and two or more things running, say so once a turn.
95async function hint($: EngineInterface) {
96 if (hinted || runningCount() < 2) return
97 if ((await $.ui.panes().catch(() => [])).some(p => p.id === PANE)) return
98 hinted = true
99 $.ui.toast(`${runningCount()} running · /ops`)
100}
101
102export const register: Register = on => {
103 on('session.start', async ($, e, next) => {
104 const result = await next(e)
105 await $.command.register({ name: 'ops', description: 'Open or close the pane of subagents and background jobs' }).catch(() => {})
106 timer?.cancel()
107 // The clocks move once a second, only while something runs and the pane is open.
108 timer = $.clock.every(TICK_MS, () => {
109 void (async () => {
110 if (runningCount() === 0) return
111 if (!(await $.ui.panes()).some(p => p.id === PANE)) return
112 $.ui.invalidate('ui.render')
113 })().catch(() => {})
114 })
115 return result
116 })
117
118 on('turn.start', async ($, e, next) => {
119 hinted = false
120 return next(e)
121 })
122
123 on('tool.call', async ($, e, next) => {
124 const tool = String(e.tool)
125 const input = e as unknown as Record<string, unknown>
126 const text = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
127 const summary = describeCall(tool, input)
128 let step: Step | undefined
129 if (e.agentId) {
130 const now = await $.clock.now()
131 const s = stat(e.agentId, now)
132 s.tools += 1
133 s.last = summary
134 step = { id: e.tool_use_id, at: now, summary }
135 steps.set(e.agentId, [...(steps.get(e.agentId) ?? []), step].slice(-MAX_STEPS))
136 }
137 if (tool === 'Agent') promptsByLabel.set(text('description'), text('prompt'))
138
139 const result = await next(e)
140 const failed = result.deny !== undefined || result.isError === true
141 if (step) {
142 step.endedAt = await $.clock.now()
143 step.ok = !failed
144 step.result = firstLine(result.deny ?? result.text)
145 }
146 const out = (result as { result?: unknown }).result
147 if (failed || !out || typeof out !== 'object') return result
148 const o = out as Record<string, unknown>
149 if (tool === 'Bash' && typeof o.backgroundTaskId === 'string') {
150 await startJob($, {
151 id: o.backgroundTaskId,
152 kind: 'shell',
153 label: clip(text('description') || oneLine(text('command')) || 'shell'),
154 command: text('command'),
155 outputPath: outputPath(result.text),
156 })
157 } else if (tool === 'Monitor' && typeof o.taskId === 'string') {
158 await startJob($, { id: o.taskId, kind: 'monitor', label: clip(text('description') || 'monitor'), command: text('command') })
159 } else if (tool === 'Workflow' && typeof o.taskId === 'string') {
160 const name = typeof o.workflowName === 'string' ? o.workflowName : text('name') || text('title') || 'workflow'
161 await startJob($, { id: o.taskId, kind: 'workflow', label: clip(name) })
162 } else if (tool === 'Agent' && typeof o.agentId === 'string') {
163 prompts.set(o.agentId, text('prompt'))
164 } else if (tool === 'TaskStop') {
165 const id = text('task_id') || text('shell_id')
166 if (id) await endJob($, id, 'killed')
167 }
168 return result
169 })
170
171 // A subagent's own requests: when it started, and how much context it carries.
172 on('turn.step', async function* ($, e, next) {
173 if (e.agentId) {
174 const s = stat(e.agentId, await $.clock.now())
175 if (s.endedAt !== undefined || s.waiting) {
176 s.endedAt = undefined // resumed: a stop was a pause, not its end
177 s.waiting = false
178 $.ui.invalidate('ui.render')
179 }
180 }
181 const result = yield* next(e)
182 if (e.agentId && result.usage) {
183 const u = result.usage
184 stat(e.agentId, await $.clock.now()).ctx = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
185 await hint($)
186 }
187 return result
188 })
189
190 // A subagent's loop ends with its own turn.complete, carrying its report.
191 on('turn.complete', async ($, e, next) => {
192 const result = await next(e)
193 if (!e.agentId) return result
194 if (e.answer) reports.set(e.agentId, e.answer)
195 const now = await $.clock.now()
196 const s = stat(e.agentId, now)
197 if (s.endedAt !== undefined) return result
198 const list = await $.agent.list().catch(() => [] as readonly AgentInfo[])
199 // Stopped with its own subagents still running: it waits for them, it is not done.
200 const live = list.filter(c => c.parentId === e.agentId && stats.get(c.id)?.endedAt === undefined && !isDone(c.status)).length
201 if (live > 0) {
202 s.waiting = true
203 s.last = `waiting for ${live} subagent${live === 1 ? '' : 's'}`
204 $.ui.invalidate('ui.render')
205 return result
206 }
207 s.endedAt = now
208 s.waiting = false
209 const info = list.find(a => a.id === e.agentId)
210 const status = e.isAborted ? 'killed' : e.reason === 'error' ? 'failed' : 'completed'
211 await finished($, `${info?.type ?? 'agent'} · ${clip(info?.description ?? e.agentId, 40)}`, now - s.startedAt, status)
212 return result
213 })
214
215 // A background job's end reaches the session as a task notification.
216 on('prompt.submit', async ($, e, next) => {
217 if (e.origin.kind === 'task-notification') {
218 const n = parseNotification(e.text)
219 if (n) await endJob($, n.id, n.status)
220 }
221 return next(e)
222 })
223
224 // At each stop Claude Code lists what is still in flight: pick up jobs started
225 // elsewhere (a command the person backgrounded) and close the ones that are gone.
226 on('classic.Stop', async ($, e, next) => {
227 const result = await next(e)
228 const tasks = e.background_tasks ?? []
229 const live = new Set(tasks.map(t => t.id))
230 for (const t of tasks) {
231 if (t.type === 'subagent' || jobs.has(t.id)) continue
232 await startJob($, { id: t.id, kind: t.type, label: clip(oneLine(t.description || t.command || t.name || t.type)), command: t.command })
233 }
234 for (const j of [...jobs.values()]) if (j.endedAt === undefined && !live.has(j.id)) await endJob($, j.id, 'completed')
235 return result
236 })
237
238 on('command.run', { command: 'ops' }, async $ => {
239 const isOpen = (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
240 if (isOpen) {
241 await $.ui.close({ id: PANE })
242 return { text: 'Mission pane closed. /ops opens it again.' }
243 }
244 view = { kind: 'list' }
245 await $.ui.open({ id: PANE, title: 'Mission', focus: true })
246 return { text: 'Mission pane open: subagents and background jobs, live. 1–9 opens a row.' }
247 })
248
249 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
250 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
251 const now = await $.clock.now()
252 const list = await $.agent.list().catch(() => [] as readonly AgentInfo[])
253 const agents = agentRows(list, now)
254 const background = jobRows(now)
255 const go = (next: View) => {
256 view = next
257 isPromptOpen = false
258 $.ui.invalidate('ui.render')
259 }
260
261 const glyph = (state: Row['state']) => (
262 <Text color={state === 'run' ? COLOR.run : state === 'wait' ? COLOR.wait : state === 'fail' ? COLOR.fail : COLOR.done}>
263 {state === 'run' ? '●' : state === 'wait' ? '◐' : state === 'fail' ? '✗' : '✓'}
264 </Text>
265 )
266
267 const heading = (title: string, extra?: string) => (
268 <Text key={`h-${title}`}>
269 <Text bold color={COLOR.head}>{title}</Text>
270 {extra ? <Text dimColor>{` · ${extra}`}</Text> : null}
271 </Text>
272 )
273
274 // The row a detail shows at its top, with ← back and ◀ ▶ through its siblings.
275 const detailBar = (rows: Row[], at: number, toView: (r: Row) => View) => {
276 const row = rows[at]
277 if (!row) return null
278 const older = rows[at - 1]
279 const newer = rows[at + 1]
280 return (
281 <Box key="bar" flexDirection="column" marginBottom={1}>
282 <Box flexDirection="row" gap={1}>
283 <Button key="back" label="← back" hotkey="b" onPress={() => go({ kind: 'list' })} />
284 <Button key="prev" label="◀" hotkey="p" dimColor={!older} onPress={() => older && go(toView(older))} />
285 <Button key="next" label="▶" hotkey="n" dimColor={!newer} onPress={() => newer && go(toView(newer))} />
286 <Text dimColor>{`${at + 1}/${rows.length}`}</Text>
287 </Box>
288 <Box flexDirection="row" gap={1}>
289 {glyph(row.state)}
290 <Text bold color={COLOR.kind}>{row.kind}</Text>
291 <Text bold>{row.label}</Text>
292 </Box>
293 <Text dimColor>
294 {[row.state === 'run' ? 'running' : row.state === 'wait' ? 'waiting' : row.note, row.time, row.tools !== undefined ? `${row.tools} tools` : '', row.ctx !== undefined ? `${short(row.ctx)} ctx` : '']
295 .filter(Boolean)
296 .join(' · ')}
297 </Text>
298 </Box>
299 )
300 }
301
302 // An agent's details: the task it got, its tool calls with results, its words, its report.
303 if (view.kind === 'agent') {
304 const id = view.id
305 const at = agents.findIndex(r => r.id === id)
306 if (at < 0) view = { kind: 'list' }
307 else {
308 const found = await $.session.messages({ agentId: id }).catch(() => [])
309 const messages = Array.isArray(found) ? found : []
310 const firstUser = messages.find(m => m.role === 'user' && m.text.trim() !== '')
311 const info = list.find(a => a.id === id)
312 const prompt = prompts.get(id) || (info ? promptsByLabel.get(info.description) : undefined) || firstUser?.text || ''
313 const promptLines = prompt.split('\n')
314 const shownPrompt = isPromptOpen ? prompt : promptLines.slice(0, PROMPT_LINES).join('\n')
315 const said = messages.filter(m => m.role === 'assistant' && m.text.trim() !== '').slice(-5)
316 const report = reports.get(id)
317 const start = stats.get(id)?.startedAt ?? 0
318 const timeline = steps.get(id) ?? []
319 return (
320 <Box flexDirection="column">
321 {detailBar(agents, at, r => ({ kind: 'agent', id: r.id }))}
322 <Box key="prompt" flexDirection="column" marginBottom={1}>
323 <Box flexDirection="row" gap={1}>
324 {heading('PROMPT')}
325 {promptLines.length > PROMPT_LINES && (
326 <Button
327 key="prompt-toggle"
328 label={isPromptOpen ? 'less' : `all ${promptLines.length} lines`}
329 hotkey="f"
330 dimColor
331 onPress={() => {
332 isPromptOpen = !isPromptOpen
333 $.ui.invalidate('ui.render')
334 }}
335 />
336 )}
337 </Box>
338 {prompt ? <Markdown key="prompt-text" dimColor text={cut(shownPrompt, 9_000)} /> : <Text dimColor>(not known yet)</Text>}
339 </Box>
340 <Box key="timeline" flexDirection="column" marginBottom={1}>
341 {heading('TIMELINE', `${timeline.length} tool calls`)}
342 {timeline.length === 0 && <Text dimColor>(no tool calls yet)</Text>}
343 {timeline.slice(-60).map(s => (
344 <Box key={`step-${s.id}`} flexDirection="row">
345 <Box width={7}>
346 <Text dimColor>{offset(s.at - start)}</Text>
347 </Box>
348 <Box width={2}>{glyph(s.endedAt === undefined ? 'run' : s.ok ? 'done' : 'fail')}</Box>
349 <Box width={38}>
350 <Text color={s.ok === false ? COLOR.fail : undefined} wrap="truncate">{s.summary}</Text>
351 </Box>
352 <Box flexGrow={1}>
353 <Text dimColor={s.ok !== false} color={s.ok === false ? COLOR.fail : undefined} wrap="truncate">
354 {s.endedAt === undefined ? '…' : s.result ? `→ ${s.result}` : ''}
355 </Text>
356 </Box>
357 </Box>
358 ))}
359 </Box>
360 {said.length > 0 && (
361 <Box key="messages" flexDirection="column" marginBottom={1}>
362 {heading('MESSAGES', `last ${said.length}`)}
363 {said.map((m, i) => (
364 <Text key={`said-${i}`} wrap="wrap">
365 <Text color={COLOR.action}>{'› '}</Text>
366 <Text>{cut(m.text.trim(), 400)}</Text>
367 </Text>
368 ))}
369 </Box>
370 )}
371 {report && (
372 <Box key="report" flexDirection="column">
373 {heading('REPORT')}
374 <Markdown key="report-text" text={cut(report, 9_000)} />
375 </Box>
376 )}
377 </Box>
378 )
379 }
380 }
381
382 // A job's details: its command and the last lines of its output, live.
383 if (view.kind === 'job') {
384 const id = view.id
385 const at = background.findIndex(r => r.id === id)
386 const job = jobs.get(id)
387 if (at < 0 || !job) view = { kind: 'list' }
388 else {
389 let lines: string[] | null = null
390 let why = 'No output file for this job.'
391 if (job.outputPath) {
392 const raw = await $.fs.read(job.outputPath).catch(() => null)
393 if (typeof raw === 'string') lines = tail(raw)
394 else why = 'The output file could not be read (gone, or over 4 MB).'
395 }
396 return (
397 <Box flexDirection="column">
398 {detailBar(background, at, r => ({ kind: 'job', id: r.id }))}
399 {job.command && (
400 <Box key="command" flexDirection="column" marginBottom={1}>
401 {heading('COMMAND')}
402 <Text dimColor wrap="wrap">{cut(job.command, 1_000)}</Text>
403 </Box>
404 )}
405 <Box key="output" flexDirection="column">
406 {heading('OUTPUT', lines ? `last ${lines.length} lines` : undefined)}
407 {lines === null && <Text dimColor>{why}</Text>}
408 {lines?.length === 0 && <Text dimColor>(nothing yet)</Text>}
409 {lines?.map((l, i) => (
410 <Text key={`out-${i}`} wrap="truncate">{l}</Text>
411 ))}
412 </Box>
413 </Box>
414 )
415 }
416 }
417
418 // The list: every agent and job, a row each, 1–9 opening one.
419 if (agents.length === 0 && background.length === 0) {
420 return <Text dimColor>Nothing yet. Subagents and background jobs of this session show up here, live.</Text>
421 }
422 const numbered = [...agents, ...background]
423 const section = (title: string, rows: Row[]) => {
424 const running = rows.filter(isLive).length
425 return (
426 <Box key={`section-${title}`} flexDirection="column" marginBottom={1}>
427 {heading(title, `${running} running · ${rows.length - running} done`)}
428 {rows.map(r => {
429 const n = numbered.indexOf(r) + 1
430 return (
431 <Box key={r.key} flexDirection="row" paddingLeft={r.depth * 2}>
432 <Box width={2}>{glyph(r.state)}</Box>
433 <Box width={10}>
434 <Text color={COLOR.kind} wrap="truncate">{r.kind}</Text>
435 </Box>
436 <Box width={30}>
437 <Text dimColor={!isLive(r)} wrap="truncate">{r.label}</Text>
438 </Box>
439 <Box width={9}>
440 <Text dimColor>{r.time}</Text>
441 </Box>
442 <Box width={10}>
443 <Text dimColor>{r.tools === undefined ? '' : `${r.tools} tools`}</Text>
444 </Box>
445 <Box width={9}>
446 <Text dimColor>{r.ctx === undefined ? '' : `${short(r.ctx)} ctx`}</Text>
447 </Box>
448 <Box flexGrow={1}>
449 <Text color={r.state === 'run' ? COLOR.action : r.state === 'wait' ? COLOR.wait : undefined} dimColor={!isLive(r)} wrap="truncate">
450 {r.note ? (isLive(r) ? `→ ${r.note}` : r.note) : ''}
451 </Text>
452 </Box>
453 <Button
454 key={`open-${r.key}`}
455 label="›"
456 {...(n <= 9 ? { hotkey: String(n) } : {})}
457 onPress={() => go(r.isJob ? { kind: 'job', id: r.id } : { kind: 'agent', id: r.id })}
458 />
459 </Box>
460 )
461 })}
462 </Box>
463 )
464 }
465 const hasDone = numbered.some(r => !isLive(r))
466 return (
467 <Box flexDirection="column">
468 {agents.length > 0 && section('AGENTS', agents)}
469 {background.length > 0 && section('BACKGROUND', background)}
470 {hasDone && (
471 <Button
472 key="clear"
473 label="clear done"
474 hotkey="c"
475 dimColor
476 onPress={() => {
477 void (async () => {
478 clearedAt = await $.clock.now()
479 $.ui.invalidate('ui.render')
480 })().catch(() => {})
481 }}
482 />
483 )}
484 </Box>
485 )
486 })
487}
488
489// Subagents as rows: running first, children under their parent, the last few done.
490function agentRows(list: readonly AgentInfo[], now: number): Row[] {
491 const toRow = (a: AgentInfo, depth: number): Row => {
492 const s = stats.get(a.id)
493 const ended = s?.endedAt ?? (isDone(a.status) ? now : undefined)
494 const state = ended === undefined ? (s?.waiting ? 'wait' : 'run') : isFailure(a.status) ? 'fail' : 'done'
495 return {
496 key: `agent-${a.id}`,
497 id: a.id,
498 isJob: false,
499 depth,
500 state,
501 kind: a.type,
502 label: oneLine(a.description || a.name || a.id),
503 time: s ? elapsed((s.endedAt ?? now) - s.startedAt) : '',
504 tools: s?.tools,
505 ctx: s?.ctx,
506 note: state === 'run' || state === 'wait' ? s?.last : a.status,
507 endedAt: ended ?? Infinity,
508 }
509 }
510 const ids = new Set(list.map(a => a.id))
511 const roots = list.filter(a => !a.parentId || !ids.has(a.parentId)).map(a => ({ a, row: toRow(a, 0) }))
512 const kept = keep(roots.map(r => r.row))
513 const out: Row[] = []
514 const walk = (a: AgentInfo, depth: number) => {
515 for (const child of list.filter(c => c.parentId === a.id)) {
516 const row = toRow(child, depth)
517 if (isLive(row) || row.endedAt > clearedAt) out.push(row)
518 walk(child, depth + 1)
519 }
520 }
521 for (const row of kept) {
522 const root = roots.find(r => r.row.key === row.key)
523 out.push(row)
524 if (root) walk(root.a, 1)
525 }
526 return out
527}
528
529function jobRows(now: number): Row[] {
530 return keep(
531 [...jobs.values()].map(j => ({
532 key: `job-${j.id}`,
533 id: j.id,
534 isJob: true,
535 depth: 0,
536 state: j.endedAt === undefined ? 'run' : isFailure(j.status) ? 'fail' : 'done',
537 kind: j.kind,
538 label: j.label,
539 time: elapsed((j.endedAt ?? now) - j.startedAt),
540 note: j.endedAt === undefined ? undefined : j.status,
541 endedAt: j.endedAt ?? Infinity,
542 })),
543 )
544}
545
546// Running rows first (oldest first), then the most recent done, at most KEEP_DONE, none cleared.
547function keep(rows: Row[]): Row[] {
548 const running = rows.filter(isLive)
549 const done = rows
550 .filter(r => !isLive(r) && r.endedAt > clearedAt)
551 .sort((a, b) => b.endedAt - a.endedAt)
552 .slice(0, KEEP_DONE)
553 return [...running, ...done]
554}
555hooks/ops.ts 123 lines1// mission-pane's text work: pure functions over plain values, no $ and no timers.
2
3export type JobKind = 'shell' | 'monitor' | 'workflow' | 'agent' | string
4export type Job = { id: string; kind: JobKind; label: string; startedAt: number; endedAt?: number; status: string; command?: string; outputPath?: string }
5// One tool call of a subagent, as its timeline shows it.
6export type Step = { id: string; at: number; summary: string; endedAt?: number; ok?: boolean; result?: string }
7// `waiting`: its loop stopped while subagents it started still run; it resumes when they report.
8export type AgentStat = { startedAt: number; endedAt?: number; waiting?: boolean; tools: number; last?: string; ctx?: number }
9
10const ACTION_CHARS = 48
11
12// What a tool call is doing, in a few words: `Bash npm test`, `Read register.tsx`.
13export function describeCall(tool: string, input: Record<string, unknown>): string {
14 const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
15 const base = (p: string) => p.slice(Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) + 1)
16 let detail = ''
17 switch (tool) {
18 case 'Bash':
19 detail = oneLine(str('description') || str('command'))
20 break
21 case 'Read':
22 case 'Write':
23 case 'Edit':
24 case 'NotebookEdit':
25 detail = base(str('file_path') || str('notebook_path'))
26 break
27 case 'Grep':
28 case 'Glob':
29 detail = `"${str('pattern')}"`
30 break
31 case 'WebFetch':
32 detail = host(str('url'))
33 break
34 case 'WebSearch':
35 detail = `"${str('query')}"`
36 break
37 case 'Agent':
38 detail = str('description')
39 break
40 case 'Skill':
41 detail = str('skill')
42 break
43 default:
44 if (tool.startsWith('mcp__')) return clip(tool.split('__').slice(1).join(' '))
45 }
46 return clip(detail ? `${tool} ${detail}` : tool)
47}
48
49// A task notification's id and status, read from its text:
50// `<task-id>b1</task-id> … <status>completed</status>`.
51export function parseNotification(text: string): { id: string; status: string } | null {
52 const id = text.match(/<task-id>\s*([^<\s]+)\s*<\/task-id>/)?.[1]
53 if (!id) return null
54 const status = text.match(/<status>\s*([^<]+?)\s*<\/status>/)?.[1] ?? 'completed'
55 return { id, status }
56}
57
58export function isDone(status: string) {
59 return !/^(running|pending|in_progress|async_launched|remote_launched)$/i.test(status)
60}
61
62export function isFailure(status: string) {
63 return /fail|error|kill|stop|abort/i.test(status)
64}
65
66export function elapsed(ms: number) {
67 const s = Math.max(0, Math.floor(ms / 1000))
68 if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
69 return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
70}
71
72export function short(tokens: number) {
73 if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
74 if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
75 return String(tokens)
76}
77
78export function oneLine(text: string) {
79 return text.replace(/\s+/g, ' ').trim()
80}
81
82// Shortens multi-line text (a prompt, a report) without joining its lines.
83export function cut(text: string, max: number) {
84 return text.length > max ? `${text.slice(0, max - 1)}…` : text
85}
86
87export function clip(text: string, max = ACTION_CHARS) {
88 const t = oneLine(text)
89 return t.length > max ? `${t.slice(0, max - 1)}…` : t
90}
91
92function host(url: string) {
93 return url.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
94}
95
96// The first line of a tool's result worth showing: `12 matches`, `exit 1: 2 failed`.
97export function firstLine(text: string | undefined, max = 60) {
98 const line = (text ?? '').split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
99 return clip(line, max)
100}
101
102// Where a background shell writes its output, from what the Bash tool told the model.
103export function outputPath(text: string | undefined) {
104 return text?.match(/Output is being written to:\s*(\S+?\.output)\b/)?.[1]
105}
106
107// The last lines of a command's output as a terminal would show them: colors stripped,
108// and a line a progress bar rewrote with \r reduced to what it last said.
109export function tail(text: string, lines = 30) {
110 return text
111 .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
112 .split('\n')
113 .map(l => l.split('\r').filter(part => part !== '').pop() ?? '')
114 .filter((l, i, all) => l.trim() !== '' || i < all.length - 1)
115 .slice(-lines)
116}
117
118// Time since the agent started, as a timeline stamp: `0:05`, `12:40`.
119export function offset(ms: number) {
120 const s = Math.max(0, Math.floor(ms / 1000))
121 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
122}
123