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
package/lib/tracker.js ADDED
@@ -0,0 +1,648 @@
1
+ /**
2
+ * Per-session trace state and the record fan-out the transport publishes.
3
+ *
4
+ * `TraceHub` owns every piece of live dashboard state: the retained backlog of
5
+ * normalized entries, the current Turn/Step position, the coalesced streaming
6
+ * buffer, cumulative token usage, and the derived activity status. It is
7
+ * deliberately free of Cordis and socket concerns so the state machine can be
8
+ * tested directly, and so the plugin's event listeners stay a thin adapter.
9
+ *
10
+ * Every mutation publishes protocol records through {@link TraceHub#subscribe}.
11
+ * Nothing here reads the session log back or writes to it.
12
+ *
13
+ * @module dsh-live-trace/tracker
14
+ */
15
+
16
+ import {
17
+ accumulateUsage,
18
+ agentStatusValue,
19
+ normalizeAgentCreated,
20
+ normalizeAgentError,
21
+ normalizeSessionCreated,
22
+ normalizeSessionEvent,
23
+ sessionInfo,
24
+ TAG
25
+ } from './normalize.js'
26
+ import { PROTOCOL_VERSION, SERVER_KIND } from './protocol.js'
27
+ import { countDiffLines, writeCallFrom } from './tools.js'
28
+
29
+ /** Longest coalesced stream tail retained per attempt, in characters. */
30
+ export const STREAM_TAIL_LIMIT = 4000
31
+
32
+ /**
33
+ * Dashboard activity status, in the vocabulary the footer displays.
34
+ * @typedef {'idle' | 'running' | 'tool' | 'waiting-approval' | 'error'} ActivityStatus
35
+ */
36
+
37
+ /**
38
+ * One monitored session's derived state.
39
+ * @typedef {object} SessionState
40
+ * @property {object} info {@link sessionInfo} projection, kept current.
41
+ * @property {object[]} entries retained normalized entries (ring buffer).
42
+ * @property {number} turn latest turn number, 0 before the first.
43
+ * @property {number} step latest step number, 0 before the first.
44
+ * @property {number} maxStep highest step seen in the current turn.
45
+ * @property {ActivityStatus} activity
46
+ * @property {'idle' | 'running'} agentStatus
47
+ * @property {number | null} statusSince
48
+ * @property {number | null} lastEventAt
49
+ * @property {string | null} lastError
50
+ * @property {Set<string>} pendingApprovals
51
+ * @property {object} usage
52
+ * @property {number | null} contextWindow
53
+ * @property {string | null} provider
54
+ * @property {string | null} model
55
+ * @property {number | null} turnStartedAt
56
+ * @property {object | null} stream
57
+ */
58
+
59
+ /**
60
+ * Create the empty per-session state for one announced session.
61
+ * @returns {SessionState}
62
+ */
63
+ function emptySessionState(info) {
64
+ return {
65
+ info,
66
+ entries: [],
67
+ turn: 0,
68
+ step: 0,
69
+ maxStep: 0,
70
+ activity: 'idle',
71
+ agentStatus: 'idle',
72
+ turnOpen: false,
73
+ statusSince: null,
74
+ statusSignature: null,
75
+ lastEventAt: null,
76
+ lastError: null,
77
+ pendingApprovals: new Set(),
78
+ usage: {},
79
+ contextWindow: null,
80
+ provider: null,
81
+ model: null,
82
+ turnStartedAt: null,
83
+ stream: null,
84
+ /** In-flight tool calls by callId, used to settle a result into its block. */
85
+ pendingTools: new Map(),
86
+ /** Aggregated file changes, one record per touched path. */
87
+ edits: []
88
+ }
89
+ }
90
+
91
+ /** Options accepted by {@link TraceHub}. */
92
+ const HUB_DEFAULTS = {
93
+ /** Normalized entries retained per session for late-joining viewers. */
94
+ backlogSize: 2000,
95
+ /** Minimum gap between coalesced streaming updates, in milliseconds. */
96
+ streamIntervalMs: 500,
97
+ /** Clock used for coalescing and status timestamps; injectable for tests. */
98
+ now: () => Date.now(),
99
+ /** Passed through to the normalizer. */
100
+ normalizeOptions: {}
101
+ }
102
+
103
+ export class TraceHub {
104
+ /**
105
+ * @param {Partial<typeof HUB_DEFAULTS>} [options]
106
+ */
107
+ constructor(options = {}) {
108
+ this.options = { ...HUB_DEFAULTS, ...options }
109
+ /** Clock used for coalescing and status timestamps. */
110
+ this.now = this.options.now
111
+ /** @type {Map<string, SessionState>} */
112
+ this.sessions = new Map()
113
+ /** @type {Set<(record: object) => void>} */
114
+ this.listeners = new Set()
115
+ /** Session the dashboard shows by default: the most recently active one. */
116
+ this.activeSessionId = null
117
+ /** Last published session-list signature, so summaries only flow on change. */
118
+ this.sessionsSignature = null
119
+ }
120
+
121
+ /**
122
+ * Register a record consumer.
123
+ * @param {(record: object) => void} listener
124
+ * @returns {() => void} disposer
125
+ */
126
+ subscribe(listener) {
127
+ this.listeners.add(listener)
128
+ return () => {
129
+ this.listeners.delete(listener)
130
+ }
131
+ }
132
+
133
+ /** @param {object} record */
134
+ emit(record) {
135
+ const framed = { v: PROTOCOL_VERSION, ...record }
136
+ for (const listener of this.listeners) {
137
+ try {
138
+ listener(framed)
139
+ } catch {
140
+ /* one broken viewer must never disturb the Harness */
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * @param {string} sessionId
147
+ * @returns {SessionState | undefined}
148
+ */
149
+ stateOf(sessionId) {
150
+ return this.sessions.get(sessionId)
151
+ }
152
+
153
+ /** Summary list for the registry and the `sessions` record. */
154
+ sessionsInfo() {
155
+ return [...this.sessions.values()]
156
+ .map((state) => ({
157
+ ...state.info,
158
+ turn: state.turn,
159
+ step: state.step,
160
+ activity: state.activity,
161
+ lastEventAt: state.lastEventAt
162
+ }))
163
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
164
+ }
165
+
166
+ /* ---------------- session lifecycle ---------------- */
167
+
168
+ /**
169
+ * @param {any} session a live Harness Session
170
+ * @param {string} [source] creation source (`startup`, `resume`, …)
171
+ */
172
+ onSessionCreated(session, source) {
173
+ const { entry, info } = normalizeSessionCreated(session, source)
174
+ const state = emptySessionState(info)
175
+ if (typeof info.createdAt === 'number') state.statusSince = info.createdAt
176
+ this.sessions.set(info.id, state)
177
+ this.activeSessionId = info.id
178
+ this.remember(state, entry)
179
+ this.emitSessions()
180
+ }
181
+
182
+ /** @param {any} session */
183
+ onSessionDisposed(session) {
184
+ const id = String(session?.id ?? '')
185
+ if (!this.sessions.has(id)) return
186
+ this.remember(this.sessions.get(id), {
187
+ seq: null,
188
+ time: this.now(),
189
+ event: 'session/disposed',
190
+ tag: TAG.SESSION,
191
+ label: 'SESSION',
192
+ text: 'closed',
193
+ level: 'muted'
194
+ })
195
+ this.sessions.delete(id)
196
+ if (this.activeSessionId === id) {
197
+ this.activeSessionId = this.sessionsInfo()[0]?.id ?? null
198
+ }
199
+ this.emitSessions()
200
+ }
201
+
202
+ emitSessions() {
203
+ this.emit({ kind: SERVER_KIND.SESSIONS, sessions: this.sessionsInfo(), activeSessionId: this.activeSessionId })
204
+ }
205
+
206
+ /* ---------------- durable session events ---------------- */
207
+
208
+ /**
209
+ * @param {any} session
210
+ * @param {{ type: string, seq?: number, time?: number, data?: any }} event
211
+ */
212
+ onSessionEvent(session, event) {
213
+ const id = String(session?.id ?? '')
214
+ let state = this.sessions.get(id)
215
+ if (state === undefined) {
216
+ // A session entered before this plugin loaded, or a scoped event for a
217
+ // session we have not seen announced. Adopt it so nothing is lost.
218
+ this.onSessionCreated(session)
219
+ state = this.sessions.get(id)
220
+ if (state === undefined) return
221
+ }
222
+
223
+ const callId = event?.type === 'tool/result' ? toId(event.data?.message?.toolCallId) : undefined
224
+ const pendingTool = callId === undefined ? undefined : state.pendingTools.get(callId)
225
+ const { entries, usage, context, sessionTitle } = normalizeSessionEvent(
226
+ event,
227
+ this.options.normalizeOptions,
228
+ pendingTool === undefined ? {} : { pendingTool }
229
+ )
230
+ if (typeof sessionTitle === 'string') {
231
+ // The title arrives as a log-only event and belongs in the header, not
232
+ // only in the trace, so the summary list is refreshed too.
233
+ state.info.title = sessionTitle
234
+ this.emitSessions()
235
+ }
236
+ for (const entry of entries) {
237
+ this.settleToolActivity(state, entry, pendingTool, callId, event)
238
+ this.remember(state, entry)
239
+ }
240
+ this.trackPosition(state, event)
241
+ if (usage !== undefined) {
242
+ accumulateUsage(state.usage, usage)
243
+ this.emit({ kind: SERVER_KIND.USAGE, sessionId: id, usage: { ...state.usage }, contextWindow: state.contextWindow })
244
+ }
245
+ if (context !== undefined) {
246
+ if (context.provider !== undefined) state.provider = context.provider
247
+ if (context.model !== undefined) state.model = context.model
248
+ if (typeof context.capacity === 'number') state.contextWindow = context.capacity
249
+ this.emit({ kind: SERVER_KIND.USAGE, sessionId: id, usage: { ...state.usage }, contextWindow: state.contextWindow })
250
+ }
251
+ this.touch(state, id, event?.time)
252
+ }
253
+
254
+ /**
255
+ * Keep the in-flight call table and the per-session edit aggregate current.
256
+ *
257
+ * The duration a tool took is only knowable where both endpoints are seen, so
258
+ * it is stamped here rather than in the pure normalizer.
259
+ *
260
+ * @param {SessionState} state
261
+ * @param {object} entry a freshly normalized entry, mutated in place
262
+ * @param {{ at?: number, name?: string } | undefined} pendingTool
263
+ * @param {string | undefined} callId
264
+ * @param {{ type?: string, data?: any }} [event] the raw event, for call arguments
265
+ */
266
+ settleToolActivity(state, entry, pendingTool, callId, event) {
267
+ if (entry.tag !== 'tool') return
268
+ if (entry.phase === 'call') {
269
+ const id = entry.callId
270
+ if (id !== undefined) {
271
+ state.pendingTools.set(id, {
272
+ name: entry.tool,
273
+ shell: entry.shell,
274
+ filePath: entry.filePath,
275
+ write: writeCallFrom(event?.data?.arguments, entry.filePath),
276
+ at: typeof entry.time === 'number' ? entry.time : this.now()
277
+ })
278
+ }
279
+ return
280
+ }
281
+ if (entry.phase !== 'result') return
282
+
283
+ // A created file reports no hunks because it had no prior text; the applied
284
+ // content lives in the call arguments, so rebuild the whole-file diff.
285
+ if (entry.operation === 'create' && entry.diffs === undefined && pendingTool?.write !== undefined) {
286
+ entry.diffs = [{ path: pendingTool.write.filePath, oldText: null, newText: pendingTool.write.content }]
287
+ }
288
+ if (pendingTool !== undefined && typeof pendingTool.at === 'number') {
289
+ entry.durationMs = Math.max(0, (typeof entry.time === 'number' ? entry.time : this.now()) - pendingTool.at)
290
+ }
291
+ if (callId !== undefined) state.pendingTools.delete(callId)
292
+ if (Array.isArray(entry.diffs) && entry.diffs.length > 0) this.recordEdits(state, entry)
293
+ }
294
+
295
+ /**
296
+ * Fold one result's diff hunks into the session's per-path aggregate.
297
+ *
298
+ * Re-editing the same file appends to that file's record rather than
299
+ * replacing it, so the edits view shows everything the model changed.
300
+ */
301
+ recordEdits(state, entry) {
302
+ const byPath = new Map(state.edits.map((edit) => [edit.path, edit]))
303
+ for (const hunk of entry.diffs) {
304
+ let edit = byPath.get(hunk.path)
305
+ if (edit === undefined) {
306
+ edit = { path: hunk.path, added: 0, removed: 0, hunks: [], calls: 0, firstAt: entry.time, lastAt: entry.time }
307
+ state.edits.push(edit)
308
+ byPath.set(hunk.path, edit)
309
+ }
310
+ const counts = countDiffLines([hunk])
311
+ edit.added += counts.added
312
+ edit.removed += counts.removed
313
+ edit.hunks.push(hunk)
314
+ edit.calls += 1
315
+ edit.lastAt = entry.time
316
+ if (entry.operation === 'create') edit.operation = 'create'
317
+ else if (edit.operation === undefined) edit.operation = entry.operation ?? 'update'
318
+ }
319
+ this.emit({ kind: SERVER_KIND.EDITS, sessionId: state.info.id, edits: state.edits })
320
+ }
321
+
322
+ /** Update turn/step/activity from one durable event. */
323
+ trackPosition(state, event) {
324
+ const data = event?.data ?? {}
325
+ switch (event?.type) {
326
+ case 'turn/start':
327
+ state.turn = numberOr(data.turn, state.turn)
328
+ state.step = 0
329
+ state.maxStep = 0
330
+ state.turnOpen = true
331
+ state.turnStartedAt = typeof event?.time === 'number' ? event.time : Date.now()
332
+ state.lastError = null
333
+ this.setActivity(state, 'running')
334
+ break
335
+ case 'turn/end':
336
+ state.step = 0
337
+ state.turnOpen = false
338
+ this.setActivity(state, 'idle')
339
+ break
340
+ case 'step/start':
341
+ state.turn = numberOr(data.turn, state.turn)
342
+ state.step = numberOr(data.step, state.step)
343
+ state.maxStep = Math.max(state.maxStep, state.step)
344
+ this.setActivity(state, 'running')
345
+ break
346
+ case 'step/end':
347
+ state.step = numberOr(data.step, state.step)
348
+ break
349
+ case 'tool/call':
350
+ this.setActivity(state, 'tool')
351
+ this.clearStream(state)
352
+ break
353
+ case 'tool/result':
354
+ this.setActivity(state, state.turnOpen ? 'running' : 'idle')
355
+ break
356
+ case 'approval/asked': {
357
+ const id = data?.id === undefined ? null : String(data.id)
358
+ if (id !== null) state.pendingApprovals.add(id)
359
+ this.setActivity(state, 'waiting-approval')
360
+ break
361
+ }
362
+ case 'approval/decided': {
363
+ const id = data?.id === undefined ? null : String(data.id)
364
+ if (id !== null) state.pendingApprovals.delete(id)
365
+ this.setActivity(state, state.turnOpen ? 'running' : 'idle')
366
+ break
367
+ }
368
+ case 'assistant/message':
369
+ // The durable message settles the live stream.
370
+ this.clearStream(state)
371
+ break
372
+ default:
373
+ break
374
+ }
375
+ // Turn and step advance without any activity change, so the position is
376
+ // published independently of {@link TraceHub#setActivity}.
377
+ this.emitStatus(state)
378
+ this.emitSessionsIfChanged()
379
+ }
380
+
381
+ /**
382
+ * Publish the session list when any session's summary actually moved.
383
+ *
384
+ * The switcher shows every session at once, so their progress has to reach a
385
+ * viewer that is not bound to them. The signature keeps this to a handful of
386
+ * records per step instead of one per event.
387
+ */
388
+ emitSessionsIfChanged() {
389
+ const signature = [...this.sessions.values()]
390
+ .map((state) => `${state.info.id}|${state.activity}|${state.turn}|${state.step}|${state.info.title ?? ''}`)
391
+ .join('\n')
392
+ if (signature === this.sessionsSignature) return false
393
+ this.sessionsSignature = signature
394
+ this.emitSessions()
395
+ return true
396
+ }
397
+
398
+ /** Record an entry in the ring buffer and publish it. */
399
+ remember(state, entry) {
400
+ if (entry === null || entry === undefined) return
401
+ state.entries.push(entry)
402
+ const cap = this.options.backlogSize
403
+ if (state.entries.length > cap) state.entries.splice(0, state.entries.length - cap)
404
+ this.emit({ kind: SERVER_KIND.ENTRY, sessionId: state.info.id, entry })
405
+ }
406
+
407
+ /** Mark a session as the most recently active and refresh its seq watermark. */
408
+ touch(state, id, time) {
409
+ state.lastEventAt = typeof time === 'number' ? time : this.now()
410
+ if (typeof state.info.seq === 'number') state.info.seq += 1
411
+ this.activeSessionId = id
412
+ }
413
+
414
+ setActivity(state, activity) {
415
+ if (state.activity === activity) return
416
+ state.activity = activity
417
+ state.statusSince = this.now()
418
+ this.emitStatus(state)
419
+ }
420
+
421
+ /**
422
+ * Publish the current status when it actually changed.
423
+ *
424
+ * Every viewer-visible field participates, so a step advancing while the
425
+ * activity stays `running` still reaches the footer.
426
+ *
427
+ * @param {SessionState} state
428
+ * @param {{ force?: boolean }} [options]
429
+ * @returns {boolean} whether a record was published
430
+ */
431
+ emitStatus(state, options = {}) {
432
+ const signature = [
433
+ state.activity,
434
+ state.turn,
435
+ state.step,
436
+ state.maxStep,
437
+ state.pendingApprovals.size,
438
+ state.agentStatus,
439
+ state.lastError ?? '',
440
+ state.stream === null ? 'nostream' : 'stream'
441
+ ].join('|')
442
+ if (options.force !== true && state.statusSignature === signature) return false
443
+ state.statusSignature = signature
444
+ this.emit({ kind: SERVER_KIND.STATUS, ...this.statusRecord(state) })
445
+ return true
446
+ }
447
+
448
+ statusRecord(state) {
449
+ return {
450
+ sessionId: state.info.id,
451
+ status: state.activity,
452
+ agentStatus: state.agentStatus,
453
+ turn: state.turn,
454
+ step: state.step,
455
+ maxStep: state.maxStep,
456
+ since: state.statusSince,
457
+ at: this.now(),
458
+ error: state.lastError,
459
+ streamActive: state.stream !== null
460
+ }
461
+ }
462
+
463
+ /* ---------------- live in-process events ---------------- */
464
+
465
+ /**
466
+ * @param {any} agent
467
+ * @param {string} [source]
468
+ */
469
+ onAgentCreated(agent, source) {
470
+ const session = agent?.session
471
+ const id = String(session?.id ?? '')
472
+ const state = this.sessions.get(id)
473
+ const { entry, provider, model } = normalizeAgentCreated(agent, source)
474
+ if (state !== undefined) {
475
+ if (provider !== undefined) state.provider = provider
476
+ if (model !== undefined) state.model = model
477
+ if (entry !== null) this.remember(state, entry)
478
+ this.emitStatus(state, { force: true })
479
+ }
480
+ }
481
+
482
+ /**
483
+ * @param {any} agent
484
+ * @param {'idle' | 'running' | string} status
485
+ */
486
+ onAgentStatus(agent, status) {
487
+ const state = this.sessions.get(String(agent?.session?.id ?? ''))
488
+ if (state === undefined) return
489
+ state.agentStatus = agentStatusValue(status)
490
+ if (state.agentStatus === 'idle' && state.pendingApprovals.size === 0 && state.activity !== 'error') {
491
+ this.setActivity(state, 'idle')
492
+ } else if (state.agentStatus === 'running' && state.activity === 'idle') {
493
+ this.setActivity(state, 'running')
494
+ } else {
495
+ this.emitStatus(state)
496
+ }
497
+ if (state.agentStatus === 'idle') this.clearStream(state)
498
+ }
499
+
500
+ /**
501
+ * Coalesce one `agent/assistant-stream` frame.
502
+ * @param {any} agent
503
+ * @param {any} frame
504
+ */
505
+ onAssistantStream(agent, frame) {
506
+ const state = this.sessions.get(String(agent?.session?.id ?? ''))
507
+ if (state === undefined || frame === null || typeof frame !== 'object') return
508
+ const now = this.now()
509
+ switch (frame.type) {
510
+ case 'start':
511
+ state.stream = {
512
+ attemptId: frame.attemptId,
513
+ revision: frame.revision,
514
+ turn: frame.turn,
515
+ step: frame.step,
516
+ text: '',
517
+ reasoning: '',
518
+ seenIndexes: new Map(),
519
+ lastFlush: now,
520
+ dirty: false
521
+ }
522
+ break
523
+ case 'chunk': {
524
+ const stream = state.stream
525
+ if (stream === null || stream.attemptId !== frame.attemptId) return
526
+ const chunk = frame.chunk ?? {}
527
+ const index = typeof chunk.index === 'number' ? chunk.index : -1
528
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
529
+ stream.text = tail(`${stream.text}${chunk.text}`, STREAM_TAIL_LIMIT)
530
+ stream.seenIndexes.set(index, true)
531
+ stream.dirty = true
532
+ } else if (chunk.type === 'reasoning-delta' && typeof chunk.text === 'string') {
533
+ stream.reasoning = tail(`${stream.reasoning}${chunk.text}`, STREAM_TAIL_LIMIT)
534
+ stream.seenIndexes.set(index, true)
535
+ stream.dirty = true
536
+ } else if (chunk.type === 'block-end' && typeof chunk.block?.text === 'string') {
537
+ // Some adapters only surface the assembled block; use it when this
538
+ // index produced no deltas, so nothing is duplicated.
539
+ if (stream.seenIndexes.get(index) !== true) {
540
+ const text = chunk.block.text
541
+ if (chunk.block.type === 'reasoning') stream.reasoning = tail(`${stream.reasoning}${text}`, STREAM_TAIL_LIMIT)
542
+ else stream.text = tail(`${stream.text}${text}`, STREAM_TAIL_LIMIT)
543
+ stream.seenIndexes.set(index, true)
544
+ stream.dirty = true
545
+ }
546
+ }
547
+ break
548
+ }
549
+ case 'end':
550
+ if (state.stream !== null && state.stream.attemptId === frame.attemptId) state.stream.ended = true
551
+ break
552
+ default:
553
+ break
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Publish coalesced streaming updates that have been quiet long enough.
559
+ * @param {number} [now]
560
+ * @returns {number} how many sessions were flushed
561
+ */
562
+ flushStreams(now = this.now()) {
563
+ let flushed = 0
564
+ for (const state of this.sessions.values()) {
565
+ const stream = state.stream
566
+ if (stream === null) continue
567
+ if (stream.dirty === false || now - stream.lastFlush < this.options.streamIntervalMs) continue
568
+ stream.lastFlush = now
569
+ stream.dirty = false
570
+ flushed += 1
571
+ this.emit({
572
+ kind: SERVER_KIND.STREAM,
573
+ sessionId: state.info.id,
574
+ turn: stream.turn,
575
+ step: stream.step,
576
+ text: stream.text,
577
+ reasoning: stream.reasoning,
578
+ at: now
579
+ })
580
+ }
581
+ return flushed
582
+ }
583
+
584
+ /** Drop the live stream buffer, telling viewers to stop showing it. */
585
+ clearStream(state) {
586
+ if (state.stream === null) return
587
+ state.stream = null
588
+ this.emit({ kind: SERVER_KIND.STREAM_END, sessionId: state.info.id, at: this.now() })
589
+ this.emitStatus(state)
590
+ }
591
+
592
+ /**
593
+ * @param {{ agent?: any, turn?: number, step?: number, error?: unknown, time?: number }} payload
594
+ */
595
+ onAgentError(payload) {
596
+ const state = this.sessions.get(String(payload?.agent?.session?.id ?? ''))
597
+ const { entries } = normalizeAgentError(payload, this.options.normalizeOptions)
598
+ if (state === undefined) return
599
+ state.lastError = entries[0]?.text ?? 'error'
600
+ for (const entry of entries) this.remember(state, entry)
601
+ this.clearStream(state)
602
+ state.statusSince = this.now()
603
+ state.activity = 'error'
604
+ this.emitStatus(state, { force: true })
605
+ }
606
+
607
+ /* ---------------- replay ---------------- */
608
+
609
+ /**
610
+ * Records a late-joining viewer needs to fill its screen for one session.
611
+ * @param {string} sessionId
612
+ * @param {number} [limit]
613
+ * @returns {object[]}
614
+ */
615
+ snapshot(sessionId, limit = 500) {
616
+ const state = this.sessions.get(sessionId)
617
+ if (state === undefined) return []
618
+ const entries = state.entries.slice(-limit)
619
+ const records = entries.map((entry) => ({ kind: SERVER_KIND.ENTRY, sessionId, entry }))
620
+ if (state.edits.length > 0) records.push({ kind: SERVER_KIND.EDITS, sessionId, edits: state.edits })
621
+ records.push({ kind: SERVER_KIND.USAGE, sessionId, usage: { ...state.usage }, contextWindow: state.contextWindow })
622
+ records.push({ kind: SERVER_KIND.STATUS, ...this.statusRecord(state) })
623
+ if (state.stream !== null && state.stream.dirty) {
624
+ records.push({
625
+ kind: SERVER_KIND.STREAM,
626
+ sessionId,
627
+ turn: state.stream.turn,
628
+ step: state.stream.step,
629
+ text: state.stream.text,
630
+ reasoning: state.stream.reasoning,
631
+ at: this.now()
632
+ })
633
+ }
634
+ return records
635
+ }
636
+ }
637
+
638
+ function toId(value) {
639
+ return value === undefined || value === null ? undefined : String(value)
640
+ }
641
+
642
+ function numberOr(value, fallback) {
643
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
644
+ }
645
+
646
+ function tail(text, limit) {
647
+ return text.length <= limit ? text : text.slice(text.length - limit)
648
+ }