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.
- package/LICENSE +21 -0
- package/README.md +920 -0
- package/README.zh.md +790 -0
- package/assets/rain.ogg +0 -0
- package/bin/dsh-glyph-probe.js +51 -0
- package/bin/dsh-live-trace.js +29 -0
- package/bin/dsh-live-working.js +14 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +12 -0
- package/index.js +328 -0
- package/lib/client.js +178 -0
- package/lib/instance.js +68 -0
- package/lib/normalize.js +850 -0
- package/lib/paths.js +66 -0
- package/lib/protocol.js +115 -0
- package/lib/registry.js +232 -0
- package/lib/tools.js +257 -0
- package/lib/tracker.js +648 -0
- package/lib/transport.js +231 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +94 -0
- package/picture/call1.png +0 -0
- package/picture/call2.png +0 -0
- package/picture/sleep1.png +0 -0
- package/picture/sleep2.png +0 -0
- package/picture/tui1.png +0 -0
- package/picture/tui2.png +0 -0
- package/picture/type1.png +0 -0
- package/picture/type2.png +0 -0
- package/scripts/bench-render.mjs +69 -0
- package/scripts/demo-working.mjs +130 -0
- package/scripts/demo.mjs +284 -0
- package/scripts/install-profile.mjs +174 -0
- package/scripts/mock-provider.mjs +211 -0
- package/src/cli/cellsize.js +120 -0
- package/src/cli/format.js +73 -0
- package/src/cli/highlight.js +932 -0
- package/src/cli/i18n.js +457 -0
- package/src/cli/main.js +630 -0
- package/src/cli/markdown.js +753 -0
- package/src/cli/renderer.js +1044 -0
- package/src/cli/screen.js +270 -0
- package/src/cli/theme.js +221 -0
- package/src/cli/view-state.js +396 -0
- package/src/cli/views.js +406 -0
- package/src/cli/width.js +337 -0
- package/src/cli/working/art.js +413 -0
- package/src/cli/working/main.js +569 -0
- package/src/cli/working/packing.js +159 -0
- package/src/cli/working/picker.js +75 -0
- package/src/cli/working/props.js +385 -0
- package/src/cli/working/scene.js +837 -0
- package/src/cli/working/sky.js +641 -0
- package/src/cli/working/sound.js +400 -0
- 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
|
+
}
|