opencode-context-indicator 1.1.5 → 1.2.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/lib/v2.js ADDED
@@ -0,0 +1,692 @@
1
+ // lib/v2.js
2
+ //
3
+ // The V2 (opencode >= 2.0.16 / Desktop) path: the context hook estimator and
4
+ // handler, the bounded session.context fallback, the event-driven subagent
5
+ // roll-up, the live log assembly, and handleV2Event. No toasts on this path
6
+ // (the Desktop exposes no TUI channel). Extracted 1:1 (code + WHY comments)
7
+ // from index.js.
8
+
9
+ import {
10
+ estJson,
11
+ estimateTokens,
12
+ fmt,
13
+ oneLine,
14
+ tokenCountOf,
15
+ withTimeout,
16
+ } from "./estimate.js";
17
+ import {
18
+ BREAKDOWN_THROTTLE_MS,
19
+ breakdownCache,
20
+ clearSessionTracking,
21
+ ERROR_TOAST_DEDUP_MS,
22
+ lastBdWrite,
23
+ lastKnownCtx,
24
+ lastKnownCtxCache,
25
+ lastKnownInput,
26
+ lastKnownInputCache,
27
+ lastKnownModel,
28
+ lastKnownModelCache,
29
+ lastKnownProvider,
30
+ lastKnownProviderCache,
31
+ lastKnownReasoning,
32
+ lastKnownReasoningCache,
33
+ MAX_TRACKED_SESSIONS,
34
+ sessionAgentCache,
35
+ sessionParentCache,
36
+ sessionTotals,
37
+ toolSchemasBySession,
38
+ TOOL_SCHEMA_TTL_MS,
39
+ v2ContextFetchTimes,
40
+ } from "./cache.js";
41
+ import {
42
+ ensureModelLimits,
43
+ getModelLimit,
44
+ getUsableContext,
45
+ } from "./limits.js";
46
+ import {
47
+ debugEvent,
48
+ logErr,
49
+ pushFinalSummary,
50
+ writeLog,
51
+ writeStateFile,
52
+ } from "./state.js";
53
+ import { pruneMap } from "./dedup.js";
54
+
55
+ const V2_CONTEXT_FETCH_THROTTLE_MS = 5000; // min gap between fallback fetches
56
+ // Background fallback requests are fire-and-forget (see the callers in the
57
+ // event handlers): cap each at 3s so a slow/hung client cannot pin resources.
58
+ const V2_FALLBACK_TIMEOUT_MS = 3000;
59
+
60
+ // Estimate the per-category breakdown directly from a V2 SessionContext.
61
+ // `context.messages` are @opencode/ai Message objects:
62
+ // { role, content: [ {type:"text",text} | {type:"media"} |
63
+ // {type:"tool-call",input} | {type:"tool-result"} |
64
+ // {type:"reasoning",text} | ... ] }
65
+ // `context.system` are SystemPart[] { type:"text", text }.
66
+ // `context.tools` is Record<name, { description, input: JsonSchema }>.
67
+ // As in V1, tool RESULTS are deliberately not counted (only call args), and
68
+ // every returned *Chars value is a TOKEN estimate (unicode heuristic).
69
+ //
70
+ // `sessionID` is optional; when given, the tool-schema estimate is cached
71
+ // per-session (see toolSchemasBySession) and reused while the cheap fingerprint
72
+ // is unchanged and the TTL has not elapsed — so the expensive estJson pass over
73
+ // every schema does not run on every single model request.
74
+ // Cheap structural fingerprint of the tool schema map: count + total description
75
+ // length + number of schema property keys + two O(n) charCode checksums (mod
76
+ // 0xFFFFFF) over every description and over the top-level input keys. The
77
+ // checksums widen the fingerprint so two different schemas that happen to share
78
+ // the same length/key counts do not collide into a wrong cache hit — without
79
+ // paying for full JSON.stringify (the whole point of the cache).
80
+ function toolsFingerprint(tools) {
81
+ if (!tools || typeof tools !== "object") return "none";
82
+ let n = 0;
83
+ let descLen = 0;
84
+ let propKeys = 0;
85
+ let descHash = 0;
86
+ let keyHash = 0;
87
+ for (const t of Object.values(tools)) {
88
+ n++;
89
+ const d = t?.description;
90
+ if (typeof d === "string") {
91
+ descLen += d.length;
92
+ for (let i = 0; i < d.length; i++) {
93
+ descHash = (descHash + d.charCodeAt(i)) & 0xffffff;
94
+ }
95
+ }
96
+ const input = t?.input;
97
+ if (input && typeof input === "object") {
98
+ const props = input.properties;
99
+ propKeys +=
100
+ props && typeof props === "object"
101
+ ? Object.keys(props).length
102
+ : Object.keys(input).length;
103
+ // Sum the top-level key names so property RENAMES that keep the same count
104
+ // still change the fingerprint.
105
+ const topKeys = Object.keys(input);
106
+ for (const k of topKeys) {
107
+ for (let i = 0; i < k.length; i++) {
108
+ keyHash = (keyHash + k.charCodeAt(i)) & 0xffffff;
109
+ }
110
+ }
111
+ }
112
+ }
113
+ return `${n}:${descLen}:${propKeys}:${descHash}:${keyHash}`;
114
+ }
115
+
116
+ function estimateBreakdownFromContext(context, sessionID) {
117
+ let userChars = 0;
118
+ let assistantChars = 0;
119
+ let reasoningChars = 0;
120
+ let toolArgsChars = 0;
121
+
122
+ const messages = Array.isArray(context?.messages) ? context.messages : [];
123
+ for (const msg of messages) {
124
+ const parts = Array.isArray(msg?.content) ? msg.content : [];
125
+ if (msg?.role === "user") {
126
+ for (const p of parts) {
127
+ if (p?.type === "text") userChars += estimateTokens(p.text);
128
+ }
129
+ } else if (msg?.role === "assistant") {
130
+ for (const p of parts) {
131
+ if (p?.type === "text") {
132
+ assistantChars += estimateTokens(p.text);
133
+ } else if (p?.type === "reasoning") {
134
+ reasoningChars += estimateTokens(p.text);
135
+ } else if (p?.type === "tool-call") {
136
+ toolArgsChars += estJson(p.input);
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ let systemChars = 0;
143
+ const system = Array.isArray(context?.system) ? context.system : [];
144
+ for (const s of system) systemChars += estimateTokens(s?.text);
145
+
146
+ let toolSchemasChars = 0;
147
+ const tools = context?.tools;
148
+ if (tools && typeof tools === "object") {
149
+ const fp = toolsFingerprint(tools);
150
+ const cached = sessionID ? toolSchemasBySession.get(sessionID) : null;
151
+ if (
152
+ cached &&
153
+ cached.toolsFingerprint === fp &&
154
+ Date.now() - cached.at < TOOL_SCHEMA_TTL_MS
155
+ ) {
156
+ toolSchemasChars = cached.toolSchemasChars; // unchanged schemas -> reuse
157
+ } else {
158
+ for (const t of Object.values(tools)) {
159
+ toolSchemasChars += estimateTokens(t?.description) + estJson(t?.input);
160
+ }
161
+ if (sessionID) {
162
+ toolSchemasBySession.set(sessionID, {
163
+ toolSchemasChars,
164
+ toolsFingerprint: fp,
165
+ at: Date.now(),
166
+ });
167
+ pruneMap(toolSchemasBySession, MAX_TRACKED_SESSIONS);
168
+ }
169
+ }
170
+ }
171
+
172
+ return {
173
+ userChars,
174
+ assistantChars,
175
+ reasoningChars,
176
+ toolArgsChars,
177
+ systemChars,
178
+ toolSchemasChars,
179
+ };
180
+ }
181
+
182
+ // V2 context hook handler: capture the final messages / system prompt / tool
183
+ // schemas + the request model. READ-ONLY — the context object is never mutated.
184
+ function onV2Context(context) {
185
+ try {
186
+ if (!context || typeof context !== "object") return;
187
+ const sid = context.sessionID;
188
+ if (!sid) return;
189
+
190
+ const parts = estimateBreakdownFromContext(context, sid);
191
+ breakdownCache.set(sid, {
192
+ ...parts,
193
+ capturedAt: Date.now(),
194
+ source: "context-hook",
195
+ });
196
+ pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
197
+
198
+ const model = context.model;
199
+ if (model && typeof model === "object") {
200
+ if (typeof model.id === "string") lastKnownModelCache.set(sid, model.id);
201
+ if (typeof model.providerID === "string") {
202
+ lastKnownProviderCache.set(sid, model.providerID);
203
+ }
204
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
205
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
206
+ }
207
+ } catch (err) {
208
+ logErr("v2 context hook failed", err);
209
+ }
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // V2 fallback: derive the message-based categories from the session's persisted
214
+ // message list when the context hook did NOT populate the cache for this
215
+ // session. This happens when the request was served by a DIFFERENT plugin
216
+ // instance (the public event stream is mirrored to every instance, but the
217
+ // breakdown cache is process-local) or when the hook did not fire at all.
218
+ //
219
+ // Only user / assistant / reasoning / tool-args can be derived from the
220
+ // persisted SessionMessageInfo[]: the ASSEMBLED system prompt and the tool
221
+ // schemas are exclusive to the context hook, so they stay null ("n/a" in the
222
+ // sidebar) unless the hook ran. Tool RESULTS are deliberately not counted
223
+ // (only call args), exactly like the hook / V1 estimator.
224
+ function estimateBreakdownFromClientMessages(messages) {
225
+ let userChars = 0;
226
+ let assistantChars = 0;
227
+ let reasoningChars = 0;
228
+ let toolArgsChars = 0;
229
+ for (const m of messages || []) {
230
+ if (!m || typeof m !== "object") continue;
231
+ if (m.type === "user") {
232
+ userChars += estimateTokens(m.text);
233
+ } else if (m.type === "assistant") {
234
+ const parts = Array.isArray(m.content) ? m.content : [];
235
+ for (const p of parts) {
236
+ if (p?.type === "text") {
237
+ assistantChars += estimateTokens(p.text);
238
+ } else if (p?.type === "reasoning") {
239
+ reasoningChars += estimateTokens(p.text);
240
+ } else if (p?.type === "tool") {
241
+ // state.input is a string while streaming; only count the resolved
242
+ // object (running/completed/error) to avoid partial counts.
243
+ const input = p?.state?.input;
244
+ if (input && typeof input === "object") toolArgsChars += estJson(input);
245
+ }
246
+ }
247
+ }
248
+ }
249
+ return {
250
+ userChars,
251
+ assistantChars,
252
+ reasoningChars,
253
+ toolArgsChars,
254
+ systemChars: null,
255
+ toolSchemasChars: null,
256
+ };
257
+ }
258
+
259
+ // Fill breakdownCache for a session when the context hook has not already done
260
+ // so. Throttled, fault-tolerant, READ-ONLY. Hook data always wins.
261
+ async function ensureV2Breakdown(ctx, sessionID) {
262
+ if (!sessionID) return;
263
+ const cached = breakdownCache.get(sessionID);
264
+ if (cached && cached.source === "context-hook") return; // hook data wins
265
+ const now = Date.now();
266
+ if (
267
+ now - (v2ContextFetchTimes.get(sessionID) || 0) <
268
+ V2_CONTEXT_FETCH_THROTTLE_MS
269
+ ) {
270
+ return;
271
+ }
272
+ v2ContextFetchTimes.set(sessionID, now);
273
+ pruneMap(v2ContextFetchTimes, MAX_TRACKED_SESSIONS);
274
+ try {
275
+ const res = await withTimeout(
276
+ ctx.session.context({ sessionID }),
277
+ V2_FALLBACK_TIMEOUT_MS,
278
+ "session.context",
279
+ );
280
+ const msgs = Array.isArray(res) ? res : res?.data;
281
+ if (Array.isArray(msgs)) {
282
+ // Re-check AFTER the await: the context hook may have populated the cache
283
+ // while this fetch was in flight — hook data must win and must never be
284
+ // overwritten by a late fallback.
285
+ const latest = breakdownCache.get(sessionID);
286
+ if (!latest || latest.source !== "context-hook") {
287
+ breakdownCache.set(sessionID, {
288
+ ...estimateBreakdownFromClientMessages(msgs),
289
+ capturedAt: Date.now(),
290
+ source: "client-context",
291
+ });
292
+ pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
293
+ }
294
+ }
295
+ } catch (err) {
296
+ logErr("v2 session.context fallback failed", err);
297
+ }
298
+ // Opportunistically resolve the model: Desktop selects it AFTER
299
+ // session.created, so without this (or session.model.selected) a session can
300
+ // stay "unknown" even though its limit is resolvable.
301
+ if (
302
+ !lastKnownModelCache.has(sessionID) ||
303
+ !lastKnownProviderCache.has(sessionID)
304
+ ) {
305
+ try {
306
+ const info = await withTimeout(
307
+ ctx.session.get({ sessionID }),
308
+ V2_FALLBACK_TIMEOUT_MS,
309
+ "session.get",
310
+ );
311
+ const model = info?.model ?? info?.data?.model;
312
+ if (model && typeof model === "object") {
313
+ if (typeof model.id === "string") lastKnownModelCache.set(sessionID, model.id);
314
+ if (typeof model.providerID === "string") {
315
+ lastKnownProviderCache.set(sessionID, model.providerID);
316
+ }
317
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
318
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
319
+ }
320
+ } catch (err) {
321
+ logErr("v2 session.get model fallback failed", err);
322
+ }
323
+ }
324
+ }
325
+
326
+ // V2 subagent aggregation: children are discovered from session.created
327
+ // (parentID), their totals accumulated from session.step.ended. No
328
+ // session.children/messages API needed. Synchronous (in-memory only).
329
+ function aggregateSubagentsV2(sessionID) {
330
+ const result = { count: 0, input: 0, output: 0, reasoning: 0, perChild: [] };
331
+ for (const [childID, parentID] of sessionParentCache) {
332
+ if (parentID !== sessionID) continue;
333
+ result.count++;
334
+ const t = sessionTotals.get(childID);
335
+ if (t) {
336
+ result.input += t.input || 0;
337
+ result.output += t.output || 0;
338
+ result.reasoning += t.reasoning || 0;
339
+ const lim = getModelLimit(t.providerID, t.modelID);
340
+ const ctxTokens = typeof t.ctx === "number" ? t.ctx : 0;
341
+ result.perChild.push({
342
+ id: childID,
343
+ input: ctxTokens,
344
+ limit: lim,
345
+ ratio: lim ? ctxTokens / lim : null,
346
+ });
347
+ } else {
348
+ result.perChild.push({ id: childID, input: 0, limit: null, ratio: null });
349
+ }
350
+ }
351
+ return result;
352
+ }
353
+
354
+ // V2: assemble the live breakdown lines (no client, no fallback fetch —
355
+ // the context hook fills breakdownCache on every request).
356
+ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens, reasoningTokens) {
357
+ const lines = [];
358
+ const ts = new Date().toISOString();
359
+ const limit = getModelLimit(providerID, modelID);
360
+ const usableInfo = getUsableContext(providerID, modelID);
361
+ const total = typeof ctxTokens === "number" && ctxTokens > 0 ? ctxTokens : inputTokens;
362
+ const denom =
363
+ usableInfo != null
364
+ ? usableInfo.usable
365
+ : limit != null && limit > 0
366
+ ? limit
367
+ : null;
368
+ const pct =
369
+ denom != null ? ` (${((total / denom) * 100).toFixed(1)}%)` : "";
370
+
371
+ lines.push(
372
+ `[${ts}] session=${sessionID} model=${modelID} ctx=${fmt(total)}/${denom != null ? fmt(denom) : "?"}${pct} count=${fmt(total)} usable=${usableInfo != null ? fmt(usableInfo.usable) : "?"} limit=${limit != null ? fmt(limit) : "?"} maxOutput=${usableInfo != null ? fmt(usableInfo.maxOutput) : "?"} reserved=${usableInfo != null ? fmt(usableInfo.reserved) : "?"} input=${fmt(inputTokens)}`,
373
+ );
374
+
375
+ const bd = breakdownCache.get(sessionID);
376
+ const source = bd?.source || "none";
377
+ const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
378
+
379
+ const sumEstimates = (bd?.userChars || 0) +
380
+ (bd?.assistantChars || 0) +
381
+ (bd?.reasoningChars || 0) +
382
+ (bd?.toolArgsChars || 0) +
383
+ (bd?.systemChars || 0) +
384
+ (bd?.toolSchemasChars || 0);
385
+ const other = Math.max(0, inputTokens - sumEstimates);
386
+
387
+ if (bd) {
388
+ lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
389
+ lines.push(` assistant ${fmt(bd.assistantChars || 0)} (est tokens)`);
390
+ const reasoningNote = rt > 0 ? ` (exact ${fmt(rt)})` : "";
391
+ lines.push(` reasoning ${fmt(bd.reasoningChars || 0)} (est tokens)${reasoningNote}`);
392
+ lines.push(` tool args ${fmt(bd.toolArgsChars || 0)} (est tokens, input only)`);
393
+ lines.push(
394
+ bd.systemChars != null
395
+ ? ` system ${fmt(bd.systemChars)} (est tokens)`
396
+ : ` system n/a (hook did not run)`,
397
+ );
398
+ lines.push(
399
+ bd.toolSchemasChars != null
400
+ ? ` tool schemas ${fmt(bd.toolSchemasChars)} (est tokens)`
401
+ : ` tool schemas n/a`,
402
+ );
403
+ } else {
404
+ lines.push(` user n/a (no breakdown captured yet)`);
405
+ lines.push(` assistant n/a`);
406
+ lines.push(` reasoning n/a`);
407
+ lines.push(` tool args n/a`);
408
+ lines.push(` system n/a (hook did not run)`);
409
+ lines.push(` tool schemas n/a`);
410
+ }
411
+ lines.push(` other ${fmt(other)} (= input - sum of estimates)`);
412
+ lines.push(` source ${source}`);
413
+
414
+ try {
415
+ const sub = aggregateSubagentsV2(sessionID);
416
+ if (sub.count > 0) {
417
+ const ratios = (sub.perChild || [])
418
+ .map((c) => c?.ratio)
419
+ .filter((r) => typeof r === "number");
420
+ const worst =
421
+ ratios.length > 0
422
+ ? ` worst=${(Math.max(...ratios) * 100).toFixed(0)}%`
423
+ : "";
424
+ lines.push(
425
+ ` subagents ${sub.count} session(s): in=${fmt(sub.input)} out=${fmt(sub.output)} r=${fmt(sub.reasoning)}${worst}`,
426
+ );
427
+ } else {
428
+ lines.push(` subagents 0`);
429
+ }
430
+ } catch (err) {
431
+ logErr("v2 subagent aggregation failed", err);
432
+ lines.push(` subagents n/a`);
433
+ }
434
+
435
+ return lines;
436
+ }
437
+
438
+ // V2 event handler. `event` is a V2Event ({ type, data, ... }).
439
+ async function handleV2Event(ctx, event) {
440
+ if (!event || typeof event.type !== "string") return;
441
+
442
+ // DEBUG tap (fault-tolerant, off via flag).
443
+ debugEvent(event);
444
+
445
+ // --- session lifecycle: parent links + model hints ---
446
+ if (event.type === "session.created") {
447
+ const data = event.data || {};
448
+ const sid = data.sessionID;
449
+ if (!sid) return;
450
+ const parentID = data.parentID || null;
451
+ // sessionParentCache is the durable parent link used when WRITING the
452
+ // state.json entry (writeStateFile); discovery itself reads the persisted
453
+ // parentID chain — see collectDescendantSessionIDs.
454
+ sessionParentCache.set(sid, parentID);
455
+ pruneMap(sessionParentCache, MAX_TRACKED_SESSIONS);
456
+ if (typeof data.agent === "string" && data.agent) {
457
+ sessionAgentCache.set(sid, data.agent);
458
+ pruneMap(sessionAgentCache, MAX_TRACKED_SESSIONS);
459
+ }
460
+ if (data.model && typeof data.model === "object") {
461
+ if (typeof data.model.id === "string") lastKnownModelCache.set(sid, data.model.id);
462
+ if (typeof data.model.providerID === "string") {
463
+ lastKnownProviderCache.set(sid, data.model.providerID);
464
+ }
465
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
466
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
467
+ }
468
+ return;
469
+ }
470
+
471
+ // Model chosen after session creation (the normal Desktop flow: the user
472
+ // picks the model in the UI). Without this the session stays "unknown"
473
+ // because session.created carries no model and session.step.ended has no
474
+ // model field.
475
+ if (event.type === "session.model.selected") {
476
+ const d = event.data || {};
477
+ const sid = d.sessionID;
478
+ if (!sid) return;
479
+ if (d.model && typeof d.model === "object") {
480
+ if (typeof d.model.id === "string") lastKnownModelCache.set(sid, d.model.id);
481
+ if (typeof d.model.providerID === "string") {
482
+ lastKnownProviderCache.set(sid, d.model.providerID);
483
+ }
484
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
485
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
486
+ }
487
+ return;
488
+ }
489
+
490
+ if (event.type === "session.deleted") {
491
+ const sid = event.data?.sessionID;
492
+ if (!sid) return;
493
+ // Discovery reads the persisted parentID chain, so a deleted session drops
494
+ // out of the table as soon as its state.json entry is gone (or, while the
495
+ // stale entry lingers, via the phantom filter — a deleted session has no
496
+ // step). No dedicated in-memory index to clean up.
497
+ clearSessionTracking(sid);
498
+ return;
499
+ }
500
+
501
+ if (event.type === "session.compacted") {
502
+ const sid = event.data?.sessionID;
503
+ if (!sid) return;
504
+ try {
505
+ const lines = buildLiveLinesV2(
506
+ sid,
507
+ lastKnownModel(sid),
508
+ lastKnownProvider(sid),
509
+ lastKnownInput(sid),
510
+ lastKnownCtx(sid),
511
+ lastKnownReasoning(sid),
512
+ );
513
+ writeStateFile(
514
+ sid,
515
+ lastKnownModel(sid),
516
+ lastKnownProvider(sid),
517
+ lastKnownInput(sid),
518
+ lastKnownCtx(sid),
519
+ lastKnownReasoning(sid),
520
+ );
521
+ pushFinalSummary(
522
+ `compacted:${event.id ?? sid}`,
523
+ [
524
+ `[${new Date().toISOString()}] COMPACTED session=${sid} (auto — the context may have overflowed)`,
525
+ ...lines.slice(1),
526
+ ],
527
+ );
528
+ } catch (err) {
529
+ logErr("v2 session.compacted handling failed", err);
530
+ }
531
+ clearSessionTracking(sid, true);
532
+ return;
533
+ }
534
+
535
+ if (event.type === "session.idle") {
536
+ const sid = event.data?.sessionID;
537
+ if (!sid) return;
538
+ try {
539
+ // Fire-and-forget: the client-API fallback must NOT block this
540
+ // instance's event loop (it would delay every other session's events).
541
+ // The result is picked up by a later state write; the 5s throttle bounds
542
+ // the cost. Never rejects.
543
+ void ensureV2Breakdown(ctx, sid).catch(() => {});
544
+ const lines = buildLiveLinesV2(
545
+ sid,
546
+ lastKnownModel(sid),
547
+ lastKnownProvider(sid),
548
+ lastKnownInput(sid),
549
+ lastKnownCtx(sid),
550
+ lastKnownReasoning(sid),
551
+ );
552
+ writeStateFile(
553
+ sid,
554
+ lastKnownModel(sid),
555
+ lastKnownProvider(sid),
556
+ lastKnownInput(sid),
557
+ lastKnownCtx(sid),
558
+ lastKnownReasoning(sid),
559
+ );
560
+ pushFinalSummary(`final:${event.id ?? sid}`, [
561
+ `[${new Date().toISOString()}] FINAL session=${sid}`,
562
+ ...lines.slice(1),
563
+ ]);
564
+ } catch (err) {
565
+ logErr("v2 final summary on idle failed", err);
566
+ }
567
+ clearSessionTracking(sid);
568
+ return;
569
+ }
570
+
571
+ // --- finished LLM step: tokens + live breakdown snapshot ---
572
+ if (event.type === "session.step.ended") {
573
+ const data = event.data || {};
574
+ const sid = data.sessionID;
575
+ if (!sid) return;
576
+ const t = data.tokens || {};
577
+ const input = typeof t.input === "number" ? t.input : 0;
578
+ const output = typeof t.output === "number" ? t.output : 0;
579
+ const reasoning = typeof t.reasoning === "number" ? t.reasoning : 0;
580
+ const contextTokens = tokenCountOf(t);
581
+
582
+ lastKnownInputCache.set(sid, input);
583
+ lastKnownCtxCache.set(sid, contextTokens);
584
+ lastKnownReasoningCache.set(sid, reasoning);
585
+ if (!lastKnownModelCache.has(sid) && data.model) {
586
+ // Some builds put the model on the step payload; harmless when absent.
587
+ if (typeof data.model.id === "string") lastKnownModelCache.set(sid, data.model.id);
588
+ if (typeof data.model.providerID === "string") {
589
+ lastKnownProviderCache.set(sid, data.model.providerID);
590
+ }
591
+ }
592
+ pruneMap(lastKnownInputCache, MAX_TRACKED_SESSIONS);
593
+ pruneMap(lastKnownCtxCache, MAX_TRACKED_SESSIONS);
594
+ pruneMap(lastKnownReasoningCache, MAX_TRACKED_SESSIONS);
595
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
596
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
597
+
598
+ // Accumulate per-session totals for parent subagent roll-up.
599
+ const tot = sessionTotals.get(sid) || {
600
+ input: 0,
601
+ output: 0,
602
+ reasoning: 0,
603
+ ctx: 0,
604
+ modelID: null,
605
+ providerID: "",
606
+ at: 0,
607
+ };
608
+ tot.input += input;
609
+ tot.output += output;
610
+ tot.reasoning += reasoning;
611
+ tot.ctx = contextTokens;
612
+ tot.modelID = lastKnownModel(sid);
613
+ tot.providerID = lastKnownProvider(sid);
614
+ tot.at = Date.now();
615
+ sessionTotals.set(sid, tot);
616
+ pruneMap(sessionTotals, MAX_TRACKED_SESSIONS);
617
+
618
+ // Ensure limits are available (fault-tolerant; await is cheap after warm).
619
+ try {
620
+ await ensureModelLimits(ctx);
621
+ } catch {
622
+ // ignore — absolute token figures only
623
+ }
624
+
625
+ // Make sure a breakdown exists for this session even if the context hook
626
+ // did not fire here (cross-instance mirror / hook gap). Fire-and-forget on
627
+ // purpose: awaiting it would serialize this instance's event handling
628
+ // behind up to the full client timeout. The cached result is written on the
629
+ // NEXT event; the 5s throttle bounds the cost. Never rejects.
630
+ void ensureV2Breakdown(ctx, sid).catch(() => {});
631
+
632
+ const now = Date.now();
633
+ const lastWrite = lastBdWrite.get(sid) || 0;
634
+ if (now - lastWrite >= BREAKDOWN_THROTTLE_MS) {
635
+ lastBdWrite.set(sid, now);
636
+ pruneMap(lastBdWrite, MAX_TRACKED_SESSIONS);
637
+ try {
638
+ const lines = buildLiveLinesV2(
639
+ sid,
640
+ lastKnownModel(sid),
641
+ lastKnownProvider(sid),
642
+ input,
643
+ contextTokens,
644
+ reasoning,
645
+ );
646
+ writeStateFile(
647
+ sid,
648
+ lastKnownModel(sid),
649
+ lastKnownProvider(sid),
650
+ input,
651
+ contextTokens,
652
+ reasoning,
653
+ );
654
+ writeLog(lines);
655
+ } catch (err) {
656
+ logErr("v2 live breakdown write failed", err);
657
+ }
658
+ }
659
+ return;
660
+ }
661
+
662
+ // --- failed step / execution: persist a claimed one-line note ---
663
+ if (
664
+ event.type === "session.step.failed" ||
665
+ event.type === "session.execution.failed" ||
666
+ event.type === "session.execution.interrupted"
667
+ ) {
668
+ const data = event.data || {};
669
+ // sid / error text come from the event payload (untrusted): collapse newlines
670
+ // so they cannot forge extra rows in the human-readable log (CWE-117).
671
+ const sid = oneLine(data.sessionID || "?");
672
+ const interrupted = event.type === "session.execution.interrupted";
673
+ const err = data.error || {};
674
+ const name = interrupted
675
+ ? "Interrupted"
676
+ : typeof err.type === "string"
677
+ ? oneLine(err.type)
678
+ : "session error";
679
+ const detail = oneLine(err.message || data.reason || "").slice(0, 200);
680
+ const bucket = Math.floor(Date.now() / ERROR_TOAST_DEDUP_MS);
681
+ pushFinalSummary(`err:${sid}:${name}:${bucket}`, [
682
+ `[${new Date().toISOString()}] ERROR session=${sid} ${name}${detail ? `: ${detail}` : ""}`,
683
+ ]);
684
+ }
685
+ }
686
+
687
+ export {
688
+ onV2Context,
689
+ handleV2Event,
690
+ estimateBreakdownFromClientMessages,
691
+ V2_FALLBACK_TIMEOUT_MS,
692
+ };