dsh-plugin-weapp-gateway 0.8.13 → 0.8.23

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.
@@ -0,0 +1,1715 @@
1
+ /**
2
+ * Context dashboard bridge for the mobile gateway.
3
+ *
4
+ * Registers one pure session projection (`mobileContext`) that folds a session's
5
+ * durable event log into the composition / request-trend / event data the mini
6
+ * program's 「上下文」 tab renders, and serves it over a single mobile query:
7
+ *
8
+ * { type: 'context-dashboard', sessionId } ->
9
+ * { kind: 'context-dashboard', sessionId, asOfSeq, value: { ...mobileContext, breakdown, usage, stats } }
10
+ *
11
+ * Token pricing and the surface-replacement rules mirror DSH's own token meter
12
+ * (`@deepseek-ai/dsh-token-meter`, fixed-density heuristic + planner), so the
13
+ * folded `systemTokens` / `toolsTokens` / message total match the harness's
14
+ * `contextBreakdown` projection exactly. Category classification and the
15
+ * request/event tallies follow `dsh-context` (Apache-2.0, bowenliang123).
16
+ *
17
+ * Wire v2 (docs/context-dashboard.md §8.1/§8.2) adds the desktop dashboard's
18
+ * missing data as additive-optional fields: per-category activity counters
19
+ * (`activity`), the six-way duration split plus its timeline (`timing`/`spans`),
20
+ * per-request surface item counts and provider-reported usage
21
+ * (`requests[].items/prompt/output`), the newest answer preview (`lastAnswer`),
22
+ * and, for `context-fleet`, the host `agentTeam` roster/task board (`team`) plus
23
+ * each agent's `lastAnswer`/`phase`. The embedded assistant `data.stream`
24
+ * decoders under "Assistant stream decoding" are ported from
25
+ * `dsh-context/src/host/logShapes.ts` (Apache-2.0) so the split matches the
26
+ * desktop plugin's semantics.
27
+ *
28
+ * The fold state is plain JSON and bounded so the harness projection cache can
29
+ * checkpoint it: at most MAX_SURFACE nodes, MAX_REQUESTS request records,
30
+ * MAX_EVENTS context events and MAX_SPANS timeline segments. `init`/`apply`/
31
+ * `view` are copied on every change (never mutated in place) because the
32
+ * registry shares the previous state with the persisted cache.
33
+ */
34
+
35
+ const CHARS_PER_TOKEN = 4
36
+ const BLOCK_OVERHEAD = 4
37
+ const ROLE_OVERHEAD = 4
38
+
39
+ const SURFACE_TYPES = new Set(['system/message', 'developer/message', 'user/message', 'assistant/message', 'tool/result'])
40
+ const MESSAGE_CATS = ['user', 'inject', 'skill', 'assistant', 'tool']
41
+
42
+ const MAX_SURFACE = 4000
43
+ const MAX_ARCHIVE_NODES = 400
44
+ const MAX_HEADER_EPOCHS = 100
45
+ const MAX_FILE_OPS = 400
46
+ const MAX_REQUESTS = 200
47
+ const MAX_EVENTS = 120
48
+ const MAX_TOOL_NAMES = 20
49
+ const MAX_CALL_NAMES = 2000
50
+ const MAX_SPANS = 600
51
+ const MAX_STEP_SPANS = 2000
52
+ const MAX_LAST_ANSWER = 240
53
+ const MAX_TEAM_ROWS = 20
54
+
55
+ // Timeline span kinds on the wire (docs §8.1): the client colors the strip by
56
+ // these indices, so the vocabulary is frozen here.
57
+ const SPAN_WAIT = 0
58
+ const SPAN_REASONING = 1
59
+ const SPAN_TEXT = 2
60
+ const SPAN_TOOLARG = 3
61
+ const SPAN_TOOLS = 4
62
+ const SPAN_OTHER = 5
63
+ const DECODE_SPAN_KINDS = { reasoning: SPAN_REASONING, text: SPAN_TEXT, toolarg: SPAN_TOOLARG }
64
+ const TEAM_PHASES = new Set(['provisioning', 'active', 'failed'])
65
+
66
+ // Activity classification of a PAIRED tool call by its durable tool name
67
+ // (docs §8.1). Prefixes stay permissive for read*/write*/search* variants while
68
+ // the explicit names cover the harness's editor/search tools.
69
+ const WRITE_TOOL_NAMES = new Set(['edit', 'apply_patch', 'str_replace_editor'])
70
+ const SEARCH_TOOL_NAMES = new Set(['grep', 'glob', 'list', 'search', 'web_search', 'ripgrep', 'search_files'])
71
+
72
+ function toolActivityOf(name) {
73
+ if (typeof name !== 'string' || name === '') return ''
74
+ if (name.startsWith('read')) return 'reads'
75
+ if (name.startsWith('write') || WRITE_TOOL_NAMES.has(name)) return 'writes'
76
+ if (SEARCH_TOOL_NAMES.has(name) || name.startsWith('search')) return 'searches'
77
+ return ''
78
+ }
79
+
80
+ // A plain object with the only method the projection registry calls. Keeping the
81
+ // bridge free of a zod dependency lets the same module load in the host plugin
82
+ // and in plain-Node unit tests.
83
+ const passthroughSchema = Object.freeze({
84
+ parse: value => value,
85
+ safeParse: value => ({ success: true, data: value }),
86
+ // Older registries may validate through the Standard Schema face instead of
87
+ // calling parse(); accept every plain-JSON fold state there too.
88
+ '~standard': Object.freeze({
89
+ version: 1,
90
+ vendor: 'mobile-gateway',
91
+ validate: value => ({ value }),
92
+ }),
93
+ })
94
+
95
+ export const CONTEXT_REQUEST_TYPES = new Set(['context-dashboard', 'context-fleet', 'context-content'])
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Token pricing (DSH token-meter fixed-density heuristic)
99
+ // ---------------------------------------------------------------------------
100
+
101
+ function estimateStructuralBlock(block) {
102
+ if (block !== null && typeof block === 'object' && block.type === 'image') {
103
+ const reference = { ...block }
104
+ delete reference.offloaded
105
+ return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(reference).length / CHARS_PER_TOKEN)
106
+ }
107
+ return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
108
+ }
109
+
110
+ function estimateSystemMessage(message) {
111
+ const blocks = Array.isArray(message && message.content) ? message.content : []
112
+ if (blocks.length === 0) return 0
113
+ let characters = 0
114
+ for (const block of blocks) {
115
+ if (block !== null && typeof block === 'object' && block.type === 'text') {
116
+ characters += typeof block.text === 'string' ? block.text.length : 0
117
+ } else {
118
+ characters += JSON.stringify(block).length
119
+ }
120
+ }
121
+ return Math.ceil(characters / CHARS_PER_TOKEN) + ROLE_OVERHEAD
122
+ }
123
+
124
+ function estimateContent(blocks) {
125
+ let tokens = 0
126
+ if (!Array.isArray(blocks)) return tokens
127
+ for (const block of blocks) {
128
+ if (block === null || typeof block !== 'object') {
129
+ tokens += estimateStructuralBlock(block)
130
+ continue
131
+ }
132
+ switch (block.type) {
133
+ case 'text':
134
+ case 'reasoning':
135
+ tokens += Math.ceil((typeof block.text === 'string' ? block.text : '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
136
+ break
137
+ case 'tool-call':
138
+ tokens += Math.ceil((typeof block.name === 'string' ? block.name : '').length / CHARS_PER_TOKEN)
139
+ + Math.ceil((typeof block.arguments === 'string' ? block.arguments : '').length / CHARS_PER_TOKEN)
140
+ + BLOCK_OVERHEAD
141
+ break
142
+ default:
143
+ tokens += estimateStructuralBlock(block)
144
+ }
145
+ }
146
+ return tokens
147
+ }
148
+
149
+ function estimateMessage(message) {
150
+ if (message === null || message === undefined) return 0
151
+ if (message.role === 'system') return estimateSystemMessage(message)
152
+ return estimateContent(message.content) + ROLE_OVERHEAD
153
+ }
154
+
155
+ function canonicalHeader(header) {
156
+ if (header === null || typeof header !== 'object') return {}
157
+ const adapterDefaults = header.adapterDefaults
158
+ return {
159
+ config: header.config,
160
+ ...(adapterDefaults && (adapterDefaults.reasoningEffort === true || adapterDefaults.maxTokens === true)
161
+ ? { adapterDefaults }
162
+ : {}),
163
+ ...(Array.isArray(header.tools) && header.tools.length > 0 ? { tools: header.tools } : {}),
164
+ }
165
+ }
166
+
167
+ function estimateToolsTokens(header) {
168
+ const canonical = canonicalHeader(header)
169
+ if (!Array.isArray(canonical.tools) || canonical.tools.length === 0) return 0
170
+ return Math.ceil(JSON.stringify(canonical.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Assistant stream decoding (ported from dsh-context/src/host/logShapes.ts,
175
+ // Apache-2.0, bowenliang123) — total over untrusted input; unknown chunks,
176
+ // missing times and non-array streams are skipped rather than thrown.
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /** Whether a raw stream chunk carries a token delta (mirrors dsh-llm's `isTokenDelta`). */
180
+ function isTokenChunk(chunk) {
181
+ if (chunk === null || typeof chunk !== 'object') return false
182
+ switch (chunk.type) {
183
+ case 'text-delta':
184
+ case 'reasoning-delta':
185
+ return typeof chunk.text === 'string' && chunk.text !== ''
186
+ case 'tool-call-delta':
187
+ return (typeof chunk.argumentsDelta === 'string' && chunk.argumentsDelta !== '') || chunk.name !== undefined
188
+ default:
189
+ return false
190
+ }
191
+ }
192
+
193
+ /** The decode bucket a `block-start.blockType` names, or undefined for a marker we cannot price. */
194
+ function decodeKindOfBlock(blockType) {
195
+ if (blockType === 'reasoning') return 'reasoning'
196
+ if (blockType === 'text') return 'text'
197
+ if (blockType === 'tool-call') return 'toolarg'
198
+ return undefined
199
+ }
200
+
201
+ /**
202
+ * Decode spans and `block-start` counts inside one embedded assistant stream.
203
+ * Each marker owns the interval up to the next one; the last one owns up to
204
+ * `endTime`. Unknown markers leave their interval unpriced.
205
+ *
206
+ * @param {unknown} stream
207
+ * @param {number} endTime
208
+ * @returns {{spans: {reasoning:number,text:number,toolarg:number}, blocks: {reasoning:number,text:number,toolarg:number}}}
209
+ */
210
+ function decodeTallyOfStream(stream, endTime) {
211
+ const tally = {
212
+ spans: { reasoning: 0, text: 0, toolarg: 0 },
213
+ blocks: { reasoning: 0, text: 0, toolarg: 0 },
214
+ }
215
+ if (!Array.isArray(stream) || !Number.isFinite(endTime)) return tally
216
+ let kind
217
+ let since = 0
218
+ for (const record of stream) {
219
+ if (record === null || typeof record !== 'object') continue
220
+ if (record.type !== 'chunk' || record.chunk === null || typeof record.chunk !== 'object') continue
221
+ const chunk = record.chunk
222
+ if (chunk.type !== 'block-start') continue
223
+ const time = record.time
224
+ if (typeof time !== 'number' || !Number.isFinite(time)) continue
225
+ if (kind !== undefined) tally.spans[kind] += Math.max(0, time - since)
226
+ kind = decodeKindOfBlock(chunk.blockType)
227
+ if (kind !== undefined) tally.blocks[kind] += 1
228
+ since = time
229
+ }
230
+ if (kind !== undefined) tally.spans[kind] += Math.max(0, endTime - since)
231
+ return tally
232
+ }
233
+
234
+ /** The ORDERED decode spans of one embedded assistant stream (same ownership rule as the tally). */
235
+ function decodeSpansOfStream(stream, endTime) {
236
+ const spans = []
237
+ if (!Array.isArray(stream) || !Number.isFinite(endTime)) return spans
238
+ let kind
239
+ let since = 0
240
+ const close = to => {
241
+ if (kind !== undefined && to > since) spans.push({ kind, start: since, end: to })
242
+ }
243
+ for (const record of stream) {
244
+ if (record === null || typeof record !== 'object') continue
245
+ if (record.type !== 'chunk' || record.chunk === null || typeof record.chunk !== 'object') continue
246
+ const chunk = record.chunk
247
+ if (chunk.type !== 'block-start') continue
248
+ const time = record.time
249
+ if (typeof time !== 'number' || !Number.isFinite(time)) continue
250
+ close(time)
251
+ kind = decodeKindOfBlock(chunk.blockType)
252
+ since = time
253
+ }
254
+ close(endTime)
255
+ return spans
256
+ }
257
+
258
+ /** The first token's instant inside one packed delta run, or undefined. */
259
+ function runFirstTokenTime(record) {
260
+ const time0 = record.time0
261
+ if (typeof time0 !== 'number' || !Number.isFinite(time0)) return undefined
262
+ if (record.type === 'tool-call-chunks' && record.name !== undefined) return time0
263
+ const fragments = record.type === 'tool-call-chunks' ? record.args : record.texts
264
+ if (!Array.isArray(fragments)) return undefined
265
+ const dt = Array.isArray(record.dt) ? record.dt : []
266
+ let time = time0
267
+ for (let index = 0; index < fragments.length; index += 1) {
268
+ if (index > 0) {
269
+ const step = dt[index - 1]
270
+ if (typeof step !== 'number' || !Number.isFinite(step)) return undefined
271
+ time += step
272
+ }
273
+ if (typeof fragments[index] === 'string' && fragments[index] !== '') return time
274
+ }
275
+ return undefined
276
+ }
277
+
278
+ /** The first token's instant inside an embedded assistant stream, or undefined when it carries no token. */
279
+ function firstTokenTimeOfStream(stream) {
280
+ if (!Array.isArray(stream)) return undefined
281
+ for (const record of stream) {
282
+ if (record === null || typeof record !== 'object') continue
283
+ if (record.type === 'chunk') {
284
+ const time = record.time
285
+ if (typeof time === 'number' && Number.isFinite(time) && isTokenChunk(record.chunk)) return time
286
+ continue
287
+ }
288
+ const time = runFirstTokenTime(record)
289
+ if (time !== undefined) return time
290
+ }
291
+ return undefined
292
+ }
293
+
294
+ /** One provider-reported usage bucket as a non-negative integer, or null when unreadable. */
295
+ function tokenCountOf(value) {
296
+ if (typeof value === 'number') {
297
+ return Number.isFinite(value) ? Math.max(0, Math.round(value)) : null
298
+ }
299
+ if (typeof value === 'string' && value.trim() !== '') {
300
+ const parsed = Number(value)
301
+ return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : null
302
+ }
303
+ return null
304
+ }
305
+
306
+ /** A non-negative duration between two instants; 0 for non-finite input. */
307
+ function durOf(from, to) {
308
+ if (typeof from !== 'number' || typeof to !== 'number' || !Number.isFinite(from) || !Number.isFinite(to)) return 0
309
+ return Math.max(0, to - from)
310
+ }
311
+
312
+ /** Image blocks inside one user message (the activity ledger's `images`, unclipped). */
313
+ function countImageBlocks(message) {
314
+ if (message === null || typeof message !== 'object' || !Array.isArray(message.content)) return 0
315
+ let count = 0
316
+ for (const block of message.content) {
317
+ if (block !== null && typeof block === 'object' && block.type === 'image') count += 1
318
+ }
319
+ return count
320
+ }
321
+
322
+ function parseCallArgs(raw) {
323
+ if (typeof raw !== 'string' || raw === '') return null
324
+ try {
325
+ const value = JSON.parse(raw)
326
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null
327
+ } catch (_) { return null }
328
+ }
329
+
330
+ function fileCallMeta(name, raw) {
331
+ const args = parseCallArgs(raw)
332
+ if (args === null) return null
333
+ let kind = ''
334
+ if (name === 'read' || name === 'read_image') kind = 'read'
335
+ else if (name === 'write' || name === 'edit') kind = 'write'
336
+ else if (name === 'grep' || name === 'glob') kind = 'search'
337
+ else if (name === 'str_replace_editor') kind = args.command === 'view' ? 'read' : 'write'
338
+ if (kind === '') return null
339
+ let path = ''
340
+ if ((name === 'grep' || name === 'glob') && typeof args.path !== 'string') {
341
+ path = typeof args.pattern === 'string' ? args.pattern : ''
342
+ } else {
343
+ for (const key of ['file_path', 'filePath', 'path']) {
344
+ if (typeof args[key] === 'string' && args[key] !== '') { path = args[key]; break }
345
+ }
346
+ }
347
+ if (!path) return null
348
+ const added = name === 'write' ? lineCount(args.content)
349
+ : name === 'edit' ? lineCount(args.new_string)
350
+ : name === 'str_replace_editor' && ['str_replace', 'insert', 'create'].includes(args.command)
351
+ ? lineCount(args.command === 'create' ? args.file_text : args.new_str) : 0
352
+ const removed = name === 'edit' ? lineCount(args.old_string)
353
+ : name === 'str_replace_editor' && args.command === 'str_replace' ? lineCount(args.old_str) : 0
354
+ return {
355
+ kind, path: path.slice(0, 2000), added, removed,
356
+ ...(kind === 'search' && !(typeof args.path === 'string' && args.path !== '') ? { pattern: true } : {}),
357
+ ...(kind === 'read' && Number.isFinite(args.limit) && args.limit > 0 ? { readCount: Math.floor(args.limit) } : {}),
358
+ }
359
+ }
360
+
361
+ function lineCount(value) {
362
+ if (typeof value !== 'string' || value === '') return 0
363
+ const breaks = (value.match(/\n/g) || []).length
364
+ return value.endsWith('\n') ? breaks : breaks + 1
365
+ }
366
+
367
+ function fileOpsOf(event, message, pending) {
368
+ const meta = pending && pending.file
369
+ if (!meta) return []
370
+ const common = {
371
+ seq: event.seq, time: timeOf(event), path: meta.path, kind: meta.kind,
372
+ tool: pending.name, err: !!(message && message.isError === true),
373
+ added: meta.added, removed: meta.removed,
374
+ ...(meta.pattern === true ? { pattern: true } : {}),
375
+ ...(meta.readCount !== undefined ? { readCount: meta.readCount } : {}),
376
+ }
377
+ const eventMeta = event && event.data && event.data.meta
378
+ const resultMeta = message && message.meta !== null && typeof message.meta === 'object'
379
+ ? message.meta : eventMeta !== null && typeof eventMeta === 'object' ? eventMeta : null
380
+ if (meta.kind === 'search' && resultMeta && resultMeta.truncated === false) {
381
+ const found = []
382
+ if (resultMeta.shape === 'matches' && Array.isArray(resultMeta.files)) {
383
+ for (const row of resultMeta.files.slice(0, 100)) {
384
+ if (row && typeof row.path === 'string' && Array.isArray(row.matches)) found.push({ path: row.path.slice(0, 2000), hits: row.matches.length })
385
+ }
386
+ } else if (resultMeta.shape === 'paths' && Array.isArray(resultMeta.paths)) {
387
+ for (const path of resultMeta.paths.slice(0, 100)) if (typeof path === 'string') found.push({ path: path.slice(0, 2000), hits: 0 })
388
+ }
389
+ if (found.length > 0) return found.map(file => ({ ...common, ...file, pattern: false }))
390
+ }
391
+ if (meta.kind === 'read' && resultMeta && typeof resultMeta.offset === 'number'
392
+ && Array.isArray(resultMeta.lines) && resultMeta.lines.length > 0) {
393
+ common.readStart = Math.max(1, Math.floor(resultMeta.offset))
394
+ common.readCount = resultMeta.lines.length
395
+ }
396
+ return [common]
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Fold state
401
+ // ---------------------------------------------------------------------------
402
+
403
+ function emptyTiming() {
404
+ return {
405
+ ttftMs: 0,
406
+ reasoningMs: 0,
407
+ textMs: 0,
408
+ toolArgMs: 0,
409
+ toolsMs: 0,
410
+ waitCount: 0,
411
+ reasoningCount: 0,
412
+ textCount: 0,
413
+ toolArgCount: 0,
414
+ toolCount: 0,
415
+ speedTokens: 0,
416
+ speedMs: 0,
417
+ }
418
+ }
419
+
420
+ function emptyActivity() {
421
+ return { reads: 0, writes: 0, searches: 0, images: 0 }
422
+ }
423
+
424
+ function emptySurfaceCounts() {
425
+ return { user: 0, inject: 0, skill: 0, assistant: 0, tool: 0 }
426
+ }
427
+
428
+ /** A bounded, plain-JSON fold state (see the module header). */
429
+ export function createState() {
430
+ return {
431
+ surface: [],
432
+ archive: [],
433
+ headerEpochs: [],
434
+ fileOps: [],
435
+ sums: { user: 0, inject: 0, skill: 0, assistant: 0, tool: 0 },
436
+ systemTokens: 0,
437
+ toolsTokens: 0,
438
+ toolDefs: 0,
439
+ counts: { humanInputs: 0, answers: 0, skillLoads: 0, injects: 0, compactions: 0, prunes: 0, toolCalls: 0 },
440
+ activity: emptyActivity(),
441
+ timing: emptyTiming(),
442
+ surfaceCounts: emptySurfaceCounts(),
443
+ toolNames: {},
444
+ wallMs: 0,
445
+ callNames: {},
446
+ requests: [],
447
+ events: [],
448
+ spans: [],
449
+ truncated: { requests: false, events: false, surface: false },
450
+ }
451
+ }
452
+
453
+ function cloneState(state) {
454
+ return {
455
+ surface: [...state.surface],
456
+ archive: Array.isArray(state.archive) ? [...state.archive] : [],
457
+ ...(Number.isFinite(state.archiveFloor) ? { archiveFloor: state.archiveFloor } : {}),
458
+ headerEpochs: Array.isArray(state.headerEpochs) ? [...state.headerEpochs] : [],
459
+ fileOps: Array.isArray(state.fileOps) ? [...state.fileOps] : [],
460
+ ...(Number.isFinite(state.fileOpsFloor) ? { fileOpsFloor: state.fileOpsFloor } : {}),
461
+ sums: { ...state.sums },
462
+ systemTokens: state.systemTokens,
463
+ toolsTokens: state.toolsTokens,
464
+ toolDefs: safeInt(state.toolDefs),
465
+ ...(state.model !== undefined ? { model: state.model } : {}),
466
+ ...(state.provider !== undefined ? { provider: state.provider } : {}),
467
+ ...(state.contextWindow !== undefined ? { contextWindow: state.contextWindow } : {}),
468
+ counts: { ...state.counts },
469
+ activity: { ...emptyActivity(), ...(state.activity !== null && typeof state.activity === 'object' ? state.activity : {}) },
470
+ timing: { ...emptyTiming(), ...(state.timing !== null && typeof state.timing === 'object' ? state.timing : {}) },
471
+ surfaceCounts: { ...emptySurfaceCounts(), ...(state.surfaceCounts !== null && typeof state.surfaceCounts === 'object' ? state.surfaceCounts : {}) },
472
+ toolNames: { ...state.toolNames },
473
+ wallMs: state.wallMs,
474
+ callNames: { ...state.callNames },
475
+ requests: [...state.requests],
476
+ events: [...state.events],
477
+ // Shared copy-on-write: only `step/end` appends, and it re-slices before
478
+ // touching the array, so a shared reference can never be mutated.
479
+ spans: Array.isArray(state.spans) ? state.spans : [],
480
+ truncated: { ...state.truncated },
481
+ ...(typeof state.lastAnswer === 'string' && state.lastAnswer !== '' ? { lastAnswer: state.lastAnswer } : {}),
482
+ ...(state.stepStart !== undefined
483
+ ? {
484
+ stepStart: {
485
+ time: state.stepStart.time,
486
+ waits: Array.isArray(state.stepStart.waits) ? [...state.stepStart.waits] : [],
487
+ ...(typeof state.stepStart.firstToken === 'number' && Number.isFinite(state.stepStart.firstToken)
488
+ ? { firstToken: state.stepStart.firstToken }
489
+ : {}),
490
+ },
491
+ }
492
+ : {}),
493
+ ...(Array.isArray(state.stepSpans) ? { stepSpans: state.stepSpans.map(span => [span[0], span[1], span[2]]) } : {}),
494
+ ...(state.approvals !== undefined ? { approvals: { ...state.approvals } } : {}),
495
+ }
496
+ }
497
+
498
+ function deriveEventMessage(event) {
499
+ const data = event && event.data
500
+ if (data === null || typeof data !== 'object') return null
501
+ switch (event.type) {
502
+ case 'user/message':
503
+ return data
504
+ case 'system/message':
505
+ case 'developer/message':
506
+ case 'assistant/message': {
507
+ const message = data.message
508
+ if (message === null || typeof message !== 'object') return null
509
+ if (!Array.isArray(message.content) || message.content.length === 0) return null
510
+ return message
511
+ }
512
+ case 'tool/result':
513
+ return data.message !== null && typeof data.message === 'object' ? data.message : null
514
+ default:
515
+ return null
516
+ }
517
+ }
518
+
519
+ function isSurfaceEvent(event) {
520
+ return SURFACE_TYPES.has(event.type) && event.surfaceOp !== undefined
521
+ }
522
+
523
+ /** {start,end} for a replacement event, or null for an append / legacy spelling. */
524
+ function replaceRangeOf(surfaceOp) {
525
+ if (surfaceOp === null || typeof surfaceOp !== 'object') return null
526
+ if (surfaceOp.op !== 'replace') return null
527
+ const start = surfaceOp.startSeq
528
+ const end = surfaceOp.endSeq
529
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null
530
+ return { start, end }
531
+ }
532
+
533
+ function addNodeSums(state, node) {
534
+ if (node.cat === 'system') return
535
+ if (MESSAGE_CATS.includes(node.cat)) {
536
+ state.sums[node.cat] += node.tokens
537
+ state.surfaceCounts[node.cat] = (state.surfaceCounts[node.cat] || 0) + 1
538
+ }
539
+ }
540
+
541
+ function removeNodeSums(state, node) {
542
+ if (node.cat === 'system') return
543
+ if (MESSAGE_CATS.includes(node.cat)) {
544
+ state.sums[node.cat] = Math.max(0, state.sums[node.cat] - node.tokens)
545
+ state.surfaceCounts[node.cat] = Math.max(0, (state.surfaceCounts[node.cat] || 0) - 1)
546
+ }
547
+ }
548
+
549
+ function recomputeSystemTokens(state) {
550
+ for (let i = state.surface.length - 1; i >= 0; i -= 1) {
551
+ const node = state.surface[i]
552
+ if (node.cat === 'system' && node.tokens > 0) {
553
+ state.systemTokens = node.tokens
554
+ return
555
+ }
556
+ }
557
+ state.systemTokens = 0
558
+ }
559
+
560
+ function applySurface(state, event, cat, message) {
561
+ const node = { seq: event.seq, cat, tokens: estimateMessage(message) }
562
+ const eventTime = timeOf(event)
563
+ if (eventTime > 0) node.time = eventTime
564
+ const source = message !== null && typeof message === 'object' ? message.source : null
565
+ if (source !== null && typeof source === 'object') {
566
+ if (typeof source.form === 'string' && source.form !== '') node.form = source.form
567
+ if (typeof source.name === 'string' && source.name !== '') node.name = source.name.slice(0, 160)
568
+ }
569
+ if (cat === 'assistant') {
570
+ const answer = firstText(Array.isArray(message && message.content) ? message.content : [])
571
+ if (answer !== '') node.text = answer.replace(/\s+/g, ' ').trim().slice(0, MAX_LAST_ANSWER)
572
+ else {
573
+ const calls = Array.isArray(message && message.content)
574
+ ? message.content.filter(block => block && typeof block === 'object' && block.type === 'tool-call')
575
+ .map(block => typeof block.name === 'string' ? block.name.slice(0, 100) : '')
576
+ .filter(Boolean).slice(0, 3)
577
+ : []
578
+ if (calls.length > 0) node.calls = calls
579
+ }
580
+ } else if (cat === 'tool') {
581
+ const callId = source && typeof source.callId === 'string' ? source.callId : ''
582
+ const call = callId !== '' ? state.callNames[callId] : null
583
+ if (call && typeof call.name === 'string') node.tool = call.name.slice(0, 100)
584
+ if (message && message.isError === true) node.err = true
585
+ }
586
+ const images = countImageBlocks(message)
587
+ if (images > 0) node.imgs = images
588
+ const range = replaceRangeOf(event.surfaceOp)
589
+ if (range !== null) {
590
+ let startIndex = -1
591
+ let endIndex = -1
592
+ for (let i = 0; i < state.surface.length; i += 1) {
593
+ if (startIndex < 0 && state.surface[i].seq === range.start) startIndex = i
594
+ if (state.surface[i].seq === range.end) {
595
+ endIndex = i
596
+ break
597
+ }
598
+ }
599
+ if (startIndex >= 0 && endIndex >= startIndex) {
600
+ const removed = state.surface.splice(startIndex, endIndex - startIndex + 1, node)
601
+ for (const old of removed) {
602
+ removeNodeSums(state, old)
603
+ state.archive.push({ ...old, gone: event.seq })
604
+ for (let index = 0; index < state.fileOps.length; index += 1) {
605
+ const op = state.fileOps[index]
606
+ if (op.seq === old.seq && op.gone === undefined) state.fileOps[index] = { ...op, gone: event.seq }
607
+ }
608
+ }
609
+ addNodeSums(state, node)
610
+ if (cat === 'system') recomputeSystemTokens(state)
611
+ trimState(state)
612
+ return node
613
+ }
614
+ }
615
+ state.surface.push(node)
616
+ addNodeSums(state, node)
617
+ if (cat === 'system') recomputeSystemTokens(state)
618
+ return node
619
+ }
620
+
621
+ function trimState(state) {
622
+ if (state.surface.length > MAX_SURFACE) {
623
+ const drop = state.surface.length - MAX_SURFACE
624
+ const removed = state.surface.splice(0, drop)
625
+ for (const old of removed) removeNodeSums(state, old)
626
+ state.truncated.surface = true
627
+ recomputeSystemTokens(state)
628
+ }
629
+ if (state.requests.length > MAX_REQUESTS) {
630
+ state.requests.splice(0, state.requests.length - MAX_REQUESTS)
631
+ state.truncated.requests = true
632
+ }
633
+ if (state.events.length > MAX_EVENTS) {
634
+ state.events.splice(0, state.events.length - MAX_EVENTS)
635
+ state.truncated.events = true
636
+ }
637
+ if (Array.isArray(state.spans) && state.spans.length > MAX_SPANS) {
638
+ state.spans = state.spans.slice(state.spans.length - MAX_SPANS)
639
+ }
640
+ if (Array.isArray(state.archive) && state.archive.length > MAX_ARCHIVE_NODES) {
641
+ const removed = state.archive.splice(0, state.archive.length - MAX_ARCHIVE_NODES)
642
+ for (const node of removed) state.archiveFloor = Math.max(state.archiveFloor || 0, safeInt(node.gone))
643
+ }
644
+ if (Array.isArray(state.headerEpochs) && state.headerEpochs.length > MAX_HEADER_EPOCHS) {
645
+ state.headerEpochs.splice(0, state.headerEpochs.length - MAX_HEADER_EPOCHS)
646
+ }
647
+ if (Array.isArray(state.fileOps) && state.fileOps.length > MAX_FILE_OPS) {
648
+ const removed = state.fileOps.splice(0, state.fileOps.length - MAX_FILE_OPS)
649
+ for (const op of removed) state.fileOpsFloor = Math.max(state.fileOpsFloor || 0, safeInt(op.seq))
650
+ }
651
+ if (Object.keys(state.callNames).length > MAX_CALL_NAMES) {
652
+ const keys = Object.keys(state.callNames)
653
+ for (const key of keys.slice(0, keys.length - MAX_CALL_NAMES)) delete state.callNames[key]
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Buffer one span of the open step (`[kind, start, end]`, absolute ms). The
659
+ * buffer is bounded: a hostile stream or replay cannot grow the persisted state,
660
+ * and a dropped span simply becomes part of the step/end residue.
661
+ */
662
+ function pushStepSpan(state, span) {
663
+ if (!Array.isArray(span)) return
664
+ const start = span[1]
665
+ const end = span[2]
666
+ if (typeof start !== 'number' || !Number.isFinite(start)) return
667
+ if (typeof end !== 'number' || !Number.isFinite(end) || end <= start) return
668
+ if (!Array.isArray(state.stepSpans)) state.stepSpans = []
669
+ if (state.stepSpans.length >= MAX_STEP_SPANS) return
670
+ state.stepSpans.push([span[0], start, end])
671
+ }
672
+
673
+ /** A non-negative integer for the wire; non-finite/hostile input degrades to 0. */
674
+ function safeInt(value) {
675
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : 0
676
+ }
677
+
678
+ function firstText(blocks) {
679
+ if (!Array.isArray(blocks)) return ''
680
+ for (const block of blocks) {
681
+ if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
682
+ return block.text
683
+ }
684
+ }
685
+ return ''
686
+ }
687
+
688
+ function skillNameOf(message) {
689
+ if (message === null || typeof message !== 'object') return ''
690
+ const text = firstText(Array.isArray(message.content) ? message.content : [])
691
+ const match = text.match(/<skill_content\s+name="([^"]+)"/)
692
+ return match === null ? '' : match[1]
693
+ }
694
+
695
+ function isInjectionSource(source) {
696
+ if (source === null || source === undefined || typeof source !== 'object') return false
697
+ return (typeof source.kind === 'string' && source.kind !== '' && source.kind !== 'user') || typeof source.form === 'string'
698
+ }
699
+
700
+ function injectionSourceName(source) {
701
+ if (source === null || source === undefined || typeof source !== 'object') return ''
702
+ if (source.kind === 'agent-instructions' && Array.isArray(source.changes)) {
703
+ const paths = []
704
+ for (const change of source.changes) {
705
+ const path = change && typeof change.path === 'string' ? change.path : ''
706
+ if (path !== '' && !paths.includes(path)) paths.push(path)
707
+ }
708
+ if (paths.length > 0) return paths.join(', ')
709
+ }
710
+ if (typeof source.plugin === 'string' && source.plugin !== '') return source.plugin
711
+ if (typeof source.kind === 'string' && source.kind !== '') return source.kind
712
+ return ''
713
+ }
714
+
715
+ function clampWaitMs(stepStart, endTime) {
716
+ const waits = Array.isArray(stepStart.waits) ? stepStart.waits : []
717
+ if (waits.length === 0) return 0
718
+ const spans = waits
719
+ .map(wait => ({ start: Math.max(stepStart.time, wait.start), end: Math.min(endTime, wait.end) }))
720
+ .filter(span => span.end > span.start)
721
+ .sort((a, b) => (a.start - b.start) || (a.end - b.end))
722
+ let total = 0
723
+ let cursor = -Infinity
724
+ for (const span of spans) {
725
+ const start = Math.max(span.start, cursor)
726
+ if (span.end > start) {
727
+ total += span.end - start
728
+ cursor = span.end
729
+ }
730
+ }
731
+ return total
732
+ }
733
+
734
+ /**
735
+ * Fold one committed session event. Returns the same state reference when the
736
+ * event cannot change the served value (the registry's change feed relies on
737
+ * `Object.is`), otherwise a fresh bounded state.
738
+ *
739
+ * @param {object} state previous `createState()`-shaped state
740
+ * @param {{type:string,seq:number,time?:number,data?:object,surfaceOp?:unknown}} event
741
+ * @returns {object} next state
742
+ */
743
+ export function applyContextEvent(state, event) {
744
+ if (state === null || typeof state !== 'object') state = createState()
745
+ if (event === null || typeof event !== 'object' || typeof event.type !== 'string') return state
746
+ const data = event.data !== null && typeof event.data === 'object' ? event.data : undefined
747
+ try {
748
+ switch (event.type) {
749
+ case 'request/header': {
750
+ const header = data && data.header !== null && typeof data.header === 'object' ? data.header : undefined
751
+ const next = cloneState(state)
752
+ next.toolsTokens = estimateToolsTokens(header)
753
+ next.toolDefs = header !== undefined && Array.isArray(header.tools) ? header.tools.length : 0
754
+ if (header !== undefined) {
755
+ const tools = Array.isArray(header.tools) ? header.tools.slice(0, 100).map(tool => {
756
+ const item = tool !== null && typeof tool === 'object' ? tool : {}
757
+ return { name: typeof item.name === 'string' ? item.name.slice(0, 120) : '?' }
758
+ }) : []
759
+ next.headerEpochs.push({ seq: event.seq, time: timeOf(event), tools })
760
+ }
761
+ const config = header && header.config !== null && typeof header.config === 'object' ? header.config : undefined
762
+ if (config && typeof config.model === 'string') next.model = config.model
763
+ if (config && typeof config.provider === 'string') next.provider = config.provider
764
+ return next
765
+ }
766
+ case 'request/context': {
767
+ if (data === undefined) return state
768
+ let next
769
+ if (typeof data.contextWindow === 'number') {
770
+ next = next || cloneState(state)
771
+ next.contextWindow = data.contextWindow
772
+ }
773
+ if (typeof data.model === 'string') {
774
+ next = next || cloneState(state)
775
+ next.model = data.model
776
+ }
777
+ if (typeof data.provider === 'string') {
778
+ next = next || cloneState(state)
779
+ next.provider = data.provider
780
+ }
781
+ return next || state
782
+ }
783
+ case 'system/message': {
784
+ if (!isSurfaceEvent(event)) return state
785
+ const next = cloneState(state)
786
+ applySurface(next, event, 'system', deriveEventMessage(event))
787
+ trimState(next)
788
+ return next
789
+ }
790
+ case 'user/message':
791
+ case 'developer/message': {
792
+ const message = deriveEventMessage(event)
793
+ // The image tally is independent of the CURRENT surface: the desktop
794
+ // activity card counts every user upload in the log, including ones a
795
+ // later compaction replaced (docs §8.1).
796
+ const images = event.type === 'user/message' ? countImageBlocks(message) : 0
797
+ if (!isSurfaceEvent(event)) {
798
+ if (images === 0) return state
799
+ const next = cloneState(state)
800
+ next.activity.images += images
801
+ return next
802
+ }
803
+ const source = message && message.source
804
+ // Skill machinery is its own bucket: skill invocations and the
805
+ // available-skills catalog are durable user/message injections that the
806
+ // plain injected-context check would absorb.
807
+ const isSkillSource = source !== undefined && source !== null
808
+ && (source.kind === 'skill-invocation' || source.kind === 'skill-catalog')
809
+ const cat = event.type === 'developer/message' || isInjectionSource(source)
810
+ ? (isSkillSource ? 'skill' : 'inject')
811
+ : 'user'
812
+ const next = cloneState(state)
813
+ if (images > 0) next.activity.images += images
814
+ applySurface(next, event, cat, message)
815
+ if (event.type === 'developer/message' || isInjectionSource(source)) {
816
+ const source = message && message.source
817
+ const record = { seq: event.seq, time: timeOf(event), kind: 'inject' }
818
+ const form = source && typeof source.form === 'string' && source.form !== '' ? source.form : 'context'
819
+ record.form = form
820
+ if (source && source.kind === 'skill-invocation') {
821
+ record.sub = 'skill'
822
+ record.name = typeof source.name === 'string' ? source.name : '?'
823
+ next.counts.skillLoads += 1
824
+ } else if (message !== null) {
825
+ const label = injectionSourceName(source)
826
+ if (label !== '') record.name = label
827
+ }
828
+ next.events.push(record)
829
+ next.counts.injects += 1
830
+ } else {
831
+ next.counts.humanInputs += 1
832
+ }
833
+ trimState(next)
834
+ return next
835
+ }
836
+ case 'assistant/message': {
837
+ const message = deriveEventMessage(event)
838
+ const eventTime = timeOf(event)
839
+ const usage = data && data.usage !== null && typeof data.usage === 'object' ? data.usage : null
840
+ // `null` means "no readable output bucket", not a fabricated 0 (see the
841
+ // output-speed rule: it only counts when provider usage is readable).
842
+ const output = usage !== null ? tokenCountOf(usage.outputTokens) : null
843
+ const answerText = firstText(message && message.content)
844
+ const next = cloneState(state)
845
+ // The request record is the prompt this response was assembled for:
846
+ // capture it BEFORE the response joins the surface.
847
+ const record = {
848
+ seq: event.seq,
849
+ time: eventTime,
850
+ ...(data && Number.isFinite(data.turn) ? { turn: data.turn } : {}),
851
+ ...(data && Number.isFinite(data.step) ? { step: data.step } : {}),
852
+ system: next.systemTokens,
853
+ tools: next.toolsTokens,
854
+ total: next.systemTokens + next.toolsTokens
855
+ + next.sums.user + next.sums.inject + next.sums.skill + next.sums.assistant + next.sums.tool,
856
+ }
857
+ for (const cat of MESSAGE_CATS) {
858
+ if (next.sums[cat] > 0) record[cat] = next.sums[cat]
859
+ }
860
+ // v2 request detail (docs §8.1): the surface node counts the request was
861
+ // assembled from (`system` is always the one live system prompt, `tools`
862
+ // the current header's tool definition count) and the provider-reported
863
+ // billed prompt / output buckets.
864
+ const items = { system: 1, tools: next.toolDefs }
865
+ for (const cat of MESSAGE_CATS) {
866
+ if (next.surfaceCounts[cat] > 0) items[cat] = next.surfaceCounts[cat]
867
+ }
868
+ record.items = items
869
+ if (usage !== null) {
870
+ const input = tokenCountOf(usage.inputTokens)
871
+ const cacheRead = tokenCountOf(usage.cacheReadTokens)
872
+ const cacheWrite = tokenCountOf(usage.cacheWriteTokens)
873
+ if (input !== null || cacheRead !== null || cacheWrite !== null || output !== null) {
874
+ record.prompt = (input ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0)
875
+ if (output !== null) record.output = output
876
+ }
877
+ }
878
+ next.requests.push(record)
879
+ // v2 last answer: the newest assistant text, whitespace-collapsed and
880
+ // bounded; tool-only steps leave the previous preview in place.
881
+ if (answerText !== '') {
882
+ const compressed = answerText.replace(/\s+/g, ' ').trim()
883
+ if (compressed !== '') {
884
+ next.lastAnswer = compressed.length > MAX_LAST_ANSWER ? compressed.slice(0, MAX_LAST_ANSWER) : compressed
885
+ }
886
+ }
887
+ // v2 timing split (docs §8.1): only an open step with an observable
888
+ // first token is priced; an unattributed stream lands in the card's
889
+ // `other` residue instead of charging the wait.
890
+ const stepStart = state.stepStart
891
+ if (stepStart !== undefined && Number.isFinite(stepStart.time)) {
892
+ const stream = data && data.stream
893
+ const firstToken = typeof stepStart.firstToken === 'number'
894
+ ? stepStart.firstToken
895
+ : firstTokenTimeOfStream(stream)
896
+ if (firstToken !== undefined) {
897
+ const decodeSpans = decodeSpansOfStream(stream, eventTime)
898
+ let decodeStart = firstToken
899
+ for (const span of decodeSpans) decodeStart = Math.min(decodeStart, span.start)
900
+ next.timing.ttftMs += durOf(stepStart.time, decodeStart)
901
+ next.timing.waitCount += 1
902
+ pushStepSpan(next, [SPAN_WAIT, stepStart.time, decodeStart])
903
+ // The decode blocks tile [decodeStart, eventTime] in stream order; the
904
+ // first block reaches back to the wait boundary, later ones keep their
905
+ // own start so the rows and the strip read the same window.
906
+ let pushedAny = false
907
+ for (const span of decodeSpans) {
908
+ const to = Math.min(eventTime, span.end)
909
+ if (to <= span.start) continue
910
+ const start = pushedAny ? span.start : decodeStart
911
+ if (to > start) {
912
+ pushStepSpan(next, [DECODE_SPAN_KINDS[span.kind], start, to])
913
+ pushedAny = true
914
+ }
915
+ }
916
+ const tally = decodeTallyOfStream(stream, eventTime)
917
+ next.timing.reasoningMs += tally.spans.reasoning
918
+ next.timing.reasoningCount += tally.blocks.reasoning
919
+ next.timing.textMs += tally.spans.text
920
+ next.timing.textCount += tally.blocks.text
921
+ next.timing.toolArgMs += tally.spans.toolarg
922
+ next.timing.toolArgCount += tally.blocks.toolarg
923
+ // Output speed pairs the first-token anchor with the provider's own
924
+ // output count; it only counts when both are known.
925
+ if (output !== null) {
926
+ next.timing.speedMs += durOf(firstToken, eventTime)
927
+ next.timing.speedTokens += output
928
+ }
929
+ }
930
+ }
931
+ if (isSurfaceEvent(event)) {
932
+ const node = applySurface(next, event, 'assistant', message)
933
+ if (node.tokens > 0 && answerText !== '') next.counts.answers += 1
934
+ }
935
+ trimState(next)
936
+ return next
937
+ }
938
+ case 'assistant/attempt': {
939
+ // An attempt that committed no surface message can still carry the
940
+ // step's first token (a retried / aborted call): stamp it on the open
941
+ // step so TTFT stays real instead of falling into the residue.
942
+ const start = state.stepStart
943
+ if (start === undefined || typeof start.firstToken === 'number') return state
944
+ const first = firstTokenTimeOfStream(data && data.stream)
945
+ if (first === undefined) return state
946
+ const next = cloneState(state)
947
+ next.stepStart.firstToken = first
948
+ return next
949
+ }
950
+ case 'tool/call': {
951
+ const callId = data && typeof data.callId === 'string' ? data.callId : ''
952
+ const name = data && typeof data.name === 'string' ? data.name : ''
953
+ if (callId === '' || name === '') return state
954
+ const next = cloneState(state)
955
+ const file = fileCallMeta(name, data && data.arguments)
956
+ next.callNames[callId] = { name, start: timeOf(event), ...(file !== null ? { file } : {}) }
957
+ trimState(next)
958
+ return next
959
+ }
960
+ case 'tool/result': {
961
+ const message = deriveEventMessage(event)
962
+ const source = message && message.source !== null && typeof message.source === 'object' ? message.source : undefined
963
+ const callId = source && typeof source.callId === 'string' ? source.callId : ''
964
+ const pending = callId !== '' ? state.callNames[callId] : undefined
965
+ const eventTime = timeOf(event)
966
+ if (!isSurfaceEvent(event)) {
967
+ if (pending === undefined) return state
968
+ const ops = fileOpsOf(event, message, pending)
969
+ if (ops.length === 0) return state
970
+ const next = cloneState(state)
971
+ next.fileOps.push(...ops)
972
+ if (callId !== '') delete next.callNames[callId]
973
+ trimState(next)
974
+ return next
975
+ }
976
+ const next = cloneState(state)
977
+ const node = applySurface(next, event, 'tool', message)
978
+ if (pending !== undefined) {
979
+ next.fileOps.push(...fileOpsOf(event, message, pending))
980
+ next.counts.toolCalls += 1
981
+ next.toolNames[pending.name] = (next.toolNames[pending.name] || 0) + 1
982
+ // v2 activity: classify the paired run by its durable tool name.
983
+ const activityKey = toolActivityOf(pending.name)
984
+ if (activityKey !== '') next.activity[activityKey] += 1
985
+ // v2 tool timing: the paired run's wall slice, priced on the same
986
+ // call/result pairing the tool tallies use.
987
+ next.timing.toolsMs += durOf(pending.start, eventTime)
988
+ next.timing.toolCount += 1
989
+ pushStepSpan(next, [SPAN_TOOLS, pending.start, eventTime])
990
+ if (pending.name === 'ask_user_question' && next.stepStart !== undefined) {
991
+ next.stepStart.waits.push({ start: pending.start, end: eventTime })
992
+ }
993
+ if (callId !== '') delete next.callNames[callId]
994
+ // A skill tool's result is harness-injected skill content: give it the
995
+ // skill bucket instead of burying it among ordinary tool results.
996
+ if (pending.name === 'skill') {
997
+ const skillName = skillNameOf(message)
998
+ if (skillName !== '') {
999
+ next.sums.tool = Math.max(0, next.sums.tool - node.tokens)
1000
+ node.cat = 'skill'
1001
+ next.sums.skill += node.tokens
1002
+ next.surfaceCounts.tool = Math.max(0, next.surfaceCounts.tool - 1)
1003
+ next.surfaceCounts.skill += 1
1004
+ next.counts.skillLoads += 1
1005
+ next.events.push({ seq: event.seq, time: eventTime, kind: 'inject', form: 'instructions', sub: 'skill', name: skillName })
1006
+ next.counts.injects += 1
1007
+ }
1008
+ }
1009
+ } else if (node.tool !== undefined) {
1010
+ // No paired call (a trimmed window): still count the tool run.
1011
+ next.counts.toolCalls += 1
1012
+ next.toolNames[node.tool] = (next.toolNames[node.tool] || 0) + 1
1013
+ }
1014
+ trimState(next)
1015
+ return next
1016
+ }
1017
+ case 'step/start': {
1018
+ const next = cloneState(state)
1019
+ next.stepStart = { time: timeOf(event), waits: [] }
1020
+ // A superseded step's span buffer belongs to the old window.
1021
+ delete next.stepSpans
1022
+ return next
1023
+ }
1024
+ case 'step/end': {
1025
+ if (state.stepStart === undefined) return state
1026
+ const next = cloneState(state)
1027
+ const start = state.stepStart
1028
+ const endTime = timeOf(event)
1029
+ const duration = Math.max(0, endTime - start.time)
1030
+ next.wallMs = state.wallMs + Math.max(0, duration - clampWaitMs(start, endTime))
1031
+ // Tile the step's span buffer across [step start, step end]: first-wins
1032
+ // de-overlap so parallel tool runs paint their union once, and every hole
1033
+ // is filled with the `other` residue so the strip stays gapless.
1034
+ const painted = []
1035
+ for (const span of Array.isArray(state.stepSpans) ? state.stepSpans : []) {
1036
+ const from = Math.max(start.time, span[1])
1037
+ const to = Math.min(endTime, span[2])
1038
+ if (to > from) painted.push([span[0], from, to])
1039
+ }
1040
+ painted.sort((left, right) => (left[1] - right[1]) || (left[2] - right[2]))
1041
+ next.spans = state.spans.slice()
1042
+ let cursor = start.time
1043
+ for (const span of painted) {
1044
+ const from = Math.max(cursor, span[1])
1045
+ if (span[2] <= from) continue
1046
+ if (from > cursor) next.spans.push([SPAN_OTHER, cursor, from])
1047
+ next.spans.push([span[0], from, span[2]])
1048
+ cursor = span[2]
1049
+ }
1050
+ if (endTime > cursor) next.spans.push([SPAN_OTHER, cursor, endTime])
1051
+ if (next.spans.length > MAX_SPANS) next.spans = next.spans.slice(next.spans.length - MAX_SPANS)
1052
+ delete next.stepStart
1053
+ delete next.stepSpans
1054
+ if (next.approvals !== undefined && Object.keys(next.approvals).length === 0) delete next.approvals
1055
+ return next
1056
+ }
1057
+ case 'approval/asked': {
1058
+ if (state.stepStart === undefined) return state
1059
+ const id = data && typeof data.id === 'string' ? data.id : ''
1060
+ if (id === '') return state
1061
+ const next = cloneState(state)
1062
+ next.approvals = { ...(next.approvals || {}), [id]: timeOf(event) }
1063
+ return next
1064
+ }
1065
+ case 'approval/decided': {
1066
+ const id = data && typeof data.id === 'string' ? data.id : ''
1067
+ const askedAt = id !== '' && state.approvals !== undefined ? state.approvals[id] : undefined
1068
+ if (askedAt === undefined || state.stepStart === undefined) return state
1069
+ const next = cloneState(state)
1070
+ next.stepStart.waits.push({ start: askedAt, end: timeOf(event) })
1071
+ delete next.approvals[id]
1072
+ return next
1073
+ }
1074
+ case 'compaction/summary':
1075
+ case 'compaction/prune': {
1076
+ const next = cloneState(state)
1077
+ const compaction = event.type === 'compaction/summary'
1078
+ next.events.push({
1079
+ seq: event.seq,
1080
+ time: timeOf(event),
1081
+ kind: compaction ? 'compaction' : 'prune',
1082
+ ...(data && Number.isFinite(data.shadowedTokenCount) ? { tokens: data.shadowedTokenCount } : {}),
1083
+ ...(compaction && data && Array.isArray(data.shadowedSeqs) ? { count: data.shadowedSeqs.length } : {}),
1084
+ })
1085
+ if (compaction) next.counts.compactions += 1
1086
+ else next.counts.prunes += 1
1087
+ trimState(next)
1088
+ return next
1089
+ }
1090
+ default:
1091
+ return state
1092
+ }
1093
+ } catch (_) {
1094
+ // Durable logs are untrusted input and the registry drives this fold with
1095
+ // no error boundary: drop a malformed event whole instead of stalling.
1096
+ return state
1097
+ }
1098
+ }
1099
+
1100
+ function timeOf(event) {
1101
+ return typeof event.time === 'number' && Number.isFinite(event.time) ? event.time : 0
1102
+ }
1103
+
1104
+ function sortedToolNames(state) {
1105
+ return Object.entries(state.toolNames)
1106
+ .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
1107
+ .slice(0, MAX_TOOL_NAMES)
1108
+ .map(([name, count]) => ({ name, count }))
1109
+ }
1110
+
1111
+ /** The projection's wire value: a detached, bounded, lossless JSON snapshot. */
1112
+ export function contextView(state) {
1113
+ const current = {
1114
+ system: state.systemTokens,
1115
+ tools: state.toolsTokens,
1116
+ user: state.sums.user,
1117
+ inject: state.sums.inject,
1118
+ skill: state.sums.skill,
1119
+ assistant: state.sums.assistant,
1120
+ tool: state.sums.tool,
1121
+ }
1122
+ current.total = current.system + current.tools + current.user + current.inject + current.skill + current.assistant + current.tool
1123
+ const activity = state.activity !== null && typeof state.activity === 'object' ? state.activity : {}
1124
+ const timingRaw = state.timing !== null && typeof state.timing === 'object' ? state.timing : {}
1125
+ // The six-way split: the five measured buckets plus the residue, so the card's
1126
+ // percentages always add up and `otherMs` is never negative (docs §8.1).
1127
+ const wallMs = safeInt(state.wallMs)
1128
+ const ttftMs = safeInt(timingRaw.ttftMs)
1129
+ const reasoningMs = safeInt(timingRaw.reasoningMs)
1130
+ const textMs = safeInt(timingRaw.textMs)
1131
+ const toolArgMs = safeInt(timingRaw.toolArgMs)
1132
+ const toolsMs = safeInt(timingRaw.toolsMs)
1133
+ const measuredMs = ttftMs + reasoningMs + textMs + toolArgMs + toolsMs
1134
+ return {
1135
+ v: 1,
1136
+ ...(state.model !== undefined ? { model: state.model } : {}),
1137
+ ...(state.provider !== undefined ? { provider: state.provider } : {}),
1138
+ ...(state.contextWindow !== undefined ? { contextWindow: state.contextWindow } : {}),
1139
+ current,
1140
+ counts: {
1141
+ humanInputs: state.counts.humanInputs,
1142
+ answers: state.counts.answers,
1143
+ toolCalls: state.counts.toolCalls,
1144
+ skillLoads: state.counts.skillLoads,
1145
+ injects: state.counts.injects,
1146
+ compactions: state.counts.compactions,
1147
+ prunes: state.counts.prunes,
1148
+ },
1149
+ // v2 activity ledger (docs §8.1): `userInputs` mirrors the v1 `humanInputs`.
1150
+ activity: {
1151
+ userInputs: state.counts.humanInputs,
1152
+ reads: safeInt(activity.reads),
1153
+ writes: safeInt(activity.writes),
1154
+ searches: safeInt(activity.searches),
1155
+ images: safeInt(activity.images),
1156
+ },
1157
+ tools: { total: state.counts.toolCalls, names: sortedToolNames(state) },
1158
+ timing: {
1159
+ wallMs,
1160
+ ttftMs,
1161
+ reasoningMs,
1162
+ textMs,
1163
+ toolArgMs,
1164
+ toolsMs,
1165
+ otherMs: Math.max(0, wallMs - measuredMs),
1166
+ waitCount: safeInt(timingRaw.waitCount),
1167
+ reasoningCount: safeInt(timingRaw.reasoningCount),
1168
+ textCount: safeInt(timingRaw.textCount),
1169
+ toolArgCount: safeInt(timingRaw.toolArgCount),
1170
+ toolCount: safeInt(timingRaw.toolCount),
1171
+ speedTokens: safeInt(timingRaw.speedTokens),
1172
+ speedMs: safeInt(timingRaw.speedMs),
1173
+ },
1174
+ // v2 timeline: newest MAX_SPANS segments, each [kind, start, end].
1175
+ spans: (Array.isArray(state.spans) ? state.spans : []).map(span => [span[0], span[1], span[2]]),
1176
+ // Slim browser index; full message/header bodies are fetched only when a row is opened.
1177
+ surface: (Array.isArray(state.surface) ? state.surface : []).map(node => ({ ...node })),
1178
+ archive: (Array.isArray(state.archive) ? state.archive : []).map(node => ({ ...node })),
1179
+ headerEpochs: (Array.isArray(state.headerEpochs) ? state.headerEpochs : []).map(epoch => ({
1180
+ ...epoch,
1181
+ tools: Array.isArray(epoch.tools) ? epoch.tools.map(tool => ({ ...tool })) : [],
1182
+ })),
1183
+ fileOps: (Array.isArray(state.fileOps) ? state.fileOps : []).map(op => ({ ...op })),
1184
+ ...(Number.isFinite(state.archiveFloor) ? { archiveFloor: state.archiveFloor } : {}),
1185
+ ...(Number.isFinite(state.fileOpsFloor) ? { fileOpsFloor: state.fileOpsFloor } : {}),
1186
+ ...(typeof state.lastAnswer === 'string' && state.lastAnswer !== '' ? { lastAnswer: state.lastAnswer } : {}),
1187
+ requests: state.requests.map(record => ({
1188
+ ...record,
1189
+ ...(record.items !== undefined && record.items !== null ? { items: { ...record.items } } : {}),
1190
+ })),
1191
+ events: state.events.map(record => ({ ...record })),
1192
+ truncated: { ...state.truncated },
1193
+ }
1194
+ }
1195
+
1196
+ /** The `mobileContext` projection definition registered on `ctx.sessionProjections`. */
1197
+ export const contextProjectionDefinition = {
1198
+ key: 'mobileContext',
1199
+ // v4 adds bounded file-operation records; older cache rows refold to populate File Activity.
1200
+ stateVersion: 4,
1201
+ stateSchema: passthroughSchema,
1202
+ init: () => createState(),
1203
+ apply: (state, event) => applyContextEvent(state, event),
1204
+ wire: { viewSchema: passthroughSchema, view: state => contextView(state) },
1205
+ }
1206
+
1207
+
1208
+ // ---------------------------------------------------------------------------
1209
+ // Fleet (agent family) data
1210
+ // ---------------------------------------------------------------------------
1211
+
1212
+ /** Harness services captured from the plugin context (absent on older hosts). */
1213
+ let hostSessions = null
1214
+ let hostProjections = null
1215
+ let hostAgents = null
1216
+
1217
+ /**
1218
+ * Capture the host services the fleet query needs. Registration is optional and
1219
+ * lifecycle-bound: when a service is absent the query answers context-unavailable.
1220
+ *
1221
+ * @param {object} ctx gateway plugin context
1222
+ * @returns {boolean} whether an injection was attempted
1223
+ */
1224
+ export function registerContextServices(ctx) {
1225
+ if (ctx === null || typeof ctx !== 'object' || typeof ctx.inject !== 'function') return false
1226
+ try {
1227
+ ctx.inject(['sessions', 'sessionProjections'], (sub) => {
1228
+ if (!sub || !sub.sessions || !sub.sessionProjections) return
1229
+ hostSessions = sub.sessions
1230
+ hostProjections = sub.sessionProjections
1231
+ sub.effect(() => () => {
1232
+ hostSessions = null
1233
+ hostProjections = null
1234
+ })
1235
+ })
1236
+ } catch (_) { /* optional service */ }
1237
+ try {
1238
+ ctx.inject(['agents'], (sub) => {
1239
+ if (!sub || !sub.agents) return
1240
+ hostAgents = sub.agents
1241
+ sub.effect(() => () => { hostAgents = null })
1242
+ })
1243
+ } catch (_) { /* optional service */ }
1244
+ return true
1245
+ }
1246
+
1247
+ const MAX_FLEET_AGENTS = 40
1248
+
1249
+ function fleetNumber(value) {
1250
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
1251
+ }
1252
+
1253
+ /**
1254
+ * Bound one host `agentTeam` projection value to the wire shape docs §8.2
1255
+ * names: at most MAX_TEAM_ROWS members and MAX_TEAM_ROWS tasks, minimal fields,
1256
+ * always a plain-JSON object. Returns null when the value carries no roster.
1257
+ */
1258
+ function teamOf(agentTeam) {
1259
+ if (agentTeam === null || typeof agentTeam !== 'object' || Array.isArray(agentTeam)) return null
1260
+ if (!Array.isArray(agentTeam.members) && !Array.isArray(agentTeam.tasks)) return null
1261
+ const members = []
1262
+ for (const member of Array.isArray(agentTeam.members) ? agentTeam.members : []) {
1263
+ if (members.length >= MAX_TEAM_ROWS) break
1264
+ if (member === null || typeof member !== 'object') continue
1265
+ if (typeof member.id !== 'string' || member.id === '') continue
1266
+ const entry = {
1267
+ id: member.id,
1268
+ name: typeof member.name === 'string' ? member.name : '',
1269
+ role: member.role === 'lead' ? 'lead' : 'teammate',
1270
+ phase: TEAM_PHASES.has(member.phase) ? member.phase : 'active',
1271
+ }
1272
+ if (typeof member.error === 'string' && member.error !== '') entry.error = member.error
1273
+ members.push(entry)
1274
+ }
1275
+ const tasks = []
1276
+ for (const task of Array.isArray(agentTeam.tasks) ? agentTeam.tasks : []) {
1277
+ if (tasks.length >= MAX_TEAM_ROWS) break
1278
+ if (task === null || typeof task !== 'object') continue
1279
+ if (typeof task.id !== 'string' || task.id === '') continue
1280
+ const entry = {
1281
+ id: task.id,
1282
+ subject: typeof task.subject === 'string' ? task.subject : '',
1283
+ status: typeof task.status === 'string' ? task.status : 'pending',
1284
+ }
1285
+ if (typeof task.ownerName === 'string' && task.ownerName !== '') entry.ownerName = task.ownerName
1286
+ if (typeof task.ownerId === 'string' && task.ownerId !== '') entry.ownerId = task.ownerId
1287
+ tasks.push(entry)
1288
+ }
1289
+ return {
1290
+ members,
1291
+ tasks,
1292
+ failure: typeof agentTeam.failure === 'string' ? agentTeam.failure : '',
1293
+ }
1294
+ }
1295
+
1296
+ /**
1297
+ * Pure fleet payload builder: `records` are session headers
1298
+ * `{id, parentSessionId, origin, createdAt, title?}`; `snapshotOf(id)` returns
1299
+ * one session's projection values (or null); `runningOf(id)` returns whether
1300
+ * that agent is live.
1301
+ *
1302
+ * @param {string} currentSessionId
1303
+ * @param {Array<object>} records
1304
+ * @param {(id: string) => (object|null)} snapshotOf
1305
+ * @param {(id: string) => boolean} [runningOf]
1306
+ * @returns {object} context-fleet value
1307
+ */
1308
+ export function buildFleetValue(currentSessionId, records, snapshotOf, runningOf) {
1309
+ const byId = new Map()
1310
+ for (const record of Array.isArray(records) ? records : []) {
1311
+ if (record === null || typeof record !== 'object' || typeof record.id !== 'string' || record.id === '') continue
1312
+ if (!byId.has(record.id)) byId.set(record.id, record)
1313
+ }
1314
+ const current = typeof currentSessionId === 'string' ? currentSessionId : ''
1315
+ const resolve = typeof snapshotOf === 'function' ? snapshotOf : () => null
1316
+ const isRunning = typeof runningOf === 'function' ? runningOf : () => false
1317
+
1318
+ // Ancestor chain (current -> root); guards against a cyclic or damaged header chain.
1319
+ const chain = []
1320
+ const chainSeen = new Set()
1321
+ const ancestorLimit = MAX_FLEET_AGENTS * 5
1322
+ let cursor = current
1323
+ while (cursor !== '' && byId.has(cursor) && !chainSeen.has(cursor) && chain.length < ancestorLimit) {
1324
+ chainSeen.add(cursor)
1325
+ const record = byId.get(cursor)
1326
+ chain.push(record)
1327
+ cursor = typeof record.parentSessionId === 'string' ? record.parentSessionId : ''
1328
+ }
1329
+ const ancestors = chain.slice().reverse()
1330
+ const rootId = ancestors.length > 0 ? ancestors[0].id : (byId.has(current) ? current : '')
1331
+
1332
+ // Subagent edges only: a fork shares the lineage field but is an independent conversation.
1333
+ const childrenOf = new Map()
1334
+ for (const record of byId.values()) {
1335
+ const parent = record.parentSessionId
1336
+ if (typeof parent !== 'string' || parent === '' || !byId.has(parent)) continue
1337
+ if (record.origin !== 'subagent') continue
1338
+ if (!childrenOf.has(parent)) childrenOf.set(parent, [])
1339
+ childrenOf.get(parent).push(record)
1340
+ }
1341
+ for (const children of childrenOf.values()) {
1342
+ children.sort((left, right) => (fleetNumber(left.createdAt) - fleetNumber(right.createdAt))
1343
+ || String(left.id).localeCompare(String(right.id)))
1344
+ }
1345
+
1346
+ const order = []
1347
+ const visited = new Set()
1348
+ const walk = (id, depth) => {
1349
+ if (order.length >= MAX_FLEET_AGENTS || visited.has(id)) return
1350
+ const record = byId.get(id)
1351
+ if (record === undefined) return
1352
+ visited.add(id)
1353
+ order.push({ record, depth })
1354
+ for (const child of childrenOf.get(id) || []) walk(child.id, depth + 1)
1355
+ }
1356
+ if (rootId !== '') walk(rootId, 0)
1357
+ // The current session may be a fork (not a subagent edge): keep the ancestor
1358
+ // chain visible after the subagent tree so the current card always renders.
1359
+ for (let i = 0; i < ancestors.length && order.length < MAX_FLEET_AGENTS; i += 1) {
1360
+ if (!visited.has(ancestors[i].id)) {
1361
+ visited.add(ancestors[i].id)
1362
+ order.push({ record: ancestors[i], depth: i })
1363
+ }
1364
+ }
1365
+ // A family deeper than the bound must still show where the user is right now:
1366
+ // trade the last tree row for the current agent.
1367
+ if (!visited.has(current) && byId.has(current)) {
1368
+ if (order.length >= MAX_FLEET_AGENTS) order.pop()
1369
+ order.push({ record: byId.get(current), depth: Math.max(0, ancestors.length - 1) })
1370
+ }
1371
+
1372
+ // Resolve every family snapshot once: the team roster comes from the ROOT
1373
+ // session's `agentTeam` projection (each session's team projection is scoped
1374
+ // to itself), and the per-agent cards read from the same snapshot.
1375
+ const resolved = order.map(item => {
1376
+ try {
1377
+ return resolve(item.record.id)
1378
+ } catch (_) {
1379
+ return null
1380
+ }
1381
+ })
1382
+ const valuesOf = index => resolved[index] !== null && typeof resolved[index] === 'object' ? resolved[index] : {}
1383
+ let team = null
1384
+ let teamPhaseById = null
1385
+ const teamCandidateOrder = []
1386
+ const rootIndex = order.findIndex(item => item.record.id === rootId)
1387
+ if (rootIndex >= 0) teamCandidateOrder.push(rootIndex)
1388
+ for (let index = 0; index < order.length; index += 1) if (index !== rootIndex) teamCandidateOrder.push(index)
1389
+ for (const index of teamCandidateOrder) {
1390
+ const candidate = teamOf(valuesOf(index).agentTeam)
1391
+ if (candidate !== null) {
1392
+ team = candidate
1393
+ break
1394
+ }
1395
+ }
1396
+ if (team !== null) {
1397
+ teamPhaseById = new Map()
1398
+ for (const member of team.members) teamPhaseById.set(member.id, member.phase)
1399
+ }
1400
+
1401
+ const agents = []
1402
+ let running = 0
1403
+ let subagents = 0
1404
+ for (let index = 0; index < order.length; index += 1) {
1405
+ const item = order[index]
1406
+ const record = item.record
1407
+ const v = valuesOf(index)
1408
+ const subagent = v.subagent !== null && typeof v.subagent === 'object' ? v.subagent : null
1409
+ const pressure = v.contextPressure !== null && typeof v.contextPressure === 'object' ? v.contextPressure : null
1410
+ const usage = v.tokenUsage !== null && typeof v.tokenUsage === 'object' ? v.tokenUsage : null
1411
+ const stats = v.sessionStats !== null && typeof v.sessionStats === 'object' ? v.sessionStats : null
1412
+ const context = v.mobileContext !== null && typeof v.mobileContext === 'object' ? v.mobileContext : null
1413
+ const compositionSource = context !== null && context.current !== null && typeof context.current === 'object' ? context.current : null
1414
+ const composition = {
1415
+ system: fleetNumber(compositionSource && compositionSource.system),
1416
+ tools: fleetNumber(compositionSource && compositionSource.tools),
1417
+ user: fleetNumber(compositionSource && compositionSource.user),
1418
+ inject: fleetNumber(compositionSource && compositionSource.inject),
1419
+ skill: fleetNumber(compositionSource && compositionSource.skill),
1420
+ assistant: fleetNumber(compositionSource && compositionSource.assistant),
1421
+ tool: fleetNumber(compositionSource && compositionSource.tool),
1422
+ }
1423
+ composition.total = composition.system + composition.tools + composition.user + composition.inject
1424
+ + composition.skill + composition.assistant + composition.tool
1425
+ const isAgentRunning = isRunning(record.id) === true
1426
+ if (isAgentRunning) running += 1
1427
+ if (record.origin === 'subagent') subagents += 1
1428
+ const title = typeof v.title === 'string' && v.title.trim() !== ''
1429
+ ? v.title.trim()
1430
+ : (typeof record.title === 'string' && record.title.trim() !== '' ? record.title.trim() : record.id)
1431
+ const entry = {
1432
+ sessionId: record.id,
1433
+ title,
1434
+ depth: item.depth,
1435
+ isCurrent: record.id === current,
1436
+ isRoot: record.id === rootId,
1437
+ running: isAgentRunning,
1438
+ createdAt: fleetNumber(record.createdAt),
1439
+ composition,
1440
+ }
1441
+ if (typeof record.parentSessionId === 'string' && record.parentSessionId !== '') entry.parentSessionId = record.parentSessionId
1442
+ if (subagent !== null && (subagent.mode === 'one-shot' || subagent.mode === 'continuable')) entry.mode = subagent.mode
1443
+ if (subagent !== null && typeof subagent.label === 'string' && subagent.label !== '') entry.label = subagent.label
1444
+ if (context !== null && typeof context.model === 'string') entry.model = context.model
1445
+ if (context !== null && typeof context.provider === 'string') entry.provider = context.provider
1446
+ if (pressure !== null && typeof pressure.contextWindow === 'number' && pressure.contextWindow > 0) entry.contextWindow = pressure.contextWindow
1447
+ if (pressure !== null && typeof pressure.pressureTokens === 'number' && pressure.pressureTokens >= 0) entry.pressureTokens = pressure.pressureTokens
1448
+ if (usage !== null) {
1449
+ const total = fleetNumber(usage.uncachedInputTokens) + fleetNumber(usage.cacheReadTokens)
1450
+ + fleetNumber(usage.cacheWriteTokens) + fleetNumber(usage.outputTokens)
1451
+ if (total > 0) entry.tokensTotal = total
1452
+ }
1453
+ if (stats !== null && Number.isFinite(stats.turns)) entry.turns = stats.turns
1454
+ if (stats !== null && Number.isFinite(stats.steps)) entry.steps = stats.steps
1455
+ const timing = v.subagentTiming !== null && typeof v.subagentTiming === 'object' ? v.subagentTiming : null
1456
+ if (timing !== null) {
1457
+ const active = timing.active !== null && typeof timing.active === 'object' ? timing.active : null
1458
+ const activeMs = fleetNumber(timing.settledMs)
1459
+ + (active !== null ? Math.max(0, fleetNumber(active.through) - fleetNumber(active.since)) : 0)
1460
+ if (activeMs > 0) entry.activeMs = activeMs
1461
+ } else if (context !== null && context.timing !== null && typeof context.timing === 'object') {
1462
+ const wallMs = fleetNumber(context.timing.wallMs)
1463
+ if (wallMs > 0) entry.activeMs = wallMs
1464
+ }
1465
+ // v2 fleet card fields (docs §8.2): the newest answer preview and the team
1466
+ // member phase; the current session defaults to `active` when the roster
1467
+ // exists but does not list it.
1468
+ if (context !== null && typeof context.lastAnswer === 'string' && context.lastAnswer !== '') {
1469
+ entry.lastAnswer = context.lastAnswer
1470
+ }
1471
+ if (teamPhaseById !== null) {
1472
+ const phase = teamPhaseById.get(record.id)
1473
+ if (typeof phase === 'string' && TEAM_PHASES.has(phase)) entry.phase = phase
1474
+ else if (record.id === current) entry.phase = 'active'
1475
+ }
1476
+ agents.push(entry)
1477
+ }
1478
+
1479
+ return {
1480
+ rootSessionId: rootId,
1481
+ currentSessionId: current,
1482
+ agents,
1483
+ ...(team !== null ? { team } : {}),
1484
+ counts: { agents: agents.length, running, subagents },
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * Serve one `context-fleet` request: list the host's sessions, walk the current
1490
+ * agent's family, and summarize each member from its projection snapshot.
1491
+ *
1492
+ * @param {{type:string,sessionId?:string}} msg mobile request frame
1493
+ * @returns {Promise<object>} wire frame
1494
+ */
1495
+ export async function handleContextFleet(msg) {
1496
+ const requestType = msg && msg.type
1497
+ const sessionId = msg && typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1498
+ if (sessionId === null) {
1499
+ return { kind: 'error', code: 'bad-request', requestType, message: 'context-fleet requires a sessionId' }
1500
+ }
1501
+ if (hostSessions === null || hostProjections === null || typeof hostSessions.list !== 'function'
1502
+ || typeof hostProjections.snapshot !== 'function') {
1503
+ return unavailable(requestType, sessionId, 'Session services are unavailable on this host')
1504
+ }
1505
+ try {
1506
+ const sessions = hostSessions.list()
1507
+ const list = Array.isArray(sessions) ? sessions : []
1508
+ const byId = new Map()
1509
+ const records = []
1510
+ for (const session of list) {
1511
+ if (session === null || typeof session !== 'object') continue
1512
+ const header = session.header !== null && typeof session.header === 'object' ? session.header : {}
1513
+ const id = typeof session.id === 'string' && session.id !== '' ? session.id : (typeof header.id === 'string' ? header.id : '')
1514
+ if (id === '' || byId.has(id)) continue
1515
+ byId.set(id, session)
1516
+ records.push({
1517
+ id,
1518
+ parentSessionId: typeof header.parentSession === 'string' ? header.parentSession : '',
1519
+ origin: header.origin === 'subagent' ? 'subagent' : '',
1520
+ createdAt: fleetNumber(header.createdAt),
1521
+ })
1522
+ }
1523
+ const value = buildFleetValue(sessionId, records, (id) => {
1524
+ const session = byId.get(id)
1525
+ if (session === undefined) return null
1526
+ const snapshot = hostProjections.snapshot(session)
1527
+ return snapshot !== null && typeof snapshot === 'object' && snapshot.values !== null && typeof snapshot.values === 'object'
1528
+ ? snapshot.values
1529
+ : null
1530
+ }, (id) => {
1531
+ try {
1532
+ return !!(hostAgents && typeof hostAgents.get === 'function' && hostAgents.get(id) && hostAgents.get(id).status === 'running')
1533
+ } catch (_) {
1534
+ return false
1535
+ }
1536
+ })
1537
+ return { kind: 'context-fleet', sessionId, value }
1538
+ } catch (_) {
1539
+ return unavailable(requestType, sessionId, 'Unable to read the agent family')
1540
+ }
1541
+ }
1542
+
1543
+ /**
1544
+ * Register the projection on the host's session-projection registry. Optional:
1545
+ * hosts without the registry (older DSH) keep the gateway working, and the
1546
+ * `context-dashboard` query then answers `context-unavailable`.
1547
+ *
1548
+ * @param {object} ctx gateway plugin context
1549
+ * @returns {boolean} whether an injection was attempted
1550
+ */
1551
+ export function registerContextProjection(ctx) {
1552
+ if (ctx === null || typeof ctx !== 'object' || typeof ctx.inject !== 'function') return false
1553
+ try {
1554
+ ctx.inject(['sessionProjections'], (sub) => {
1555
+ if (!sub || !sub.sessionProjections || typeof sub.sessionProjections.register !== 'function') return
1556
+ sub.sessionProjections.register(contextProjectionDefinition)
1557
+ })
1558
+ return true
1559
+ } catch (_) {
1560
+ return false
1561
+ }
1562
+ }
1563
+
1564
+ function unavailable(requestType, sessionId, message) {
1565
+ return { kind: 'error', code: 'context-unavailable', requestType, sessionId, message }
1566
+ }
1567
+
1568
+ function boundedContextText(value, max, meta) {
1569
+ if (typeof value !== 'string') return ''
1570
+ if (value.length > max && meta) meta.truncated = true
1571
+ return value.slice(0, max)
1572
+ }
1573
+
1574
+ function eventContentText(event, meta) {
1575
+ const data = event && event.data && typeof event.data === 'object' ? event.data : {}
1576
+ if (event.type === 'request/header') {
1577
+ const header = data.header && typeof data.header === 'object' ? data.header : {}
1578
+ const tools = Array.isArray(header.tools) ? header.tools.slice(0, 100) : []
1579
+ const lines = []
1580
+ const config = header.config && typeof header.config === 'object' ? header.config : {}
1581
+ if (typeof config.provider === 'string' || typeof config.model === 'string') {
1582
+ lines.push(`Provider: ${config.provider || '—'} · Model: ${config.model || '—'}`)
1583
+ }
1584
+ for (const tool of tools) {
1585
+ if (tool === null || typeof tool !== 'object') continue
1586
+ const name = typeof tool.name === 'string' ? tool.name : 'Tool'
1587
+ lines.push(`\n${name}${typeof tool.description === 'string' && tool.description !== '' ? `\n${tool.description}` : ''}`)
1588
+ if (tool.parameters !== undefined || tool.inputSchema !== undefined || tool.schema !== undefined) {
1589
+ let schema
1590
+ try { schema = JSON.stringify(tool.parameters ?? tool.inputSchema ?? tool.schema) } catch (_) { schema = '' }
1591
+ if (schema) lines.push(`\nSchema: ${boundedContextText(schema, 5000, meta)}`)
1592
+ }
1593
+ }
1594
+ return lines.join('\n')
1595
+ }
1596
+ if (event.type === 'request/context') {
1597
+ return [
1598
+ `Model: ${typeof data.model === 'string' ? data.model : '—'}`,
1599
+ `Provider: ${typeof data.provider === 'string' ? data.provider : '—'}`,
1600
+ `Context window: ${Number.isFinite(data.contextWindow) ? data.contextWindow : '—'}`,
1601
+ ].join('\n')
1602
+ }
1603
+ const message = event.type === 'user/message' ? data
1604
+ : data.message !== null && typeof data.message === 'object' ? data.message : null
1605
+ const blocks = message && Array.isArray(message.content) ? message.content : []
1606
+ const lines = []
1607
+ for (const block of blocks) {
1608
+ if (block === null || typeof block !== 'object') continue
1609
+ if ((block.type === 'text' || block.type === 'reasoning') && typeof block.text === 'string') {
1610
+ lines.push(block.text)
1611
+ } else if (block.type === 'tool-call') {
1612
+ const name = typeof block.name === 'string' ? block.name : 'tool'
1613
+ lines.push(`[${name}]${typeof block.arguments === 'string' && block.arguments !== '' ? `\n${boundedContextText(block.arguments, 8000, meta)}` : ''}`)
1614
+ } else if (block.type === 'image') {
1615
+ lines.push('[image attachment]')
1616
+ } else {
1617
+ let summary
1618
+ try { summary = JSON.stringify(block) } catch (_) { summary = '' }
1619
+ if (summary) lines.push(boundedContextText(summary, 1000, meta))
1620
+ }
1621
+ }
1622
+ return lines.join('\n\n')
1623
+ }
1624
+
1625
+ /** Fetch one durable event body only when the user opens that row in the browser. */
1626
+ export async function handleContextContent(api, msg) {
1627
+ const requestType = msg && msg.type
1628
+ const sessionId = msg && typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1629
+ const seq = msg && Number.isSafeInteger(msg.seq) && msg.seq >= 0 ? msg.seq : null
1630
+ if (sessionId === null || seq === null) {
1631
+ return { kind: 'error', code: 'bad-request', requestType, ...(sessionId !== null ? { sessionId } : {}), message: 'context-content requires a sessionId and seq' }
1632
+ }
1633
+ if (!api || !api.sessions || typeof api.sessions.history !== 'function') {
1634
+ return unavailable(requestType, sessionId, 'Session history is unavailable on this host')
1635
+ }
1636
+ try {
1637
+ const history = await api.sessions.history({ sessionId, throughSeq: seq, beforeSeq: seq + 1, maxMessages: 1 })
1638
+ if (history && history.kind === 'error') return { ...history, requestType, sessionId }
1639
+ const entries = history && Array.isArray(history.events) ? history.events : []
1640
+ let found = null
1641
+ for (const entry of entries) {
1642
+ const candidate = entry && typeof entry === 'object' && entry.event && typeof entry.event === 'object'
1643
+ ? entry.event : entry
1644
+ if (candidate && candidate.seq === seq && typeof candidate.type === 'string') { found = candidate; break }
1645
+ }
1646
+ if (found === null) return unavailable(requestType, sessionId, 'The selected context item is no longer available')
1647
+ const allowed = new Set(['system/message', 'developer/message', 'user/message', 'assistant/message', 'tool/result', 'request/header', 'request/context'])
1648
+ if (!allowed.has(found.type)) return { kind: 'error', code: 'bad-request', requestType, sessionId, message: 'The selected event is not a context item' }
1649
+ const textMeta = { truncated: false }
1650
+ const text = boundedContextText(eventContentText(found, textMeta), 24000, textMeta)
1651
+ return {
1652
+ kind: 'context-content', sessionId, seq,
1653
+ value: { seq, time: timeOf(found), type: found.type, text, ...(textMeta.truncated ? { truncated: true } : {}) },
1654
+ }
1655
+ } catch (_) {
1656
+ return unavailable(requestType, sessionId, 'Unable to read the selected context item')
1657
+ }
1658
+ }
1659
+
1660
+ /**
1661
+ * Serve one `context-dashboard` request: read the session's projection snapshot
1662
+ * (which carries every registered wire unit) and augment `mobileContext` with
1663
+ * the harness's official breakdown / usage / stats projections.
1664
+ *
1665
+ * @param {object} api DSH host adapter (needs `sessions.history`)
1666
+ * @param {{type:string,sessionId?:string}} msg mobile request frame
1667
+ * @returns {Promise<object>} wire frame
1668
+ */
1669
+ export async function handleContextQuery(api, msg) {
1670
+ const requestType = msg && msg.type
1671
+ const sessionId = msg && typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1672
+ if (sessionId === null) {
1673
+ return { kind: 'error', code: 'bad-request', requestType, message: 'context-dashboard requires a sessionId' }
1674
+ }
1675
+ if (!api || !api.sessions || typeof api.sessions.history !== 'function') {
1676
+ return unavailable(requestType, sessionId, 'Session history is unavailable on this host')
1677
+ }
1678
+ try {
1679
+ // The host adapter returns the raw `session/follow` snapshot (no `kind`):
1680
+ // only the gateway's proxyQuery helper stamps a kind. Accept both shapes so
1681
+ // the handler works with the adapter directly and with legacy mocks.
1682
+ const history = await api.sessions.history({ sessionId, maxMessages: 1 })
1683
+ if (history && history.kind === 'error') {
1684
+ return { ...history, requestType, sessionId }
1685
+ }
1686
+ const projections = history !== null && typeof history === 'object'
1687
+ && history.projections !== null && typeof history.projections === 'object'
1688
+ ? history.projections
1689
+ : undefined
1690
+ if (projections === undefined) {
1691
+ return unavailable(requestType, sessionId, 'The session projection snapshot is unavailable')
1692
+ }
1693
+ const values = projections && projections.values !== null && typeof projections.values === 'object' ? projections.values : {}
1694
+ const context = values.mobileContext
1695
+ if (context === null || typeof context !== 'object') {
1696
+ return unavailable(requestType, sessionId, 'The context projection is not registered on this host')
1697
+ }
1698
+ const usage = values.tokenUsage !== null && typeof values.tokenUsage === 'object' ? values.tokenUsage : null
1699
+ const stats = values.sessionStats !== null && typeof values.sessionStats === 'object' ? values.sessionStats : null
1700
+ const breakdown = values.contextBreakdown !== null && typeof values.contextBreakdown === 'object' ? values.contextBreakdown : null
1701
+ return {
1702
+ kind: 'context-dashboard',
1703
+ sessionId,
1704
+ asOfSeq: projections ? projections.asOfSeq : undefined,
1705
+ value: {
1706
+ ...context,
1707
+ breakdown,
1708
+ usage,
1709
+ stats,
1710
+ },
1711
+ }
1712
+ } catch (_) {
1713
+ return unavailable(requestType, sessionId, 'Unable to read the context projection')
1714
+ }
1715
+ }