dsh-live-trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -0
  3. package/README.zh.md +790 -0
  4. package/assets/rain.ogg +0 -0
  5. package/bin/dsh-glyph-probe.js +51 -0
  6. package/bin/dsh-live-trace.js +29 -0
  7. package/bin/dsh-live-working.js +14 -0
  8. package/cordis.patch.yml +31 -0
  9. package/icon.svg +12 -0
  10. package/index.js +328 -0
  11. package/lib/client.js +178 -0
  12. package/lib/instance.js +68 -0
  13. package/lib/normalize.js +850 -0
  14. package/lib/paths.js +66 -0
  15. package/lib/protocol.js +115 -0
  16. package/lib/registry.js +232 -0
  17. package/lib/tools.js +257 -0
  18. package/lib/tracker.js +648 -0
  19. package/lib/transport.js +231 -0
  20. package/locale/en.json +6 -0
  21. package/locale/zh.json +6 -0
  22. package/package.json +94 -0
  23. package/picture/call1.png +0 -0
  24. package/picture/call2.png +0 -0
  25. package/picture/sleep1.png +0 -0
  26. package/picture/sleep2.png +0 -0
  27. package/picture/tui1.png +0 -0
  28. package/picture/tui2.png +0 -0
  29. package/picture/type1.png +0 -0
  30. package/picture/type2.png +0 -0
  31. package/scripts/bench-render.mjs +69 -0
  32. package/scripts/demo-working.mjs +130 -0
  33. package/scripts/demo.mjs +284 -0
  34. package/scripts/install-profile.mjs +174 -0
  35. package/scripts/mock-provider.mjs +211 -0
  36. package/src/cli/cellsize.js +120 -0
  37. package/src/cli/format.js +73 -0
  38. package/src/cli/highlight.js +932 -0
  39. package/src/cli/i18n.js +457 -0
  40. package/src/cli/main.js +630 -0
  41. package/src/cli/markdown.js +753 -0
  42. package/src/cli/renderer.js +1044 -0
  43. package/src/cli/screen.js +270 -0
  44. package/src/cli/theme.js +221 -0
  45. package/src/cli/view-state.js +396 -0
  46. package/src/cli/views.js +406 -0
  47. package/src/cli/width.js +337 -0
  48. package/src/cli/working/art.js +413 -0
  49. package/src/cli/working/main.js +569 -0
  50. package/src/cli/working/packing.js +159 -0
  51. package/src/cli/working/picker.js +75 -0
  52. package/src/cli/working/props.js +385 -0
  53. package/src/cli/working/scene.js +837 -0
  54. package/src/cli/working/sky.js +641 -0
  55. package/src/cli/working/sound.js +400 -0
  56. package/src/cli/working/state.js +528 -0
@@ -0,0 +1,850 @@
1
+ /**
2
+ * Event normalization: raw DeepSeek Harness events into flat display records.
3
+ *
4
+ * Everything here is pure and has no Harness, filesystem, or terminal
5
+ * dependency, so the mapping from an event to what the dashboard shows is
6
+ * testable in isolation and identical in the plugin and the viewer's fixtures.
7
+ *
8
+ * Two families of input arrive:
9
+ *
10
+ * 1. Durable session-log events, delivered as `ctx.on('session/event', (session, event))`.
11
+ * Their envelope is `{ type, seq, time, data }`.
12
+ * 2. Live, in-process events the log does not record verbatim, such as
13
+ * `agent/assistant-stream`, `agent/status`, and `agent/error`.
14
+ *
15
+ * @module dsh-live-trace/normalize
16
+ */
17
+
18
+ import {
19
+ capOutput,
20
+ diffOperationFrom,
21
+ filePathFrom,
22
+ narrowFileDiffs,
23
+ parseExitStatus,
24
+ shellCallFrom
25
+ } from './tools.js'
26
+
27
+ /** Display tags; the viewer maps each to a distinct color and prefix treatment. */
28
+ export const TAG = {
29
+ SESSION: 'session',
30
+ TURN: 'turn',
31
+ STEP: 'step',
32
+ USER: 'user',
33
+ ASSISTANT: 'assistant',
34
+ TOOL: 'tool',
35
+ RESULT: 'result',
36
+ ERROR: 'error',
37
+ APPROVAL: 'approval',
38
+ META: 'meta'
39
+ }
40
+
41
+ /** Default cap on one entry's primary text, in characters. */
42
+ export const DEFAULT_TEXT_LIMIT = 4000
43
+
44
+ /**
45
+ * Event types the dashboard hides by default.
46
+ *
47
+ * These are internal transport bookkeeping records, not agent activity: they
48
+ * carry delivery watermarks or diagnostics and would otherwise drown the trace
49
+ * in one useless line per step. Entries ending in `*` are prefix matches.
50
+ */
51
+ export const DEFAULT_MUTED_EVENT_TYPES = ['session-log-deepseek/*']
52
+
53
+ const DEFAULT_OPTIONS = {
54
+ showSystemMessages: false,
55
+ showRequestMetadata: false,
56
+ // An unknown event type is much more likely to be a plugin's bookkeeping
57
+ // record than something a human needs to watch, so the board stays quiet by
58
+ // default and shows them only when asked.
59
+ showUnknownEvents: false,
60
+ mutedEventTypes: DEFAULT_MUTED_EVENT_TYPES,
61
+ textLimit: DEFAULT_TEXT_LIMIT
62
+ }
63
+
64
+ /**
65
+ * @param {unknown} type an event type name
66
+ * @param {string[]} [patterns] exact names or `prefix*` patterns
67
+ * @returns {boolean} whether the type is hidden
68
+ */
69
+ export function isMutedEventType(type, patterns = DEFAULT_MUTED_EVENT_TYPES) {
70
+ if (typeof type !== 'string' || !Array.isArray(patterns)) return false
71
+ return patterns.some((pattern) =>
72
+ typeof pattern === 'string' && pattern.endsWith('*') ? type.startsWith(pattern.slice(0, -1)) : type === pattern
73
+ )
74
+ }
75
+
76
+ /* ------------------------------------------------------------------ *
77
+ * Text utilities
78
+ * ------------------------------------------------------------------ */
79
+
80
+ /**
81
+ * Collapse any text to a single line: no CR/LF/TAB, no runs of whitespace.
82
+ * @param {unknown} value
83
+ * @returns {string}
84
+ */
85
+ export function collapse(value) {
86
+ if (value === null || value === undefined) return ''
87
+ return String(value)
88
+ .replace(/[\r\n\t\f\v]+/g, ' ')
89
+ .replace(/\u00a0/g, ' ')
90
+ .replace(/\s{2,}/g, ' ')
91
+ .trim()
92
+ }
93
+
94
+ /**
95
+ * Collapse and cap text with a trailing ellipsis.
96
+ * @param {unknown} value
97
+ * @param {number} limit
98
+ * @returns {string}
99
+ */
100
+ export function clip(value, limit = 160) {
101
+ const text = collapse(value)
102
+ if (limit <= 0 || text.length <= limit) return text
103
+ return `${text.slice(0, Math.max(0, limit - 1))}…`
104
+ }
105
+
106
+ /**
107
+ * Render an arbitrary thrown value as one line.
108
+ * @param {unknown} error
109
+ * @returns {string}
110
+ */
111
+ export function describeError(error) {
112
+ if (error === null || error === undefined) return 'unknown error'
113
+ if (typeof error === 'string') return clip(error, 400)
114
+ if (typeof error === 'object') {
115
+ const record = /** @type {Record<string, unknown>} */ (error)
116
+ const code = typeof record.code === 'string' && record.code.length > 0 ? record.code : undefined
117
+ const message =
118
+ typeof record.message === 'string' && record.message.length > 0
119
+ ? record.message
120
+ : typeof record.reason === 'string' && record.reason.length > 0
121
+ ? record.reason
122
+ : undefined
123
+ if (code !== undefined && message !== undefined) return clip(`${code}: ${message}`, 400)
124
+ if (message !== undefined) return clip(message, 400)
125
+ if (code !== undefined) return clip(code, 400)
126
+ try {
127
+ return clip(JSON.stringify(record), 400)
128
+ } catch {
129
+ return 'unserializable error'
130
+ }
131
+ }
132
+ return clip(String(error), 400)
133
+ }
134
+
135
+ /* ------------------------------------------------------------------ *
136
+ * LLM content helpers
137
+ * ------------------------------------------------------------------ */
138
+
139
+ /** Attachment display name, tolerating the several shapes refs use. */
140
+ function attachmentName(attachment) {
141
+ if (attachment === null || typeof attachment !== 'object') return ''
142
+ const record = /** @type {Record<string, unknown>} */ (attachment)
143
+ for (const key of ['name', 'fileName', 'filename', 'title', 'id']) {
144
+ const value = record[key]
145
+ if (typeof value === 'string' && value.length > 0) return value
146
+ }
147
+ return ''
148
+ }
149
+
150
+ /**
151
+ * Flatten model-facing content blocks into one display line.
152
+ * @param {unknown} content
153
+ * @param {{ includeReasoning?: boolean, includeToolCalls?: boolean }} [options]
154
+ * @returns {string}
155
+ */
156
+ export function contentText(content, options = {}) {
157
+ if (typeof content === 'string') return collapse(content)
158
+ if (!Array.isArray(content)) return ''
159
+ const parts = []
160
+ for (const block of content) {
161
+ if (block === null || typeof block !== 'object') continue
162
+ const record = /** @type {Record<string, unknown>} */ (block)
163
+ switch (record.type) {
164
+ case 'text':
165
+ parts.push(collapse(record.text))
166
+ break
167
+ case 'reasoning':
168
+ if (options.includeReasoning === true) parts.push(collapse(record.text))
169
+ break
170
+ case 'tool-call':
171
+ if (options.includeToolCalls === true) {
172
+ parts.push(`→ ${collapse(record.name)}(${clip(record.arguments, 120)})`)
173
+ }
174
+ break
175
+ case 'tool-addition':
176
+ parts.push(`+tool ${collapse(record.toolName)}`)
177
+ break
178
+ case 'tool-removal':
179
+ parts.push(`-tool ${collapse(record.toolName)}`)
180
+ break
181
+ case 'image': {
182
+ const name = attachmentName(record.attachment)
183
+ parts.push(name.length > 0 ? `[image ${name}]` : '[image]')
184
+ break
185
+ }
186
+ case 'file': {
187
+ const name = attachmentName(record.attachment)
188
+ parts.push(name.length > 0 ? `[file ${name}]` : '[file]')
189
+ break
190
+ }
191
+ default:
192
+ if (typeof record.type === 'string') parts.push(`[${record.type}]`)
193
+ }
194
+ }
195
+ return parts.filter((part) => part.length > 0).join(' ')
196
+ }
197
+
198
+ /**
199
+ * Flatten content blocks preserving their internal line structure.
200
+ *
201
+ * {@link contentText} collapses everything to one line, which is right for a
202
+ * compact trace row. Markdown rendering needs the original paragraphs and code
203
+ * fences back, so this variant joins blocks with a blank line and keeps
204
+ * newlines inside each block.
205
+ *
206
+ * @param {unknown} content
207
+ * @param {{ includeReasoning?: boolean, includeToolCalls?: boolean }} [options]
208
+ * @returns {string}
209
+ */
210
+ export function contentBlocks(content, options = {}) {
211
+ if (typeof content === 'string') return content
212
+ if (!Array.isArray(content)) return ''
213
+ const parts = []
214
+ for (const block of content) {
215
+ if (block === null || typeof block !== 'object') continue
216
+ const record = /** @type {Record<string, unknown>} */ (block)
217
+ switch (record.type) {
218
+ case 'text':
219
+ if (typeof record.text === 'string' && record.text.trim().length > 0) parts.push(record.text)
220
+ break
221
+ case 'reasoning':
222
+ if (options.includeReasoning === true && typeof record.text === 'string' && record.text.trim().length > 0) {
223
+ parts.push(record.text)
224
+ }
225
+ break
226
+ case 'tool-call':
227
+ if (options.includeToolCalls === true) {
228
+ parts.push(`→ ${collapse(record.name)}(${clip(record.arguments, 120)})`)
229
+ }
230
+ break
231
+ default:
232
+ break
233
+ }
234
+ }
235
+ return parts.join('\n\n')
236
+ }
237
+
238
+ /** Raw multi-line text of a model message, capped without collapsing lines. */
239
+ function rawMessageText(message, options = {}) {
240
+ const limit = options.limit ?? 4000
241
+ const raw = contentBlocks(/** @type {Record<string, unknown> | undefined} */ (message)?.content, options)
242
+ return raw.length > limit ? raw.slice(0, limit) : raw
243
+ }
244
+
245
+ /** First non-empty text chunk of a model message, capped. */
246
+ function messageText(message, options = {}) {
247
+ const content = /** @type {Record<string, unknown> | undefined} */ (message)?.content
248
+ return clip(contentText(content, options), options.limit ?? 4000)
249
+ }
250
+
251
+ /**
252
+ * Summarize raw tool-call arguments (an unparsed JSON string) for one line.
253
+ * @param {unknown} raw
254
+ * @param {number} [limit]
255
+ * @returns {string}
256
+ */
257
+ export function summarizeToolArguments(raw, limit = 200) {
258
+ if (typeof raw !== 'string' || raw.trim().length === 0) return ''
259
+ let parsed
260
+ try {
261
+ parsed = JSON.parse(raw)
262
+ } catch {
263
+ return clip(raw, limit)
264
+ }
265
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return clip(raw, limit)
266
+ const parts = []
267
+ for (const [key, value] of Object.entries(parsed)) {
268
+ let rendered
269
+ if (typeof value === 'string') rendered = JSON.stringify(clip(value, 120))
270
+ else if (value === null) rendered = 'null'
271
+ else if (typeof value === 'bigint') rendered = String(value)
272
+ else if (typeof value === 'object') {
273
+ try {
274
+ rendered = clip(JSON.stringify(value), 80)
275
+ } catch {
276
+ rendered = '[object]'
277
+ }
278
+ } else rendered = String(value)
279
+ parts.push(`${key}=${rendered}`)
280
+ if (parts.join(' ').length >= limit) break
281
+ }
282
+ return clip(parts.join(' '), limit)
283
+ }
284
+
285
+ /**
286
+ * Human text for a `turn/end` reason.
287
+ * @param {unknown} reason
288
+ * @returns {string}
289
+ */
290
+ export function turnEndReasonText(reason) {
291
+ if (reason === null || typeof reason !== 'object') return 'ended'
292
+ const record = /** @type {Record<string, unknown>} */ (reason)
293
+ switch (record.kind) {
294
+ case 'completed':
295
+ return 'completed'
296
+ case 'aborted':
297
+ return record.reason === undefined ? 'aborted' : `aborted (${clip(record.reason, 80)})`
298
+ case 'blocked':
299
+ return 'blocked'
300
+ case 'error':
301
+ return `error: ${describeError(record.error)}`
302
+ case 'max-tokens':
303
+ return 'max output tokens reached'
304
+ case 'forked':
305
+ return 'forked'
306
+ default:
307
+ return typeof record.kind === 'string' ? record.kind : 'ended'
308
+ }
309
+ }
310
+
311
+ /* ------------------------------------------------------------------ *
312
+ * Session metadata
313
+ * ------------------------------------------------------------------ */
314
+
315
+ /**
316
+ * Project a live Session into the small shape the viewer displays.
317
+ * @param {any} session
318
+ * @returns {{ id: string, title?: string, cwd?: string, createdAt?: number, origin?: string, parentSession?: string, delegationDepth?: number, agentPreset?: string, seq?: number }}
319
+ */
320
+ export function sessionInfo(session) {
321
+ const header = session?.header ?? {}
322
+ const id = typeof session?.id === 'string' ? session.id : String(session?.id ?? 'unknown')
323
+ /** @type {any} */
324
+ const info = { id }
325
+ const title = session?.title ?? header.title
326
+ if (typeof title === 'string' && title.length > 0) info.title = clip(title, 120)
327
+ if (typeof header.cwd === 'string') info.cwd = header.cwd
328
+ if (typeof header.createdAt === 'number') info.createdAt = header.createdAt
329
+ if (typeof header.origin === 'string') info.origin = header.origin
330
+ if (header.parentSession !== undefined && header.parentSession !== null) {
331
+ info.parentSession = String(header.parentSession)
332
+ }
333
+ if (typeof header.delegationDepth === 'number') info.delegationDepth = header.delegationDepth
334
+ if (typeof header.agentPreset === 'string') info.agentPreset = header.agentPreset
335
+ const seq = session?.seq
336
+ if (typeof seq === 'number') info.seq = seq
337
+ return info
338
+ }
339
+
340
+ /**
341
+ * Display label for a session's origin.
342
+ * @param {object} info a {@link sessionInfo} result
343
+ * @returns {string}
344
+ */
345
+ export function sessionKindLabel(info) {
346
+ if (info.origin === 'subagent') {
347
+ const depth = typeof info.delegationDepth === 'number' ? ` L${info.delegationDepth}` : ''
348
+ return `subagent${depth}`
349
+ }
350
+ return 'main'
351
+ }
352
+
353
+ /* ------------------------------------------------------------------ *
354
+ * Durable session events
355
+ * ------------------------------------------------------------------ */
356
+
357
+ const EVENT_PREFIX = {
358
+ 'turn/start': 'TURN',
359
+ 'turn/end': 'TURN',
360
+ 'step/start': 'STEP',
361
+ 'step/end': 'STEP'
362
+ }
363
+
364
+ /**
365
+ * Normalize one append-feed session event.
366
+ *
367
+ * @param {{ type: string, seq?: number, time?: number, data?: any }} event
368
+ * @param {{ showSystemMessages?: boolean, showRequestMetadata?: boolean, showUnknownEvents?: boolean, mutedEventTypes?: string[], textLimit?: number, outputLimits?: object }} [options]
369
+ * @param {{ pendingTool?: { name?: string, shell?: object, filePath?: string } }} [context]
370
+ * the in-flight call a tool result settles, supplied by the tracker
371
+ * @returns {{ entries: object[], usage?: object, context?: object, sessionTitle?: string }}
372
+ */
373
+ export function normalizeSessionEvent(event, options = {}, context = {}) {
374
+ const config = { ...DEFAULT_OPTIONS, ...options }
375
+ const type = event?.type
376
+ const data = event?.data ?? {}
377
+ const base = {
378
+ seq: typeof event?.seq === 'number' ? event.seq : null,
379
+ time: typeof event?.time === 'number' ? event.time : Date.now(),
380
+ event: typeof type === 'string' ? type : 'unknown'
381
+ }
382
+ const out = { entries: [] }
383
+ if (isMutedEventType(type, config.mutedEventTypes)) return out
384
+
385
+ switch (type) {
386
+ case 'turn/start':
387
+ out.entries.push({
388
+ ...base,
389
+ tag: TAG.TURN,
390
+ label: `${EVENT_PREFIX[type]} ${data.turn}`,
391
+ text: 'turn started',
392
+ turn: data.turn
393
+ })
394
+ break
395
+
396
+ case 'turn/end':
397
+ out.entries.push({
398
+ ...base,
399
+ tag: TAG.TURN,
400
+ label: `${EVENT_PREFIX[type]} ${data.turn} END`,
401
+ text: turnEndReasonText(data.reason),
402
+ turn: data.turn,
403
+ level: data.reason?.kind === 'error' ? 'error' : data.reason?.kind === 'completed' ? 'success' : 'info'
404
+ })
405
+ break
406
+
407
+ case 'step/start':
408
+ out.entries.push({
409
+ ...base,
410
+ tag: TAG.STEP,
411
+ label: `${EVENT_PREFIX[type]} ${data.step}`,
412
+ text: 'model call started',
413
+ turn: data.turn,
414
+ step: data.step
415
+ })
416
+ break
417
+
418
+ case 'step/end':
419
+ out.entries.push({
420
+ ...base,
421
+ tag: TAG.STEP,
422
+ label: `${EVENT_PREFIX[type]} ${data.step} END`,
423
+ text: 'step closed',
424
+ turn: data.turn,
425
+ step: data.step
426
+ })
427
+ break
428
+
429
+ case 'user/message': {
430
+ const source = data.source ?? {}
431
+ const injected = source.kind !== undefined && source.kind !== 'user'
432
+ out.entries.push({
433
+ ...base,
434
+ tag: TAG.USER,
435
+ label: injected ? 'CONTEXT' : 'USER',
436
+ text: messageText(data, { limit: config.textLimit }),
437
+ detail: injected ? `source: ${clip(source.kind, 60)}` : undefined,
438
+ level: injected ? 'muted' : 'info'
439
+ })
440
+ break
441
+ }
442
+
443
+ case 'developer/message':
444
+ out.entries.push({
445
+ ...base,
446
+ tag: TAG.META,
447
+ label: 'DEVELOPER',
448
+ text: messageText(data.message, { limit: config.textLimit }) || 'developer instruction',
449
+ turn: data.turn,
450
+ step: data.step
451
+ })
452
+ break
453
+
454
+ case 'system/message':
455
+ if (config.showSystemMessages) {
456
+ out.entries.push({
457
+ ...base,
458
+ tag: TAG.META,
459
+ label: 'SYSTEM',
460
+ text: messageText(data.message, { limit: config.textLimit }) || '(empty system prompt)',
461
+ turn: data.turn,
462
+ step: data.step,
463
+ level: 'muted'
464
+ })
465
+ }
466
+ break
467
+
468
+ case 'assistant/message': {
469
+ const visible = rawMessageText(data.message, { limit: config.textLimit })
470
+ const thinking = rawMessageText(data.message, { includeReasoning: true, limit: config.textLimit })
471
+ // `contentBlocks` with reasoning included also returns the visible text,
472
+ // so subtract it to keep only the thinking part.
473
+ const reasoning = thinking === visible ? '' : thinking.replace(visible, '').trim()
474
+ const toolCalls = contentText(data.message?.content, { includeToolCalls: true })
475
+ const collapsed = collapse(visible)
476
+ out.entries.push({
477
+ ...base,
478
+ tag: TAG.ASSISTANT,
479
+ label: data.interrupted === true ? 'ASSISTANT (interrupted)' : 'ASSISTANT',
480
+ // `text` stays the one-line form so `--plain`, filters, and the repeat
481
+ // collapsing keep working; `rawText` carries the markdown.
482
+ text: collapsed || (toolCalls.length > 0 ? toolCalls : '(no visible text)'),
483
+ rawText: visible,
484
+ reasoning: reasoning.length > 0 ? reasoning : undefined,
485
+ turn: data.turn,
486
+ step: data.step,
487
+ level: data.interrupted === true ? 'warn' : 'info'
488
+ })
489
+ if (data.usage !== undefined && data.usage !== null) out.usage = data.usage
490
+ break
491
+ }
492
+
493
+ case 'assistant/attempt': {
494
+ const reasoning = clip(reasoningOnly(data.stream), 400)
495
+ out.entries.push({
496
+ ...base,
497
+ tag: TAG.META,
498
+ label: 'ATTEMPT',
499
+ text: reasoning.length > 0 ? `attempt settled without a message: ${reasoning}` : 'attempt settled without a message',
500
+ turn: data.turn,
501
+ step: data.step,
502
+ level: 'warn'
503
+ })
504
+ break
505
+ }
506
+
507
+ case 'tool/call': {
508
+ const callId = data.callId !== undefined ? String(data.callId) : undefined
509
+ const shell = shellCallFrom(data.name, data.arguments)
510
+ const filePath = filePathFrom(data.arguments)
511
+ out.entries.push({
512
+ ...base,
513
+ // The call and its result share a key, so the viewer can upgrade the row
514
+ // in place instead of leaving a dangling "running" line behind.
515
+ key: toolKey(callId, base.seq),
516
+ phase: 'call',
517
+ tag: TAG.TOOL,
518
+ label: 'TOOL',
519
+ text: `${clip(data.name, 60)} ${summarizeToolArguments(data.arguments)}`.trim(),
520
+ tool: typeof data.name === 'string' ? data.name : undefined,
521
+ callId,
522
+ // The raw call arguments ride along so a viewer can describe the work
523
+ // in the model's own words (the mascot's telephone bubble, for one).
524
+ arguments: data.arguments,
525
+ turn: data.turn,
526
+ step: data.step,
527
+ ...(shell === undefined ? {} : { shell }),
528
+ ...(filePath === undefined ? {} : { filePath })
529
+ })
530
+ break
531
+ }
532
+
533
+ case 'tool/result': {
534
+ const isError = data.message?.isError === true
535
+ const callId = data.message?.toolCallId !== undefined ? String(data.message.toolCallId) : undefined
536
+ const call = context.pendingTool
537
+ const toolName =
538
+ call?.name ??
539
+ (typeof data.message?.source?.toolName === 'string' ? data.message.source.toolName : undefined) ??
540
+ 'tool'
541
+ // Parsed from the raw multi-line text: the exit marker lives on its own
542
+ // final line, which a collapsed one-liner would no longer match.
543
+ const rawBody = rawMessageText(data.message, { limit: config.textLimit })
544
+
545
+ // A shell result carries its exit status as a trailing marker; splitting
546
+ // it out lets the board show the status as its own pill and keeps the
547
+ // marker out of the output body.
548
+ let output
549
+ let shellStatus
550
+ let body = rawBody
551
+ if (call?.shell !== undefined) {
552
+ const parsed = parseExitStatus(rawBody)
553
+ const capped = capOutput(parsed.body, config.outputLimits)
554
+ output = capped
555
+ shellStatus = { exitCode: parsed.exitCode, signal: parsed.signal }
556
+ body = capped.text
557
+ }
558
+
559
+ const diffs = narrowFileDiffs(data.meta)
560
+ const operation = diffOperationFrom(data.meta)
561
+ // A shell command reports failure through its exit status, not through
562
+ // `isError` (a non-zero exit is information for the model, not an
563
+ // infrastructure failure), so the glyph follows the exit code.
564
+ const ok = shellStatus?.exitCode !== undefined ? shellStatus.exitCode === 0 : !isError
565
+
566
+ out.entries.push({
567
+ ...base,
568
+ key: toolKey(callId, base.seq),
569
+ phase: 'result',
570
+ tag: TAG.TOOL,
571
+ // A merged block keeps reading as the tool that ran; an orphaned result
572
+ // (whose call the viewer never saw) still reads as a result.
573
+ label: call === undefined ? 'RESULT' : 'TOOL',
574
+ text: (call === undefined ? `${clip(toolName, 40)} ` : '') + (collapse(body) || (isError ? 'tool failed' : 'ok')),
575
+ rawText: body,
576
+ detail: isError && data.error !== undefined ? describeError(data.error) : undefined,
577
+ ok,
578
+ level: ok ? 'success' : 'error',
579
+ tool: toolName,
580
+ callId,
581
+ turn: data.turn,
582
+ step: data.step,
583
+ ...(call?.shell === undefined ? {} : { shell: call.shell }),
584
+ ...(call?.filePath === undefined ? {} : { filePath: call.filePath }),
585
+ ...(output === undefined ? {} : { output }),
586
+ ...(shellStatus === undefined ? {} : { shellStatus }),
587
+ ...(diffs === undefined ? {} : { diffs }),
588
+ ...(operation === undefined ? {} : { operation })
589
+ })
590
+ break
591
+ }
592
+
593
+ case 'approval/asked':
594
+ out.entries.push({
595
+ ...base,
596
+ tag: TAG.APPROVAL,
597
+ label: 'APPROVAL',
598
+ text: `${clip(data.toolName, 60)}${data.reason !== undefined ? ` — ${clip(data.reason, 300)}` : ''}`.trim(),
599
+ callId: data.callId !== undefined ? String(data.callId) : undefined,
600
+ level: 'warn'
601
+ })
602
+ break
603
+
604
+ case 'approval/decided':
605
+ out.entries.push({
606
+ ...base,
607
+ tag: TAG.APPROVAL,
608
+ label: 'APPROVAL',
609
+ text: `decision: ${clip(data.outcome, 40)}`,
610
+ level: data.outcome === 'allowed-once' ? 'success' : data.outcome === 'rejected' ? 'error' : 'warn'
611
+ })
612
+ break
613
+
614
+ case 'request/context':
615
+ out.context = {
616
+ provider: data.provider,
617
+ model: data.model,
618
+ capacity: pickNumber(data, ['capacity', 'contextWindow', 'maxContextTokens', 'contextTokens'])
619
+ }
620
+ if (config.showRequestMetadata) {
621
+ out.entries.push({
622
+ ...base,
623
+ tag: TAG.META,
624
+ label: 'ROUTE',
625
+ text: `${clip(data.provider, 40)}/${clip(data.model, 60)}`,
626
+ level: 'muted'
627
+ })
628
+ }
629
+ break
630
+
631
+ case 'request/header':
632
+ if (config.showRequestMetadata) {
633
+ out.entries.push({
634
+ ...base,
635
+ tag: TAG.META,
636
+ label: 'HEADER',
637
+ text: `request header (${clip(data.reason, 40)})`,
638
+ level: 'muted'
639
+ })
640
+ }
641
+ break
642
+
643
+ case 'session/title': {
644
+ const title = clip(data.title, 200)
645
+ if (title.length > 0) out.sessionTitle = title
646
+ out.entries.push({
647
+ ...base,
648
+ tag: TAG.META,
649
+ label: 'TITLE',
650
+ text: title,
651
+ level: 'muted'
652
+ })
653
+ break
654
+ }
655
+
656
+ case 'permission/preset':
657
+ case 'sandbox/mode':
658
+ case 'approval/policy':
659
+ out.entries.push({
660
+ ...base,
661
+ tag: TAG.META,
662
+ label: 'POLICY',
663
+ text: describePolicyEvent(type, data),
664
+ level: 'muted'
665
+ })
666
+ break
667
+
668
+ case 'session/end-seed':
669
+ break
670
+
671
+ default:
672
+ if (config.showUnknownEvents) {
673
+ out.entries.push({
674
+ ...base,
675
+ tag: TAG.META,
676
+ label: typeof type === 'string' ? type.toUpperCase() : 'EVENT',
677
+ text: summarizeUnknown(data, config.textLimit),
678
+ level: 'muted'
679
+ })
680
+ }
681
+ }
682
+
683
+ return out
684
+ }
685
+
686
+ /** One compact line for a startup policy record. */
687
+ function describePolicyEvent(type, data) {
688
+ switch (type) {
689
+ case 'permission/preset':
690
+ return `preset=${clip(data?.preset, 60) || 'default'}`
691
+ case 'sandbox/mode':
692
+ return `sandbox=${clip(data?.mode, 60) || 'default'}`
693
+ case 'approval/policy':
694
+ return `approval=${clip(data?.policy, 60) || 'default'}`
695
+ default:
696
+ return ''
697
+ }
698
+ }
699
+
700
+ /** Extract only reasoning text from content blocks or a compact stream record list. */
701
+ function reasoningOnly(source) {
702
+ if (!Array.isArray(source)) return ''
703
+ const parts = []
704
+ for (const item of source) {
705
+ if (item === null || typeof item !== 'object') continue
706
+ // Durable assistant streams are `AssistantStreamRecord[]`; a reasoning
707
+ // block shows up either as a bare content block or inside `chunk`.
708
+ const block = /** @type {any} */ (item)
709
+ const chunk = block.chunk ?? block.block ?? block
710
+ if (chunk?.type === 'reasoning' && typeof chunk.text === 'string') parts.push(chunk.text)
711
+ else if (chunk?.type === 'reasoning-delta' && typeof chunk.text === 'string') parts.push(chunk.text)
712
+ }
713
+ return collapse(parts.join(' '))
714
+ }
715
+
716
+ /** Stable identity that lets a call and its result occupy one trace row. */
717
+ function toolKey(callId, seq) {
718
+ return callId === undefined ? `tool#${seq ?? 'x'}` : `tool:${callId}`
719
+ }
720
+
721
+ function pickNumber(object, keys) {
722
+ for (const key of keys) {
723
+ const value = object?.[key]
724
+ if (typeof value === 'number' && Number.isFinite(value)) return value
725
+ }
726
+ return undefined
727
+ }
728
+
729
+ /** Best-effort one-line account of an event shape we do not model. */
730
+ function summarizeUnknown(data, limit) {
731
+ if (data === null || data === undefined) return ''
732
+ if (typeof data !== 'object') return clip(data, 200)
733
+ for (const key of ['message', 'reason', 'text', 'name', 'kind', 'outcome']) {
734
+ if (key in data) {
735
+ const value = data[key]
736
+ if (typeof value === 'string') return clip(value, 200)
737
+ if (value !== null && typeof value === 'object') return clip(contentText(value?.content) || JSON.stringify(value), 200)
738
+ }
739
+ }
740
+ try {
741
+ return clip(JSON.stringify(data), Math.min(200, limit))
742
+ } catch {
743
+ return ''
744
+ }
745
+ }
746
+
747
+ /* ------------------------------------------------------------------ *
748
+ * Live, in-process events
749
+ * ------------------------------------------------------------------ */
750
+
751
+ /**
752
+ * Normalize an `agent/status` transition into a status value for the dashboard.
753
+ * @param {'idle' | 'running' | string} status
754
+ * @returns {'idle' | 'running'}
755
+ */
756
+ export function agentStatusValue(status) {
757
+ return status === 'running' ? 'running' : 'idle'
758
+ }
759
+
760
+ /**
761
+ * Normalize `agent/error`.
762
+ * @param {{ turn?: number, step?: number, error?: unknown, time?: number }} payload
763
+ * @param {{ textLimit?: number }} [options]
764
+ * @returns {{ entries: object[] }}
765
+ */
766
+ export function normalizeAgentError(payload, options = {}) {
767
+ const config = { ...DEFAULT_OPTIONS, ...options }
768
+ return {
769
+ entries: [
770
+ {
771
+ seq: null,
772
+ time: typeof payload?.time === 'number' ? payload.time : Date.now(),
773
+ event: 'agent/error',
774
+ tag: TAG.ERROR,
775
+ label: 'ERROR',
776
+ text: clip(describeError(payload?.error), config.textLimit),
777
+ turn: payload?.turn,
778
+ step: payload?.step,
779
+ level: 'error'
780
+ }
781
+ ]
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Normalize a session announcement into a lifecycle entry.
787
+ * @param {any} session
788
+ * @param {string} [source]
789
+ * @returns {{ entry: object, info: object }}
790
+ */
791
+ export function normalizeSessionCreated(session, source) {
792
+ const info = sessionInfo(session)
793
+ return {
794
+ info,
795
+ entry: {
796
+ seq: null,
797
+ time: typeof info.createdAt === 'number' ? info.createdAt : Date.now(),
798
+ event: 'session/created',
799
+ tag: TAG.SESSION,
800
+ label: 'SESSION',
801
+ text: `opened${source !== undefined ? ` (${source})` : ''}${info.title !== undefined ? ` — ${info.title}` : ''}`,
802
+ level: 'info'
803
+ }
804
+ }
805
+ }
806
+
807
+ /**
808
+ * Normalize an `agent/created` announcement into a routing entry.
809
+ * @param {any} agent
810
+ * @param {string} [source]
811
+ * @returns {{ entry: object | null, model?: string, provider?: string }}
812
+ */
813
+ export function normalizeAgentCreated(agent, source) {
814
+ const options = agent?.options ?? {}
815
+ const provider = typeof options.provider === 'string' ? options.provider : undefined
816
+ const model = typeof options.model === 'string' ? options.model : undefined
817
+ if (provider === undefined && model === undefined) return { entry: null }
818
+ return {
819
+ provider,
820
+ model,
821
+ entry: {
822
+ seq: null,
823
+ time: Date.now(),
824
+ event: 'agent/created',
825
+ tag: TAG.META,
826
+ label: 'MODEL',
827
+ text: `${provider ?? '?'}/${model ?? '?'}${source !== undefined ? ` (${source})` : ''}`,
828
+ level: 'muted'
829
+ }
830
+ }
831
+ }
832
+
833
+ /* ------------------------------------------------------------------ *
834
+ * Usage accounting
835
+ * ------------------------------------------------------------------ */
836
+
837
+ /**
838
+ * Fold one `usage` payload into running totals.
839
+ * @param {{ inputTokens?: number, outputTokens?: number, totalTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, reasoningTokens?: number } | undefined} totals mutable totals
840
+ * @param {object | undefined} usage one step's reported usage
841
+ * @returns {object} the same totals object, mutated
842
+ */
843
+ export function accumulateUsage(totals, usage) {
844
+ if (usage === null || typeof usage !== 'object') return totals
845
+ for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']) {
846
+ const value = usage[key]
847
+ if (typeof value === 'number' && Number.isFinite(value)) totals[key] = (totals[key] ?? 0) + value
848
+ }
849
+ return totals
850
+ }