opencode-context-indicator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +242 -0
  3. package/index.js +2367 -0
  4. package/lib/dedup.js +120 -0
  5. package/package.json +57 -0
  6. package/tui.tsx +180 -0
package/index.js ADDED
@@ -0,0 +1,2367 @@
1
+ /**
2
+ * context-indicator.js
3
+ *
4
+ * Context-size indicator + per-category breakdown for opencode.
5
+ *
6
+ * ---------------------------------------------------------------------------
7
+ * TWO MODES (dual-format plugin)
8
+ * ---------------------------------------------------------------------------
9
+ *
10
+ * V1 (opencode >= 1.18.29) — `server()` (preserved unchanged from the original
11
+ * plugin; NOT active on Desktop v2, but kept so the classic runtime keeps
12
+ * working):
13
+ * - throttled real-time TOAST after each assistant message:
14
+ *
15
+ * ctx 12.3k / 131k (9%) · r 1.2k · c 3.4k
16
+ *
17
+ * r = reasoning tokens, c = cache.read tokens (only when nonzero); cost is
18
+ * appended ONLY when the model config carries explicit pricing (custom
19
+ * providers usually omit it -> no cost noise). Metric = the native opencode
20
+ * overflow count: tokens.total when present, else input + output +
21
+ * cache.read + cache.write.
22
+ * - toast signals: session.error, session.compacted, message.part.updated
23
+ * (compaction / retry parts), assistant info.error;
24
+ * - [main]/[sub] attribution via Session.parentID;
25
+ * - subagent per-child ratios via client.session.children + messages;
26
+ * - model limits via client.config.providers(), fallback config() hook
27
+ * (every provider in the config: <providerID>.models[<modelID>].limit).
28
+ *
29
+ * V2 (opencode >= 2.0.16, OpenCode Desktop) — `setup(ctx)`:
30
+ * - NO TOASTS. The Desktop v2 plugin API exposes no TUI/toast channel
31
+ * (issue #49380); a Desktop-extension API that would enable toasts is
32
+ * still a PR (#47948). So the V2 path intentionally drops toast output
33
+ * and delivers the SAME information through the breakdown log below.
34
+ * TODO: restore the realtime toast once the Desktop-extension API ships.
35
+ * - the NICHE of the V2 path is the per-category context breakdown in
36
+ * %TEMP%\context-breakdown.log (os.tmpdir()/context-breakdown.log)
37
+ * - live snapshot (rewritten, atomic temp+rename) and
38
+ * - final summaries on session.idle (in-memory bounded list, persisted by
39
+ * rewriting the file);
40
+ * categories: user / assistant / reasoning / tool(args only) / system
41
+ * prompt / tool schemas / other(= input - sum of estimates) + subagents.
42
+ *
43
+ * V2 sources (verified against @opencode/plugin 2.0.16 / @opencode/schema):
44
+ * - ctx.session.hook("context", cb)
45
+ * SessionContext = { sessionID, model: {providerID,id,variant?},
46
+ * system: SystemPart[] (mut), messages: Message[] (mut),
47
+ * tools: Record<name,{description,input:JsonSchema}>, options,
48
+ * agent }. Fires right before a model request, carrying the FINAL
49
+ * messages + assembled system prompt + the tool schemas. We only
50
+ * READ it (never mutate). This is the 1:1 replacement of V1's
51
+ * experimental.chat.messages.transform + system.transform, and it
52
+ * even gives tool schemas explicitly (V1 had to call client.tool.list)
53
+ * — so the "tool schemas" category is more accurate here than in V1.
54
+ * - ctx.event.subscribe() -> session.created (data.parentID for [sub]
55
+ * tracking + data.model), session.idle / session.deleted /
56
+ * session.compacted (data.sessionID), session.step.ended
57
+ * (data.tokens {input,output,reasoning,cache:{read,write}}, data.cost,
58
+ * data.finish), session.step.failed / session.execution.failed /
59
+ * session.execution.interrupted (notes).
60
+ * - ctx.model.list() -> { data: ModelInfo[] } with limit.context per model
61
+ * (the V2 replacement for client.config.providers()).
62
+ *
63
+ * V2 subagents: the V2 SessionDomain has NO children/messages API (only
64
+ * create/get/.../context). So child sessions are tracked from
65
+ * session.created (parentID) and their token totals accumulated from
66
+ * session.step.ended (keyed by the child's own sessionID). This is
67
+ * event-driven and does not need the removed API.
68
+ *
69
+ * ---------------------------------------------------------------------------
70
+ * TUI STATE BRIDGE (optional sidebar)
71
+ * ---------------------------------------------------------------------------
72
+ *
73
+ * Every live/final log write ALSO rewrites
74
+ * %TEMP%\opencode-context-indicator-state.json (os.tmpdir())
75
+ * — a machine-readable { sessionID: { model, providerID, ctx, usable,
76
+ * limit, categories{...}, updatedAt } } snapshot consumed by the optional
77
+ * TUI sidebar in ./tui.tsx (package entry "./tui"). Written atomically
78
+ * (temp + rename), keyed by session, bounded to MAX_TRACKED_SESSIONS, and
79
+ * strictly additive: any failure is swallowed and never affects the log.
80
+ *
81
+ * ---------------------------------------------------------------------------
82
+ * SHARED DESIGN
83
+ * ---------------------------------------------------------------------------
84
+ *
85
+ * - Metric: the native opencode overflow count — tokens.total when present,
86
+ * else input + output + cache.read + cache.write — compared against the
87
+ * model's usable window (limit.input − reserved, else limit.context −
88
+ * maxOutput). Percentages are NOT clamped at 100% — exceeding the window
89
+ * must stay visible.
90
+ * - Category estimates use a unicode-aware heuristic (see estimateTokens):
91
+ * Cyrillic ~2.5 chars/token, CJK ~1.5, latin/ASCII ~4. These are ESTIMATES,
92
+ * not a tokenizer; "other" is the residual input − sum of estimates.
93
+ * - Fault tolerant: ANY failure in breakdown/file/subagent code is caught and
94
+ * logged — it must never break the main path.
95
+ * - Does NOT touch opencode-token-monitor (token_stats / token_history /
96
+ * token_export keep working unchanged).
97
+ *
98
+ * Cross-instance behaviour: OpenCode loads one plugin instance per location and
99
+ * mirrors the public event stream to every instance. The live snapshot is an
100
+ * idempotent full-file rewrite (written atomically via temp+rename), so
101
+ * concurrent writers cannot corrupt it. Final summaries, however, would be
102
+ * emitted once per instance; they are therefore claimed exactly-once through
103
+ * ./lib/dedup.js (claimOnce("final:<eventID>")). The MESSAGE transform hook /
104
+ * context hook is per served request; breakdown estimates are process-local and
105
+ * rebuilt on the next request.
106
+ *
107
+ * DEBUG_EVENTS: when true, every incoming event.type is appended to
108
+ * %TEMP%\context-events.log (full JSON payload for session.error, sessionID
109
+ * for session.compacted; file truncated at ~1MB). Flip to false to disable.
110
+ */
111
+
112
+ import { tmpdir } from "node:os";
113
+ import {
114
+ appendFileSync,
115
+ mkdirSync,
116
+ readFileSync,
117
+ renameSync,
118
+ statSync,
119
+ writeFileSync,
120
+ } from "node:fs";
121
+ import { join } from "node:path";
122
+ import { claimOnce, pruneMap } from "./lib/dedup.js";
123
+
124
+ const THROTTLE_MS = 2500; // min gap between toasts
125
+ const MIN_GROWTH_TOKENS = 2048; // only re-toast if context grew by this many tokens
126
+ const MAX_TRACKED_SESSIONS = 256; // LRU cap for maps
127
+ const BREAKDOWN_THROTTLE_MS = 7500; // min gap between live breakdown writes
128
+ const MSG_FETCH_THROTTLE_MS = 30000; // min gap between fallback session.messages calls
129
+ const SUBAGENT_THROTTLE_MS = 30000; // min gap between subagent aggregations
130
+ const TOOL_SCHEMA_TTL_MS = 3600 * 1000; // tool schemas rarely change
131
+ const MAX_FINAL_SUMMARIES = 50; // bound the in-memory final-summary log
132
+ // Token-estimate densities by script (unicode heuristic — estimate, not a
133
+ // tokenizer): Latin/ASCII ~4 chars/token, Cyrillic ~2.5, CJK ~1.5.
134
+ const CHARS_PER_TOKEN_OTHER = 4;
135
+ const CHARS_PER_TOKEN_CYRILLIC = 2.5;
136
+ const CHARS_PER_TOKEN_CJK = 1.5;
137
+ const CRITICAL_RATIO = 0.9; // > this fill: bypass growth gate, shorter throttle
138
+ const CRITICAL_THROTTLE_MS = 10000; // min gap between critical (>90%) toasts
139
+ const ERROR_TOAST_DEDUP_MS = 60000; // same error -> at most one toast / window
140
+ const COMPACTED_TOAST_DEDUP_MS = 60000; // compacted-toast dedup window
141
+ const PROVIDERS_TTL_MS = 5 * 60 * 1000; // providers()/model.list() cache TTL after SUCCESS
142
+ const PROVIDERS_FAIL_TTL_MS = 30 * 1000; // retry-no-sooner gate after FAILURE
143
+ const PROVIDERS_FETCH_TIMEOUT_MS = 2000; // hard cap for one providers() call (V1)
144
+ const V2_MODEL_FETCH_TIMEOUT_MS = 8000; // hard cap for one ctx.model.list() call (V2)
145
+ const TOAST_MS_ERROR = 8000; // duration for error / overflow toasts
146
+
147
+ // --- DEBUG instrumentation (behaviour probing on Desktop) ---
148
+ // When true, every incoming event.type is appended to %TEMP%\context-events.log
149
+ // (full JSON payload for session.error, sessionID for session.compacted).
150
+ // The file is truncated once it exceeds ~1MB. Flip to false to disable.
151
+ const DEBUG_EVENTS = false;
152
+ const EVENT_LOG_MAX_BYTES = 1024 * 1024; // ~1MB truncate threshold
153
+
154
+ const LOG_FILE = join(tmpdir(), "context-breakdown.log");
155
+
156
+ // Machine-readable snapshot consumed by the optional TUI sidebar (./tui.tsx,
157
+ // package entry "./tui"). Written next to each log update — same moment, same
158
+ // data. See writeStateFile() below.
159
+ const STATE_FILE = join(tmpdir(), "opencode-context-indicator-state.json");
160
+
161
+ // Separate tap log for raw events (written only when DEBUG_EVENTS is true).
162
+ const EVENT_LOG_FILE = join(tmpdir(), "context-events.log");
163
+
164
+ // Cross-instance dedup markers for the context-indicator (see ./lib/dedup.js).
165
+ // Used for final summaries / compacted / error notes: the live snapshot itself
166
+ // is an idempotent rewrite and needs no claim.
167
+ const DEDUP_DIR = join(tmpdir(), "context-indicator.dedup");
168
+
169
+ // sessionID -> { at, tokens }
170
+ const lastShown = new Map();
171
+
172
+ // `<providerID>:<modelID>` -> { context, input, output, reserved } limit record,
173
+ // populated lazily from the merged config / providers() / model.list.
174
+ const modelLimits = new Map();
175
+
176
+ // `<providerID>:<modelID>` -> { input, output, cacheRead, cacheWrite } — ONLY
177
+ // when the model config carries explicit pricing (custom providers usually omit).
178
+ const modelPrices = new Map();
179
+
180
+ // sessionID -> captured breakdown from transform hooks / context hook / fallback
181
+ // { systemChars, userChars, assistantChars, reasoningTokens, reasoningChars,
182
+ // toolArgsChars, toolSchemasChars, capturedAt, source }
183
+ // NOTE: the *Chars fields are TOKEN estimates (unicode heuristic — see
184
+ // estimateTokens); the name is kept from the original plugin for 1:1 diffability.
185
+ const breakdownCache = new Map();
186
+
187
+ // `<providerID>:<modelID>` -> { chars, at } — cached tool-schema estimate (V1 path)
188
+ const toolSchemaCache = new Map();
189
+
190
+ // sessionID -> { count, input, output, reasoning, at } — subagent aggregation (V1)
191
+ const subagentCache = new Map();
192
+
193
+ // sessionID -> last time we called the expensive session.messages fallback
194
+ const msgFetchTimes = new Map();
195
+
196
+ // sessionID -> last modelID / providerID / input tokens / reasoning seen.
197
+ // The modelID/providerID pair is turned into a `<providerID>:<modelID>` limit
198
+ // lookup key at every modelLimits/modelPrices access site (the maps themselves
199
+ // are model-keyed, these caches are session-keyed by design).
200
+ const lastKnownModelCache = new Map();
201
+ const lastKnownProviderCache = new Map();
202
+ const lastKnownInputCache = new Map();
203
+ const lastKnownReasoningCache = new Map();
204
+ // sessionID -> last native opencode overflow count (tokens.total, else
205
+ // input + output + cache.read + cache.write)
206
+ const lastKnownCtxCache = new Map();
207
+
208
+ // sessionID -> last time we wrote a live breakdown snapshot
209
+ const lastBdWrite = new Map();
210
+
211
+ // sessionID -> { sig, at } — dedup for session.error / info.error toasts
212
+ const lastErrorToast = new Map();
213
+
214
+ // sessionID -> timestamp — dedup for session.compacted toasts
215
+ const lastCompactedToast = new Map();
216
+
217
+ // sessionID -> parentID (null = main session) — [main]/[sub] attribution cache
218
+ const sessionParentCache = new Map();
219
+
220
+ // V2: sessionID -> { input, output, reasoning, ctx, modelID, at } accumulated
221
+ // from session.step.ended, used to fold child-session totals into the parent.
222
+ const sessionTotals = new Map();
223
+
224
+ // Gating for client.config.providers() (V1) fetches:
225
+ // providersLoadedAt — last SUCCESSFUL fetch (full PROVIDERS_TTL_MS cache);
226
+ // providersFailAt — last FAILED/timed-out fetch (short fail TTL, so a
227
+ // broken/hung client does not freeze limits for the
228
+ // full 5 minutes);
229
+ // providersInFlight — in-flight fetch promise (dedups parallel callers).
230
+ let providersLoadedAt = 0;
231
+ let providersFailAt = 0;
232
+ let providersInFlight = null;
233
+
234
+ // Gating for ctx.model.list() (V2) — success TTL + in-flight dedup. Failures
235
+ // are retried after the short gate; limit lookups then simply fall back to
236
+ // absolute tokens (as in V1).
237
+ let v2LimitsLoadedAt = 0;
238
+ let v2LimitsFailAt = 0;
239
+ let v2LimitsInFlight = null;
240
+
241
+ // Bounded list of final summaries written on session.idle
242
+ const finalSummaries = [];
243
+
244
+ function fmt(n) {
245
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
246
+ }
247
+
248
+ // Unicode-aware TOKEN estimate (heuristic, NOT a tokenizer). The string is
249
+ // scanned ONCE by code point and split into three density buckets:
250
+ // - Cyrillic (U+0400–U+04FF + supplements/extensions): ~2.5 chars/token
251
+ // - CJK / Japanese / Korean (U+4E00–U+9FFF, U+3040–U+30FF, …): ~1.5
252
+ // - everything else (latin/ASCII/digits/punct): ~4
253
+ // tokens = cyr/2.5 + cjk/1.5 + other/4. Single O(n) pass, no intermediate
254
+ // arrays (these strings can be 100k+ chars). Guards against absurd input.
255
+ function estimateTokens(text) {
256
+ if (!text) return 0;
257
+ const s = typeof text === "string" ? text : String(text);
258
+ const len = s.length;
259
+ if (len === 0) return 0;
260
+ let other = 0;
261
+ let cyr = 0;
262
+ let cjk = 0;
263
+ for (let i = 0; i < len; i++) {
264
+ const cp = s.codePointAt(i);
265
+ if (cp > 0xffff) i++; // astral pair — consume the low surrogate
266
+ if (cp < 0x80) {
267
+ other++;
268
+ } else if (
269
+ (cp >= 0x0400 && cp <= 0x04ff) || // Cyrillic
270
+ (cp >= 0x0500 && cp <= 0x052f) || // Cyrillic Supplement
271
+ (cp >= 0x2de0 && cp <= 0x2dff) || // Cyrillic Extended-A
272
+ (cp >= 0xa640 && cp <= 0xa69f) || // Cyrillic Extended-B
273
+ (cp >= 0x1c80 && cp <= 0x1c8f) // Cyrillic Extended-C
274
+ ) {
275
+ cyr++;
276
+ } else if (
277
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
278
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
279
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
280
+ (cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
281
+ (cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
282
+ (cp >= 0x3000 && cp <= 0x303f) || // CJK Symbols and Punctuation
283
+ (cp >= 0xff00 && cp <= 0xffef) // Halfwidth/Fullwidth Forms
284
+ ) {
285
+ cjk++;
286
+ } else {
287
+ other++;
288
+ }
289
+ }
290
+ return Math.round(
291
+ other / CHARS_PER_TOKEN_OTHER +
292
+ cyr / CHARS_PER_TOKEN_CYRILLIC +
293
+ cjk / CHARS_PER_TOKEN_CJK,
294
+ );
295
+ }
296
+
297
+ // Token estimate from a serializable value (tool args / params).
298
+ function estJson(value) {
299
+ if (value == null) return 0;
300
+ try {
301
+ return estimateTokens(JSON.stringify(value));
302
+ } catch {
303
+ return 0;
304
+ }
305
+ }
306
+
307
+ // Native opencode overflow count for a tokens object:
308
+ // count = tokens.total || input + output + cache.read + cache.write
309
+ // (with legacy flat cacheRead/cacheWrite fallbacks) — the exact value opencode
310
+ // compares against the model's usable window in its overflow check.
311
+ function tokenCountOf(tokens) {
312
+ if (!tokens) return 0;
313
+ if (typeof tokens.total === "number" && tokens.total > 0) return tokens.total;
314
+ const cacheRead =
315
+ typeof tokens.cache?.read === "number"
316
+ ? tokens.cache.read
317
+ : typeof tokens.cacheRead === "number"
318
+ ? tokens.cacheRead
319
+ : 0;
320
+ const cacheWrite =
321
+ typeof tokens.cache?.write === "number"
322
+ ? tokens.cache.write
323
+ : typeof tokens.cacheWrite === "number"
324
+ ? tokens.cacheWrite
325
+ : 0;
326
+ return (tokens.input || 0) + (tokens.output || 0) + cacheRead + cacheWrite;
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Logging helpers (all fault-tolerant; never throw into the caller).
331
+ // ---------------------------------------------------------------------------
332
+
333
+ function logErr(msg, err) {
334
+ try {
335
+ console.error(`[context-indicator] ${msg}:`, err);
336
+ } catch {
337
+ /* ignore */
338
+ }
339
+ }
340
+
341
+ // Build the full file content: header + live snapshot + final summaries.
342
+ function renderFile(liveLines) {
343
+ const lines = [];
344
+ lines.push(
345
+ "# context-breakdown.log",
346
+ `# generated ${new Date().toISOString()} by context-indicator plugin`,
347
+ "# estimates: unicode heuristic (cyrillic 2.5, CJK 1.5, other 4 chars/token) — estimate, not a tokenizer",
348
+ "# live snapshot is rewritten; final summaries are preserved (bounded)",
349
+ "",
350
+ "## live snapshot",
351
+ ...liveLines,
352
+ "",
353
+ "## final summaries",
354
+ );
355
+ if (finalSummaries.length === 0) {
356
+ lines.push("(none yet)");
357
+ } else {
358
+ for (const s of finalSummaries) lines.push(s);
359
+ }
360
+ return lines.join("\n") + "\n";
361
+ }
362
+
363
+ // Atomically rewrite the whole log file (bounded by design). The temp+rename
364
+ // makes concurrent writers from multiple plugin instances safe: a reader sees
365
+ // either the previous or the new file, never a half-written one.
366
+ function writeLog(liveLines) {
367
+ try {
368
+ const content = renderFile(liveLines);
369
+ const tmp = `${LOG_FILE}.${process.pid}.tmp`;
370
+ writeFileSync(tmp, content, "utf8");
371
+ renameSync(tmp, LOG_FILE);
372
+ } catch (err) {
373
+ logErr("writeLog failed", err);
374
+ }
375
+ }
376
+
377
+ // Append a single line to the file (used for final summaries / notes).
378
+ function appendLogLine(line) {
379
+ try {
380
+ appendFileSync(LOG_FILE, line + "\n", "utf8");
381
+ } catch (err) {
382
+ logErr("appendLogLine failed", err);
383
+ }
384
+ }
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // TUI state bridge (opencode-context-indicator)
388
+ // ---------------------------------------------------------------------------
389
+ // Persist a tiny machine-readable snapshot of the CURRENT breakdown for one
390
+ // session so the optional TUI sidebar (./tui.tsx, package entry "./tui") can
391
+ // render it without talking to the server. The file is one JSON object keyed by
392
+ // sessionID, rewritten atomically (temp + rename) at every log write — same
393
+ // moment, same data. Strictly additive: any failure is swallowed and must never
394
+ // affect the log / toast / event paths.
395
+ function writeStateFile(
396
+ sessionID,
397
+ modelID,
398
+ providerID,
399
+ inputTokens,
400
+ ctxTokens,
401
+ reasoningTokens,
402
+ ) {
403
+ try {
404
+ if (!sessionID) return;
405
+ const bd = breakdownCache.get(sessionID) || null;
406
+ const limit = getModelLimit(providerID, modelID);
407
+ const usableInfo = getUsableContext(providerID, modelID);
408
+ const denom =
409
+ usableInfo != null
410
+ ? usableInfo.usable
411
+ : limit != null && limit > 0
412
+ ? limit
413
+ : null;
414
+ const total =
415
+ typeof ctxTokens === "number" && ctxTokens > 0
416
+ ? ctxTokens
417
+ : inputTokens || 0;
418
+
419
+ // Read the existing file FIRST: when THIS instance has no fresh breakdown
420
+ // (the context hook did not fire here — e.g. the request was served by
421
+ // another plugin instance whose event stream is mirrored to us), keep the
422
+ // values already persisted instead of clobbering them with nulls/zeros.
423
+ let all = {};
424
+ try {
425
+ all = JSON.parse(readFileSync(STATE_FILE, "utf8"));
426
+ } catch {
427
+ all = {}; // missing / empty / corrupt -> start fresh
428
+ }
429
+ if (!all || typeof all !== "object" || Array.isArray(all)) all = {};
430
+ const pc = all[sessionID]?.categories || null;
431
+
432
+ const catUser = bd ? bd.userChars || 0 : pc?.user ?? 0;
433
+ const catAssistant = bd ? bd.assistantChars || 0 : pc?.assistant ?? 0;
434
+ const catReasoning = bd ? bd.reasoningChars || 0 : pc?.reasoning ?? 0;
435
+ const catToolArgs = bd ? bd.toolArgsChars || 0 : pc?.toolArgs ?? 0;
436
+ // system / tool schemas are hook-only; a fallback breakdown has them null
437
+ // and must not erase a value the hook already persisted.
438
+ const catSystem =
439
+ bd && bd.systemChars != null ? bd.systemChars : pc?.system ?? null;
440
+ const catToolSchemas =
441
+ bd && bd.toolSchemasChars != null
442
+ ? bd.toolSchemasChars
443
+ : pc?.toolSchemas ?? null;
444
+
445
+ const sumEstimates =
446
+ catUser +
447
+ catAssistant +
448
+ catReasoning +
449
+ catToolArgs +
450
+ (catSystem || 0) +
451
+ (catToolSchemas || 0);
452
+ const other = Math.max(0, (inputTokens || 0) - sumEstimates);
453
+ const entry = {
454
+ sessionID,
455
+ model: modelID || "unknown",
456
+ providerID: providerID || "",
457
+ ctx: total,
458
+ input: inputTokens || 0,
459
+ usable: denom,
460
+ limit: limit != null ? limit : null,
461
+ reasoning: typeof reasoningTokens === "number" ? reasoningTokens : 0,
462
+ categories: {
463
+ user: catUser,
464
+ assistant: catAssistant,
465
+ reasoning: catReasoning,
466
+ toolArgs: catToolArgs,
467
+ system: catSystem,
468
+ toolSchemas: catToolSchemas,
469
+ other,
470
+ },
471
+ updatedAt: new Date().toISOString(),
472
+ };
473
+ all[sessionID] = entry;
474
+ // Bound the file: keep the most recently updated MAX_TRACKED_SESSIONS.
475
+ const keys = Object.keys(all);
476
+ if (keys.length > MAX_TRACKED_SESSIONS) {
477
+ keys
478
+ .sort((a, b) =>
479
+ String(all[b]?.updatedAt || "").localeCompare(
480
+ String(all[a]?.updatedAt || ""),
481
+ ),
482
+ )
483
+ .slice(MAX_TRACKED_SESSIONS)
484
+ .forEach((k) => delete all[k]);
485
+ }
486
+ const tmp = `${STATE_FILE}.${process.pid}.tmp`;
487
+ writeFileSync(tmp, JSON.stringify(all), "utf8");
488
+ renameSync(tmp, STATE_FILE);
489
+ } catch (err) {
490
+ logErr("writeStateFile failed", err);
491
+ }
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // DEBUG event tap (DEBUG_EVENTS only; fully fault-tolerant — any failure here
496
+ // is swallowed and must never affect the main path).
497
+ // ---------------------------------------------------------------------------
498
+
499
+ // Append one line to the event log, truncating it when it grows past ~1MB.
500
+ function appendEventLog(line) {
501
+ try {
502
+ try {
503
+ const st = statSync(EVENT_LOG_FILE);
504
+ if (st.size > EVENT_LOG_MAX_BYTES) {
505
+ writeFileSync(EVENT_LOG_FILE, "", "utf8"); // truncate, keep it bounded
506
+ }
507
+ } catch {
508
+ // file does not exist yet — nothing to truncate
509
+ }
510
+ appendFileSync(EVENT_LOG_FILE, line + "\n", "utf8");
511
+ } catch (err) {
512
+ logErr("appendEventLog failed", err);
513
+ }
514
+ }
515
+
516
+ // Tap every incoming event: timestamp + type. session.error gets the full
517
+ // JSON payload, session.compacted gets the sessionID. Never throws.
518
+ function debugEvent(event) {
519
+ if (!DEBUG_EVENTS || !event) return;
520
+ try {
521
+ let line = `[${new Date().toISOString()}] ${event.type}`;
522
+ if (event.type === "session.error") {
523
+ try {
524
+ line += ` ${JSON.stringify(event.properties ?? event.data ?? {})}`.slice(
525
+ 0,
526
+ 4000,
527
+ );
528
+ } catch {
529
+ line += " <unserializable payload>";
530
+ }
531
+ } else if (event.type === "session.compacted") {
532
+ const sid =
533
+ event.properties?.sessionID ?? event.data?.sessionID ?? "?";
534
+ line += ` sessionID=${sid}`;
535
+ }
536
+ appendEventLog(line);
537
+ } catch (err) {
538
+ logErr("debugEvent failed", err);
539
+ }
540
+ }
541
+
542
+ // Reject when `promise` has not settled within `ms`. The underlying promise
543
+ // is not cancellable in JS — its late result is simply discarded. The timer
544
+ // is always cleared; never leaves a dangling timeout.
545
+ function withTimeout(promise, ms, label) {
546
+ let timer;
547
+ const gate = new Promise((_, reject) => {
548
+ timer = setTimeout(
549
+ () => reject(new Error(`${label}: no response within ${ms}ms`)),
550
+ ms,
551
+ );
552
+ });
553
+ return Promise.race([promise, gate]).finally(() => clearTimeout(timer));
554
+ }
555
+
556
+ // Stable lookup key for the model-keyed limit/price/schema maps. providerID may
557
+ // be unknown ("" ) — the result is still unique per modelID, but different
558
+ // providers never share a cache slot.
559
+ function modelKey(providerID, modelID) {
560
+ if (typeof modelID !== "string" || modelID.length === 0) return null;
561
+ const p = typeof providerID === "string" && providerID.length > 0 ? providerID : "";
562
+ return `${p}:${modelID}`;
563
+ }
564
+
565
+ // Raw limit record for a model: { context, input, output, reserved } or null.
566
+ function getModelLimitRecord(providerID, modelID) {
567
+ const key = modelKey(providerID, modelID);
568
+ if (!key) return null;
569
+ const v = modelLimits.get(key);
570
+ return v && typeof v === "object" ? v : null;
571
+ }
572
+
573
+ // Context window for a model (null when unknown). Used for subagent ratios and
574
+ // as the degraded denominator when the native usable window is not derivable.
575
+ function getModelLimit(providerID, modelID) {
576
+ const rec = getModelLimitRecord(providerID, modelID);
577
+ if (!rec) return null;
578
+ return typeof rec.context === "number" && rec.context > 0 ? rec.context : null;
579
+ }
580
+
581
+ // Native opencode usable window (see packages/opencode/src/session/overflow.ts):
582
+ // usable = limit.input ? limit.input − reserved : limit.context − maxOutput
583
+ // reserved = compaction.reserved ?? min(20_000, maxOutput)
584
+ // maxOutput = model.limit.output
585
+ // Returns { usable, limit, maxOutput, reserved } or null when not derivable
586
+ // (callers then degrade to count / limit.context, or to no percentage at all).
587
+ function getUsableContext(providerID, modelID) {
588
+ const rec = getModelLimitRecord(providerID, modelID);
589
+ if (!rec) return null;
590
+ const context =
591
+ typeof rec.context === "number" && rec.context > 0 ? rec.context : null;
592
+ const input =
593
+ typeof rec.input === "number" && rec.input > 0 ? rec.input : null;
594
+ const maxOutput =
595
+ typeof rec.output === "number" && rec.output > 0 ? rec.output : 0;
596
+ if (input == null && context == null) return null;
597
+ // Mirrors opencode overflow.ts: reserved = min(20000, maxOutput), i.e. 0 when
598
+ // the model declares no output limit (NOT a 20000 default — that would
599
+ // understate the usable window).
600
+ const reserved =
601
+ typeof rec.reserved === "number" && rec.reserved >= 0
602
+ ? rec.reserved
603
+ : Math.min(20000, maxOutput);
604
+ const usable = input != null ? input - reserved : context - maxOutput;
605
+ if (!(usable > 0)) return null;
606
+ return { usable, limit: context, maxOutput, reserved };
607
+ }
608
+
609
+ // ---------------------------------------------------------------------------
610
+ // V1 breakdown estimation from messages (Array<{info, parts}>).
611
+ // ---------------------------------------------------------------------------
612
+
613
+ function estimateBreakdown(messages) {
614
+ let userChars = 0;
615
+ let assistantChars = 0;
616
+ let reasoningTokens = 0;
617
+ let reasoningChars = 0;
618
+ let toolArgsChars = 0;
619
+
620
+ for (const { info, parts } of messages || []) {
621
+ const partsArr = parts || [];
622
+ if (info?.role === "user") {
623
+ for (const p of partsArr) {
624
+ if (p?.type === "text") userChars += estimateTokens(p.text);
625
+ }
626
+ } else if (info?.role === "assistant") {
627
+ // Exact reasoning token count straight from the message metadata.
628
+ if (typeof info?.tokens?.reasoning === "number") {
629
+ reasoningTokens += info.tokens.reasoning;
630
+ }
631
+ for (const p of partsArr) {
632
+ if (p?.type === "text") {
633
+ assistantChars += estimateTokens(p.text);
634
+ } else if (p?.type === "reasoning") {
635
+ reasoningChars += estimateTokens(p.text);
636
+ } else if (p?.type === "tool") {
637
+ // ONLY the call arguments (state.input). Deliberately NOT summing
638
+ // state.output — in some sessions tool output reached ~29 MB of
639
+ // chars, which a naive char-based estimate turns into "7 million tokens" and a
640
+ // negative remainder. Tool output is not part of the sent prompt.
641
+ toolArgsChars += estJson(p.state?.input);
642
+ }
643
+ }
644
+ }
645
+ }
646
+
647
+ return {
648
+ userChars,
649
+ assistantChars,
650
+ reasoningTokens,
651
+ reasoningChars,
652
+ toolArgsChars,
653
+ };
654
+ }
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // V1 tool schema estimate (cached per modelID).
658
+ // ---------------------------------------------------------------------------
659
+
660
+ async function getToolSchemas(client, providerID, modelID) {
661
+ const key = modelKey(providerID, modelID);
662
+ const cached = key ? toolSchemaCache.get(key) : null;
663
+ if (cached && Date.now() - cached.at < TOOL_SCHEMA_TTL_MS) {
664
+ return cached.chars;
665
+ }
666
+ let chars = 0;
667
+ try {
668
+ const res = await client.tool.list({ query: { provider: providerID, model: modelID } });
669
+ const tools = Array.isArray(res) ? res : res?.data;
670
+ if (Array.isArray(tools)) {
671
+ for (const t of tools) {
672
+ chars += estimateTokens(t?.description) + estJson(t?.parameters);
673
+ }
674
+ }
675
+ } catch (err) {
676
+ logErr(`tool.list failed for ${modelID}`, err);
677
+ }
678
+ if (key) {
679
+ toolSchemaCache.set(key, { chars, at: Date.now() });
680
+ pruneMap(toolSchemaCache, MAX_TRACKED_SESSIONS);
681
+ }
682
+ return chars;
683
+ }
684
+
685
+ // ---------------------------------------------------------------------------
686
+ // V1 model limits: effective values from client.config.providers(), cached.
687
+ // ---------------------------------------------------------------------------
688
+
689
+ // Load effective limits for ALL providers from the server config. Preferred
690
+ // over the config() hook snapshot because it reflects live overrides.
691
+ // Gating: a SUCCESSFUL fetch is cached for PROVIDERS_TTL_MS; a failed or
692
+ // timed-out attempt only blocks retries for PROVIDERS_FAIL_TTL_MS, so limits
693
+ // recover quickly instead of staying stale for the full 5 minutes. All
694
+ // failures are caught here — the function never rejects; callers fall back to
695
+ // whatever the config() hook loaded.
696
+ async function ensureProviderLimits(client) {
697
+ const now = Date.now();
698
+ if (now - providersLoadedAt < PROVIDERS_TTL_MS) return; // fresh success
699
+ if (now - providersFailAt < PROVIDERS_FAIL_TTL_MS) return; // recent failure
700
+ if (providersInFlight) return providersInFlight; // dedup parallel callers
701
+ providersInFlight = (async () => {
702
+ try {
703
+ const res = await withTimeout(
704
+ client.config.providers(),
705
+ PROVIDERS_FETCH_TIMEOUT_MS,
706
+ "config.providers",
707
+ );
708
+ const providers = Array.isArray(res)
709
+ ? res
710
+ : res?.data?.providers ?? res?.providers ?? res?.data;
711
+ if (!Array.isArray(providers)) {
712
+ providersFailAt = Date.now(); // malformed payload -> retry soon
713
+ return;
714
+ }
715
+ for (const p of providers) {
716
+ const models = p?.models;
717
+ if (!models || typeof models !== "object") continue;
718
+ // providerID is on the top level of each provider entry (fall back to
719
+ // `id`; both are accepted across builds).
720
+ const providerID =
721
+ typeof p?.providerID === "string" && p.providerID
722
+ ? p.providerID
723
+ : typeof p?.id === "string"
724
+ ? p.id
725
+ : "";
726
+ for (const [modelID, def] of Object.entries(models)) {
727
+ const key = modelKey(providerID, modelID);
728
+ if (!key) continue;
729
+ const lim = def?.limit;
730
+ if (!lim || typeof lim !== "object") continue;
731
+ const context =
732
+ typeof lim.context === "number" && lim.context > 0 ? lim.context : null;
733
+ const input =
734
+ typeof lim.input === "number" && lim.input > 0 ? lim.input : null;
735
+ const output =
736
+ typeof lim.output === "number" && lim.output > 0 ? lim.output : null;
737
+ if (context == null && input == null) continue;
738
+ const prevRec = modelLimits.get(key);
739
+ modelLimits.set(key, {
740
+ context,
741
+ input,
742
+ output,
743
+ reserved:
744
+ typeof def?.compaction?.reserved === "number"
745
+ ? def.compaction.reserved
746
+ : (prevRec?.reserved ?? null),
747
+ });
748
+ }
749
+ }
750
+ providersLoadedAt = Date.now(); // set ONLY on success
751
+ } catch (err) {
752
+ providersFailAt = Date.now(); // failed attempt -> short retry gate
753
+ logErr("config.providers failed", err);
754
+ } finally {
755
+ providersInFlight = null;
756
+ }
757
+ })();
758
+ return providersInFlight;
759
+ }
760
+
761
+ // ---------------------------------------------------------------------------
762
+ // V1 subagent (child session) aggregation — throttled + cached.
763
+ // ---------------------------------------------------------------------------
764
+
765
+ async function aggregateSubagents(client, sessionID) {
766
+ const now = Date.now();
767
+ const cached = subagentCache.get(sessionID);
768
+ if (cached && now - cached.at < SUBAGENT_THROTTLE_MS) {
769
+ return cached;
770
+ }
771
+ const result = { count: 0, input: 0, output: 0, reasoning: 0, perChild: [], at: now };
772
+ try {
773
+ const res = await client.session.children({ path: { id: sessionID } });
774
+ const children = Array.isArray(res) ? res : res?.data;
775
+ if (Array.isArray(children)) {
776
+ result.count = children.length;
777
+ for (const child of children) {
778
+ try {
779
+ const msgsRes = await client.session.messages({ path: { id: child.id } });
780
+ const msgs = Array.isArray(msgsRes) ? msgsRes : msgsRes?.data;
781
+ let childInput = 0;
782
+ let childModelID = null;
783
+ let childProviderID = "";
784
+ for (const { info } of msgs || []) {
785
+ if (info?.role === "assistant" && info?.tokens) {
786
+ result.input += info.tokens.input || 0;
787
+ result.output += info.tokens.output || 0;
788
+ result.reasoning += info.tokens.reasoning || 0;
789
+ // Per-child fill: the LAST assistant message's native overflow
790
+ // count (tokens.total, else input + output + cache.read +
791
+ // cache.write, incl. legacy cacheRead/cacheWrite) + that child's
792
+ // model — same formula as the main path, so cache-heavy children
793
+ // are not under-reported and still get the ⚠ marker.
794
+ if (typeof info.tokens.input === "number") {
795
+ childInput = tokenCountOf(info.tokens);
796
+ childModelID = info.modelID || childModelID;
797
+ childProviderID = info.providerID || childProviderID;
798
+ }
799
+ }
800
+ }
801
+ const childLimit = getModelLimit(childProviderID, childModelID);
802
+ result.perChild.push({
803
+ id: child.id,
804
+ input: childInput,
805
+ limit: childLimit,
806
+ ratio: childLimit ? childInput / childLimit : null,
807
+ });
808
+ } catch (err) {
809
+ logErr(`subagent messages failed for ${child.id}`, err);
810
+ }
811
+ }
812
+ }
813
+ } catch (err) {
814
+ logErr(`session.children failed for ${sessionID}`, err);
815
+ }
816
+ subagentCache.set(sessionID, result);
817
+ pruneMap(subagentCache, MAX_TRACKED_SESSIONS);
818
+ return result;
819
+ }
820
+
821
+ // ---------------------------------------------------------------------------
822
+ // V1 toast helpers: attribution, safe showing, error/overflow toasts.
823
+ // ---------------------------------------------------------------------------
824
+
825
+ // "[main]" / "[sub]" prefix from Session.parentID. Cached from
826
+ // session.created/updated; falls back to client.session.get once per session.
827
+ // Unknown attribution -> "" (never blocks the toast).
828
+ async function sessionPrefix(client, sessionID) {
829
+ if (!sessionID) return "";
830
+ try {
831
+ if (!sessionParentCache.has(sessionID)) {
832
+ const res = await client.session.get({ path: { id: sessionID } });
833
+ const info = res?.data?.info ?? res?.data ?? res?.info ?? res;
834
+ sessionParentCache.set(sessionID, info?.parentID || null);
835
+ pruneMap(sessionParentCache, MAX_TRACKED_SESSIONS);
836
+ }
837
+ return sessionParentCache.get(sessionID) ? "[sub]" : "[main]";
838
+ } catch (err) {
839
+ logErr(`session.get failed for ${sessionID}`, err);
840
+ return "";
841
+ }
842
+ }
843
+
844
+ // Show a toast; on failure retry once with the "warning" variant (in case the
845
+ // build rejects "error"). Never throws.
846
+ async function showToastSafe(client, message, variant, duration) {
847
+ try {
848
+ await client.tui.showToast({ body: { message, variant, duration } });
849
+ } catch (err) {
850
+ if (variant !== "warning") {
851
+ try {
852
+ await client.tui.showToast({ body: { message, variant: "warning", duration } });
853
+ return;
854
+ } catch (err2) {
855
+ logErr(`toast fallback failed (variant ${variant})`, err2);
856
+ return;
857
+ }
858
+ }
859
+ logErr("toast error", err);
860
+ }
861
+ }
862
+
863
+ function truncate(text, max) {
864
+ const s = String(text ?? "");
865
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
866
+ }
867
+
868
+ // Short session id for toasts (last 6 chars).
869
+ function shortID(sessionID) {
870
+ if (!sessionID) return "";
871
+ const s = String(sessionID);
872
+ return s.length > 8 ? `…${s.slice(-6)}` : s;
873
+ }
874
+
875
+ // Best-effort message extraction from an arbitrary error payload.
876
+ function errMessageOf(err) {
877
+ if (!err) return "";
878
+ if (typeof err === "string") return err;
879
+ return err?.data?.message || err.message || "";
880
+ }
881
+
882
+ // Dedup for error-ish toasts: identical signature for the same session is
883
+ // suppressed within ERROR_TOAST_DEDUP_MS. Returns true when allowed.
884
+ function errorToastDedup(sessionID, sig) {
885
+ const key = sessionID || "__unknown__";
886
+ const now = Date.now();
887
+ const prev = lastErrorToast.get(key);
888
+ if (prev && prev.sig === sig && now - prev.at < ERROR_TOAST_DEDUP_MS) {
889
+ return false;
890
+ }
891
+ lastErrorToast.set(key, { sig, at: now });
892
+ pruneMap(lastErrorToast, MAX_TRACKED_SESSIONS);
893
+ return true;
894
+ }
895
+
896
+ // session.error payload: { sessionID?, error?: { name, data: { message,
897
+ // statusCode?, isRetryable? } } } — sessionID is optional, handle undefined.
898
+ async function toastSessionError(client, event) {
899
+ const props = event.properties || {};
900
+ const sessionID =
901
+ typeof props.sessionID === "string" ? props.sessionID : undefined;
902
+ const error = props.error || {};
903
+ const name = error.name || error?.data?.name || "session error";
904
+ const detail = errMessageOf(error);
905
+ const status = error?.data?.statusCode;
906
+ const sig = `${name}|${status ?? ""}|${String(detail).slice(0, 120)}`;
907
+ if (!errorToastDedup(sessionID, sig)) return;
908
+
909
+ let text = name;
910
+ try {
911
+ const prefix = await sessionPrefix(client, sessionID);
912
+ if (prefix) text = `${prefix} ${text}`;
913
+ if (status != null) text += ` (HTTP ${status})`;
914
+ if (detail) text += `: ${truncate(detail, 160)}`;
915
+ if (sessionID) text += ` · ${shortID(sessionID)}`;
916
+ } catch (err) {
917
+ logErr("session.error message build failed", err);
918
+ }
919
+ await showToastSafe(client, text.trim(), "error", TOAST_MS_ERROR);
920
+ }
921
+
922
+ // Assistant message with info.error: force a warning toast bypassing the
923
+ // throttle gates (same dedup as session.error).
924
+ async function toastMessageError(client, sessionID, error) {
925
+ const name = (error && (error.name || error?.data?.name)) || "model error";
926
+ const detail = errMessageOf(error);
927
+ const sig = `msg|${name}|${String(detail).slice(0, 120)}`;
928
+ if (!errorToastDedup(sessionID, sig)) return;
929
+
930
+ let text = name;
931
+ try {
932
+ const prefix = await sessionPrefix(client, sessionID);
933
+ if (prefix) text = `${prefix} ${text}`;
934
+ } catch (err) {
935
+ logErr("message.error prefix failed", err);
936
+ }
937
+ if (detail) text += `: ${truncate(detail, 160)}`;
938
+ await showToastSafe(client, text.trim(), "warning", TOAST_MS_ERROR);
939
+ }
940
+
941
+ // session.compacted (and compaction parts): auto-compaction usually means the
942
+ // context window was exceeded. MUST run BEFORE clearSessionTracking — it reads
943
+ // the last known input/model, which are wiped right after.
944
+ async function toastCompactedSignal(client, sessionID) {
945
+ if (!sessionID) return;
946
+ const now = Date.now();
947
+ const prev = lastCompactedToast.get(sessionID);
948
+ if (prev && now - prev < COMPACTED_TOAST_DEDUP_MS) return;
949
+ lastCompactedToast.set(sessionID, now);
950
+ pruneMap(lastCompactedToast, MAX_TRACKED_SESSIONS);
951
+
952
+ let text = "session compacted (auto) — возможно, контекст был переполнен";
953
+ try {
954
+ const prefix = await sessionPrefix(client, sessionID);
955
+ const ctx = lastKnownCtx(sessionID) || lastKnownInput(sessionID);
956
+ const limit = getModelLimit(lastKnownProvider(sessionID), lastKnownModel(sessionID));
957
+ if (ctx > 0) text += ` (ctx ${fmt(ctx)}${limit ? ` / ${fmt(limit)}` : ""})`;
958
+ if (prefix) text = `${prefix} ${text}`;
959
+ } catch (err) {
960
+ logErr("compacted message build failed", err);
961
+ }
962
+ await showToastSafe(client, text.trim(), "warning", TOAST_MS_ERROR);
963
+ }
964
+
965
+ // Best-effort mid-turn hints from message.part.updated: compaction parts
966
+ // mirror session.compacted; retry parts surface provider errors when they
967
+ // carry one. Never blocks the main path.
968
+ async function maybeToastPartSignal(client, event) {
969
+ const part = event.properties?.part;
970
+ if (!part || !part.type) return;
971
+ const sessionID = part.sessionID || event.properties?.sessionID || undefined;
972
+ if (!sessionID) return;
973
+
974
+ if (part.type === "compaction") {
975
+ try {
976
+ await toastCompactedSignal(client, sessionID); // shared dedup window
977
+ } catch (err) {
978
+ logErr("compaction part toast failed", err);
979
+ }
980
+ return;
981
+ }
982
+
983
+ if (part.type !== "retry") return;
984
+ const err = part.error ?? part.state?.error;
985
+ const detail = errMessageOf(err);
986
+ if (!detail) return;
987
+ const name = (err && (err.name || err?.data?.name)) || "retry";
988
+ const sig = `part|${name}|${String(detail).slice(0, 120)}`;
989
+ if (!errorToastDedup(sessionID, sig)) return;
990
+ let text = `${name}: ${truncate(detail, 160)}`;
991
+ try {
992
+ const prefix = await sessionPrefix(client, sessionID);
993
+ if (prefix) text = `${prefix} ${text}`;
994
+ } catch {
995
+ // attribution is optional here
996
+ }
997
+ await showToastSafe(client, text.trim(), "warning", TOAST_MS_ERROR);
998
+ }
999
+
1000
+ // ---------------------------------------------------------------------------
1001
+ // V1 fallback: fetch messages via client.session.messages (expensive, throttled).
1002
+ // ---------------------------------------------------------------------------
1003
+
1004
+ async function fetchBreakdownFallback(client, sessionID) {
1005
+ const now = Date.now();
1006
+ const last = msgFetchTimes.get(sessionID) || 0;
1007
+ if (now - last < MSG_FETCH_THROTTLE_MS) return null; // too soon -> skip
1008
+ msgFetchTimes.set(sessionID, now);
1009
+ pruneMap(msgFetchTimes, MAX_TRACKED_SESSIONS);
1010
+
1011
+ try {
1012
+ const res = await client.session.messages({ path: { id: sessionID } });
1013
+ const msgs = Array.isArray(res) ? res : res?.data;
1014
+ const estParts = estimateBreakdown(msgs);
1015
+ return { ...estParts, capturedAt: now, source: "fallback" };
1016
+ } catch (err) {
1017
+ logErr(`session.messages fallback failed for ${sessionID}`, err);
1018
+ return null;
1019
+ }
1020
+ }
1021
+
1022
+ // ---------------------------------------------------------------------------
1023
+ // Clear per-session tracking. Full wipe on session.idle / session.deleted;
1024
+ // on session.compacted (keepToastDedup=true) the toast-dedup entries survive
1025
+ // the cleanup so consecutive compacted events still dedup.
1026
+ // ---------------------------------------------------------------------------
1027
+
1028
+ function clearSessionTracking(sessionID, keepToastDedup = false) {
1029
+ if (!sessionID) return;
1030
+ lastShown.delete(sessionID);
1031
+ breakdownCache.delete(sessionID);
1032
+ subagentCache.delete(sessionID);
1033
+ msgFetchTimes.delete(sessionID);
1034
+ // Error/overflow + compacted toast dedup entries.
1035
+ if (!keepToastDedup) {
1036
+ lastErrorToast.delete(sessionID);
1037
+ lastCompactedToast.delete(sessionID);
1038
+ }
1039
+ // Attribution cache (re-resolved on the next toast if needed).
1040
+ sessionParentCache.delete(sessionID);
1041
+ // Idle-summary caches were previously left dirty — clean them too.
1042
+ lastKnownModelCache.delete(sessionID);
1043
+ lastKnownProviderCache.delete(sessionID);
1044
+ lastKnownInputCache.delete(sessionID);
1045
+ lastKnownReasoningCache.delete(sessionID);
1046
+ lastKnownCtxCache.delete(sessionID);
1047
+ lastBdWrite.delete(sessionID);
1048
+ // V2 subagent accumulation for this session.
1049
+ sessionTotals.delete(sessionID);
1050
+ }
1051
+
1052
+ // ---------------------------------------------------------------------------
1053
+ // V1: assemble the live breakdown lines for the log file.
1054
+ // ---------------------------------------------------------------------------
1055
+
1056
+ async function buildLiveLines(client, sessionID, modelID, providerID, inputTokens, ctxTokens) {
1057
+ const lines = [];
1058
+ const ts = new Date().toISOString();
1059
+ const limit = getModelLimit(providerID, modelID);
1060
+ const usableInfo = getUsableContext(providerID, modelID);
1061
+ // Total context = native overflow count (tokens.total, else input + output +
1062
+ // cache.read + cache.write); pct has NO upper clamp — exceeding the window
1063
+ // must stay visible in the log too. Denominator is the native usable window
1064
+ // when derivable, else the raw context window.
1065
+ const total = typeof ctxTokens === "number" && ctxTokens > 0 ? ctxTokens : inputTokens;
1066
+ const denom =
1067
+ usableInfo != null
1068
+ ? usableInfo.usable
1069
+ : limit != null && limit > 0
1070
+ ? limit
1071
+ : null;
1072
+ const pct =
1073
+ denom != null ? ` (${((total / denom) * 100).toFixed(1)}%)` : "";
1074
+
1075
+ lines.push(
1076
+ `[${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)}`,
1077
+ );
1078
+
1079
+ // --- breakdown from cache (transform hooks) or fallback fetch ---
1080
+ let bd = breakdownCache.get(sessionID);
1081
+ let source = bd?.source || "none";
1082
+
1083
+ if (!bd || Date.now() - bd.capturedAt > MSG_FETCH_THROTTLE_MS) {
1084
+ // Try to refresh via fallback if we have nothing fresh.
1085
+ const fb = await fetchBreakdownFallback(client, sessionID);
1086
+ if (fb) {
1087
+ bd = fb;
1088
+ source = fb.source;
1089
+ breakdownCache.set(sessionID, fb);
1090
+ pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
1091
+ }
1092
+ }
1093
+
1094
+ // Tool schemas: cached per modelID (rarely change). Stored into the session
1095
+ // breakdown so the estimate is stable across writes.
1096
+ let toolSchemasChars = bd?.toolSchemasChars;
1097
+ if (toolSchemasChars == null) {
1098
+ try {
1099
+ toolSchemasChars = await getToolSchemas(client, providerID, modelID);
1100
+ if (bd) {
1101
+ bd.toolSchemasChars = toolSchemasChars;
1102
+ breakdownCache.set(sessionID, bd);
1103
+ }
1104
+ } catch (err) {
1105
+ logErr("tool schema estimate failed", err);
1106
+ toolSchemasChars = null;
1107
+ }
1108
+ }
1109
+
1110
+ const sumEstimates = (bd?.userChars || 0) +
1111
+ (bd?.assistantChars || 0) +
1112
+ (bd?.reasoningChars || 0) +
1113
+ (bd?.toolArgsChars || 0) +
1114
+ (bd?.systemChars || 0) +
1115
+ (bd?.toolSchemasChars || 0);
1116
+ const other = Math.max(0, inputTokens - sumEstimates);
1117
+
1118
+ if (bd) {
1119
+ lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
1120
+ lines.push(` assistant ${fmt(bd.assistantChars || 0)} (est tokens)`);
1121
+ const reasoningNote =
1122
+ bd.reasoningTokens > 0
1123
+ ? ` (exact ${fmt(bd.reasoningTokens)})`
1124
+ : "";
1125
+ lines.push(` reasoning ${fmt(bd.reasoningChars || 0)} (est tokens)${reasoningNote}`);
1126
+ lines.push(` tool args ${fmt(bd.toolArgsChars || 0)} (est tokens, input only)`);
1127
+ lines.push(
1128
+ bd.systemChars != null
1129
+ ? ` system ${fmt(bd.systemChars)} (est tokens)`
1130
+ : ` system n/a (hook не сработал)`,
1131
+ );
1132
+ lines.push(
1133
+ bd.toolSchemasChars != null
1134
+ ? ` tool schemas ${fmt(bd.toolSchemasChars)} (est tokens)`
1135
+ : ` tool schemas n/a`,
1136
+ );
1137
+ } else {
1138
+ lines.push(` user n/a (no breakdown captured yet)`);
1139
+ lines.push(` assistant n/a`);
1140
+ lines.push(` reasoning n/a`);
1141
+ lines.push(` tool args n/a`);
1142
+ lines.push(` system n/a (hook не сработал)`);
1143
+ lines.push(` tool schemas n/a`);
1144
+ }
1145
+ lines.push(` other ${fmt(other)} (= input - sum of estimates)`);
1146
+ lines.push(` source ${source}`);
1147
+
1148
+ // --- subagent aggregation (informational only, own context window) ---
1149
+ try {
1150
+ const sub = await aggregateSubagents(client, sessionID);
1151
+ if (sub.count > 0) {
1152
+ const ratios = (sub.perChild || [])
1153
+ .map((c) => c?.ratio)
1154
+ .filter((r) => typeof r === "number");
1155
+ const worst =
1156
+ ratios.length > 0
1157
+ ? ` worst=${(Math.max(...ratios) * 100).toFixed(0)}%`
1158
+ : "";
1159
+ lines.push(
1160
+ ` subagents ${sub.count} session(s): in=${fmt(sub.input)} out=${fmt(sub.output)} r=${fmt(sub.reasoning)}${worst}`,
1161
+ );
1162
+ } else {
1163
+ lines.push(` subagents 0`);
1164
+ }
1165
+ } catch (err) {
1166
+ logErr("subagent aggregation failed", err);
1167
+ lines.push(` subagents n/a`);
1168
+ }
1169
+
1170
+ return lines;
1171
+ }
1172
+
1173
+ // ---------------------------------------------------------------------------
1174
+ // V1 entrypoint (event handler + hooks), preserved unchanged.
1175
+ // ---------------------------------------------------------------------------
1176
+
1177
+ function makeV1(client) {
1178
+ // Fire-and-forget prefetch of effective model limits (fault-tolerant; the
1179
+ // config() hook below stays as the fallback source).
1180
+ ensureProviderLimits(client).catch((err) =>
1181
+ logErr("provider limits prefetch failed", err),
1182
+ );
1183
+
1184
+ return {
1185
+ config(cfg) {
1186
+ try {
1187
+ // Provider-agnostic: iterate EVERY provider in the config, regardless
1188
+ // of name. V1 configs expose `provider`; some shapes use `providers`.
1189
+ const buckets = [cfg?.provider, cfg?.providers].filter(
1190
+ (b) => b && typeof b === "object",
1191
+ );
1192
+ if (buckets.length === 0) return;
1193
+ for (const bucket of buckets) {
1194
+ for (const [providerID, pdef] of Object.entries(bucket)) {
1195
+ const models = pdef?.models;
1196
+ if (!models || typeof models !== "object") continue;
1197
+ for (const [modelID, def] of Object.entries(models)) {
1198
+ const key = modelKey(providerID, modelID);
1199
+ if (!key) continue;
1200
+ const lim = def?.limit;
1201
+ const context =
1202
+ typeof lim?.context === "number" && lim.context > 0 ? lim.context : null;
1203
+ const input =
1204
+ typeof lim?.input === "number" && lim.input > 0 ? lim.input : null;
1205
+ const output =
1206
+ typeof lim?.output === "number" && lim.output > 0 ? lim.output : null;
1207
+ if (context != null || input != null) {
1208
+ const prevRec = modelLimits.get(key);
1209
+ modelLimits.set(key, {
1210
+ context,
1211
+ input,
1212
+ output,
1213
+ reserved:
1214
+ typeof def?.compaction?.reserved === "number"
1215
+ ? def.compaction.reserved
1216
+ : (prevRec?.reserved ?? null),
1217
+ });
1218
+ }
1219
+ // Capture explicit pricing ONLY when present (custom providers
1220
+ // usually omit it).
1221
+ const c = def?.cost;
1222
+ if (
1223
+ c &&
1224
+ typeof c === "object" &&
1225
+ (typeof c.input === "number" || typeof c.output === "number")
1226
+ ) {
1227
+ modelPrices.set(key, {
1228
+ input: typeof c.input === "number" ? c.input : 0,
1229
+ output: typeof c.output === "number" ? c.output : 0,
1230
+ cacheRead: typeof c?.cache?.read === "number" ? c.cache.read : 0,
1231
+ cacheWrite: typeof c?.cache?.write === "number" ? c.cache.write : 0,
1232
+ });
1233
+ }
1234
+ }
1235
+ }
1236
+ }
1237
+ } catch {
1238
+ // keep whatever we already have
1239
+ }
1240
+ },
1241
+
1242
+ // Capture the final messages right before they are sent to the LLM.
1243
+ // We only READ here — never mutate the outgoing messages.
1244
+ "experimental.chat.messages.transform"(_input, { messages }) {
1245
+ try {
1246
+ if (!Array.isArray(messages) || messages.length === 0) return;
1247
+ const sid = messages[0]?.info?.sessionID;
1248
+ if (!sid) return;
1249
+ const estParts = estimateBreakdown(messages);
1250
+ const prev = breakdownCache.get(sid) || {};
1251
+ breakdownCache.set(sid, {
1252
+ ...prev,
1253
+ ...estParts,
1254
+ capturedAt: Date.now(),
1255
+ source: "transform",
1256
+ });
1257
+ pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
1258
+ } catch (err) {
1259
+ logErr("messages.transform failed", err);
1260
+ }
1261
+ },
1262
+
1263
+ // Capture the assembled system prompt (string[]) before send.
1264
+ "experimental.chat.system.transform"({ sessionID }, { system }) {
1265
+ try {
1266
+ const sid = sessionID;
1267
+ if (!sid) return;
1268
+ let systemChars = 0;
1269
+ if (Array.isArray(system)) {
1270
+ for (const s of system) systemChars += estimateTokens(s);
1271
+ }
1272
+ const prev = breakdownCache.get(sid) || {};
1273
+ breakdownCache.set(sid, {
1274
+ ...prev,
1275
+ systemChars,
1276
+ capturedAt: Date.now(),
1277
+ source: prev.source || "system-transform",
1278
+ });
1279
+ pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
1280
+ } catch (err) {
1281
+ logErr("system.transform failed", err);
1282
+ }
1283
+ },
1284
+
1285
+ async event({ event }) {
1286
+ if (!event) return;
1287
+
1288
+ // DEBUG: log every incoming event type (fault-tolerant, off via flag).
1289
+ debugEvent(event);
1290
+
1291
+ // Cache parent links for [main]/[sub] attribution from lifecycle events
1292
+ // (properties.info carries parentID when present).
1293
+ if (
1294
+ event.type === "session.created" ||
1295
+ event.type === "session.updated"
1296
+ ) {
1297
+ try {
1298
+ const sinfo = event.properties?.info;
1299
+ if (sinfo?.id && sinfo.parentID) {
1300
+ sessionParentCache.set(sinfo.id, sinfo.parentID);
1301
+ pruneMap(sessionParentCache, MAX_TRACKED_SESSIONS);
1302
+ }
1303
+ } catch (err) {
1304
+ logErr("session parent tracking failed", err);
1305
+ }
1306
+ return;
1307
+ }
1308
+
1309
+ // Cleanup tracked sessions when they end / are deleted / are compacted.
1310
+ if (
1311
+ event.type === "session.idle" ||
1312
+ event.type === "session.deleted" ||
1313
+ event.type === "session.compacted"
1314
+ ) {
1315
+ // session.idle / session.compacted -> properties.sessionID;
1316
+ // session.deleted -> properties.info.id
1317
+ const sid =
1318
+ event.properties?.sessionID ?? event.properties?.info?.id;
1319
+ if (!sid) return;
1320
+
1321
+ // session.compacted: toast BEFORE clearing tracking — it needs the
1322
+ // last known input/model, which clearSessionTracking wipes below.
1323
+ if (event.type === "session.compacted") {
1324
+ try {
1325
+ await toastCompactedSignal(client, sid);
1326
+ } catch (err) {
1327
+ logErr("session.compacted toast failed", err);
1328
+ }
1329
+ }
1330
+
1331
+ if (event.type === "session.idle") {
1332
+ // Final summary to the log (best effort; never breaks anything).
1333
+ // Timestamp captured BEFORE the async build so the cross-instance
1334
+ // dedup key is stable across plugin instances.
1335
+ const idleAt = Date.now();
1336
+ try {
1337
+ const modelID = lastKnownModel(sid);
1338
+ const providerID = lastKnownProvider(sid);
1339
+ const inputTokens = lastKnownInput(sid);
1340
+ const lines = await buildLiveLines(
1341
+ client,
1342
+ sid,
1343
+ modelID,
1344
+ providerID,
1345
+ inputTokens,
1346
+ lastKnownCtx(sid),
1347
+ );
1348
+ writeStateFile(
1349
+ sid,
1350
+ modelID,
1351
+ providerID,
1352
+ inputTokens,
1353
+ lastKnownCtx(sid),
1354
+ lastKnownReasoning(sid),
1355
+ );
1356
+ // Claim once across instances: without this, every plugin instance
1357
+ // appends its own FINAL line. V1 events carry no `id`, so fall back
1358
+ // to a short time bucket (same strategy as the error-toast dedup).
1359
+ const eventKey = event.id ?? event.properties?.id;
1360
+ const claimKey = eventKey
1361
+ ? `final:${eventKey}`
1362
+ : `final:${sid}:${Math.floor(idleAt / 5000)}`;
1363
+ pushFinalSummary(claimKey, [
1364
+ `[${new Date().toISOString()}] FINAL session=${sid}`,
1365
+ ...lines.slice(1),
1366
+ ]);
1367
+ } catch (err) {
1368
+ logErr("final summary on idle failed", err);
1369
+ }
1370
+ }
1371
+
1372
+ // session.compacted keeps the toast-dedup entries: two consecutive
1373
+ // session.compacted events must not produce two toasts (60s window).
1374
+ clearSessionTracking(sid, event.type === "session.compacted");
1375
+ return;
1376
+ }
1377
+
1378
+ // Session-level errors (provider failures etc.). sessionID is optional
1379
+ // in the payload — handled gracefully inside.
1380
+ if (event.type === "session.error") {
1381
+ try {
1382
+ await toastSessionError(client, event);
1383
+ } catch (err) {
1384
+ logErr("session.error handling failed", err);
1385
+ }
1386
+ return;
1387
+ }
1388
+
1389
+ // Mid-turn overflow / retry hints from compaction & retry parts.
1390
+ if (event.type === "message.part.updated") {
1391
+ try {
1392
+ await maybeToastPartSignal(client, event);
1393
+ } catch (err) {
1394
+ logErr("message.part.updated signal failed", err);
1395
+ }
1396
+ return;
1397
+ }
1398
+
1399
+ if (event.type !== "message.updated") return;
1400
+
1401
+ const info = event?.properties?.info;
1402
+ if (!info || info.role !== "assistant") return;
1403
+
1404
+ const sessionID = info.sessionID;
1405
+
1406
+ // Assistant message carrying an error (when present in this build):
1407
+ // force a warning toast, bypassing the throttle gates. Same dedup as
1408
+ // session.error.
1409
+ if (info.error) {
1410
+ try {
1411
+ await toastMessageError(client, sessionID, info.error);
1412
+ } catch (err) {
1413
+ logErr("info.error toast failed", err);
1414
+ }
1415
+ return;
1416
+ }
1417
+
1418
+ const tokens = info.tokens;
1419
+ if (!tokens || typeof tokens.input !== "number") return;
1420
+
1421
+ // Context size components. The "how full is the window" metric is the
1422
+ // native opencode overflow count: tokens.total when present, else
1423
+ // input + output + cache.read + cache.write (cache tokens included).
1424
+ const cacheRead =
1425
+ typeof tokens.cache?.read === "number"
1426
+ ? tokens.cache.read
1427
+ : typeof tokens.cacheRead === "number"
1428
+ ? tokens.cacheRead
1429
+ : 0;
1430
+ const cacheWrite =
1431
+ typeof tokens.cache?.write === "number"
1432
+ ? tokens.cache.write
1433
+ : typeof tokens.cacheWrite === "number"
1434
+ ? tokens.cacheWrite
1435
+ : 0;
1436
+ const contextTokens = tokenCountOf(tokens);
1437
+
1438
+ // Remember last known model/provider/input for the idle final summary.
1439
+ if (info.modelID) lastKnownModelCache.set(sessionID, info.modelID);
1440
+ if (info.providerID) lastKnownProviderCache.set(sessionID, info.providerID);
1441
+ lastKnownInputCache.set(sessionID, tokens.input);
1442
+ if (typeof tokens.reasoning === "number") {
1443
+ lastKnownReasoningCache.set(sessionID, tokens.reasoning);
1444
+ }
1445
+ lastKnownCtxCache.set(sessionID, contextTokens);
1446
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
1447
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
1448
+ pruneMap(lastKnownInputCache, MAX_TRACKED_SESSIONS);
1449
+ pruneMap(lastKnownReasoningCache, MAX_TRACKED_SESSIONS);
1450
+ pruneMap(lastKnownCtxCache, MAX_TRACKED_SESSIONS);
1451
+
1452
+ // Resolve the limit for THIS message's model, keyed by
1453
+ // providerID:modelID. Prefer the effective limits from
1454
+ // client.config.providers(); the config() hook remains the fallback.
1455
+ const mkey = modelKey(info.providerID, info.modelID);
1456
+ let limit = getModelLimit(info.providerID, info.modelID);
1457
+ let usableInfo = getUsableContext(info.providerID, info.modelID);
1458
+ if (limit == null && usableInfo == null) {
1459
+ try {
1460
+ await ensureProviderLimits(client);
1461
+ } catch {
1462
+ // ignore — fall back to whatever the config() hook provided
1463
+ }
1464
+ limit = getModelLimit(info.providerID, info.modelID);
1465
+ usableInfo = getUsableContext(info.providerID, info.modelID);
1466
+ }
1467
+
1468
+ const now = Date.now();
1469
+ const prev = lastShown.get(sessionID);
1470
+ // Native usable window when derivable; else the raw context window; else
1471
+ // no percentage at all (legacy behaviour).
1472
+ const denom =
1473
+ usableInfo != null
1474
+ ? usableInfo.usable
1475
+ : limit != null && limit > 0
1476
+ ? limit
1477
+ : null;
1478
+ const ratio = denom != null ? contextTokens / denom : 0;
1479
+ const critical = ratio > CRITICAL_RATIO;
1480
+ // Throttle: suppress if NOT enough time has passed OR growth is small.
1481
+ // Critical (>90%) toasts skip the growth gate and use a shorter gap so
1482
+ // threshold crossings are not silenced by MIN_GROWTH_TOKENS.
1483
+ if (
1484
+ prev &&
1485
+ (now - prev.at < (critical ? CRITICAL_THROTTLE_MS : THROTTLE_MS) ||
1486
+ (!critical && contextTokens - prev.tokens < MIN_GROWTH_TOKENS))
1487
+ ) {
1488
+ return;
1489
+ }
1490
+
1491
+ lastShown.set(sessionID, { at: now, tokens: contextTokens });
1492
+ pruneMap(lastShown, MAX_TRACKED_SESSIONS);
1493
+
1494
+ let message;
1495
+ let variant = "info";
1496
+ if (denom != null) {
1497
+ // No upper clamp: exceeding the window must stay visible (>100%).
1498
+ const pct = ((contextTokens / denom) * 100).toFixed(1);
1499
+ message = `ctx ${fmt(contextTokens)} / ${fmt(denom)} (${pct}%)`;
1500
+ if (ratio > 0.75) variant = "warning";
1501
+ } else {
1502
+ message = `ctx ${fmt(contextTokens)}`;
1503
+ }
1504
+
1505
+ // F2: extend the toast with reasoning / cache.read when nonzero
1506
+ // (raw reported values, unchanged).
1507
+ const extras = [];
1508
+ const reasoning = typeof tokens.reasoning === "number" ? tokens.reasoning : 0;
1509
+ if (reasoning > 0) extras.push(`r ${fmt(reasoning)}`);
1510
+ if (cacheRead > 0) extras.push(`c ${fmt(cacheRead)}`);
1511
+
1512
+ // Cost: only when the model config carries explicit pricing. Each token
1513
+ // class is priced with its OWN rate; reasoning is already counted inside
1514
+ // `output` (opencode reports output inclusive of reasoning), so it is NOT
1515
+ // added separately. Absent rates are simply skipped.
1516
+ const price = mkey ? modelPrices.get(mkey) : null;
1517
+ if (price) {
1518
+ const inputTok = typeof tokens.input === "number" ? tokens.input : 0;
1519
+ const outputTok = typeof tokens.output === "number" ? tokens.output : 0;
1520
+ const cost =
1521
+ (inputTok / 1000) * (price.input || 0) +
1522
+ (outputTok / 1000) * (price.output || 0) +
1523
+ (cacheRead / 1000) * (price.cacheRead || 0) +
1524
+ (cacheWrite / 1000) * (price.cacheWrite || 0);
1525
+ if (cost > 0) extras.push(`${cost.toFixed(4)}$`);
1526
+ }
1527
+ if (extras.length > 0) {
1528
+ message = `${message} · ${extras.join(" · ")}`;
1529
+ }
1530
+
1531
+ // F3: compact subagent info in the toast (fresh cached value only).
1532
+ // Per-child ratios come from the cached aggregation — no extra calls.
1533
+ try {
1534
+ const sub = subagentCache.get(sessionID);
1535
+ if (sub && sub.count > 0 && now - sub.at < SUBAGENT_THROTTLE_MS) {
1536
+ const perChild = Array.isArray(sub.perChild) ? sub.perChild : [];
1537
+ const ratios = perChild
1538
+ .map((c) => c?.ratio)
1539
+ .filter((r) => typeof r === "number");
1540
+ if (ratios.length > 0) {
1541
+ const worst = Math.max(...ratios);
1542
+ const worstPct = `${(worst * 100).toFixed(0)}%`;
1543
+ const hot = perChild.filter(
1544
+ (c) => typeof c?.ratio === "number" && c.ratio > CRITICAL_RATIO,
1545
+ ).length;
1546
+ message = hot > 0
1547
+ ? `${message} · sub ${sub.count}, ${hot}⚠ (${worstPct})`
1548
+ : `${message} · sub ${sub.count}, worst ${worstPct}`;
1549
+ } else {
1550
+ // No known limits for the children -> legacy cumulative total.
1551
+ const subTotal = sub.input + sub.output + sub.reasoning;
1552
+ message = `${message} · sub ${fmt(subTotal)}`;
1553
+ }
1554
+ }
1555
+ } catch (err) {
1556
+ logErr("toast subagent suffix failed", err);
1557
+ }
1558
+
1559
+ // Attribution: [main]/[sub] by Session.parentID (cached; faults ignored).
1560
+ try {
1561
+ const prefix = await sessionPrefix(client, sessionID);
1562
+ if (prefix) message = `${prefix} ${message}`;
1563
+ } catch (err) {
1564
+ logErr("attribution prefix failed", err);
1565
+ }
1566
+
1567
+ try {
1568
+ await showToastSafe(client, message, variant, 3500);
1569
+ } catch (err) {
1570
+ logErr("toast error", err);
1571
+ }
1572
+
1573
+ // F1: live breakdown to the log (throttled separately, fault-tolerant).
1574
+ // Writes whenever enough wall-clock time has passed since the last write;
1575
+ // the breakdown cache itself may be fresh (from transform hooks) or stale
1576
+ // (then buildLiveLines refreshes it via the fallback fetch).
1577
+ try {
1578
+ const lastWrite = lastBdWrite.get(sessionID) || 0;
1579
+ if (now - lastWrite >= BREAKDOWN_THROTTLE_MS) {
1580
+ lastBdWrite.set(sessionID, now);
1581
+ pruneMap(lastBdWrite, MAX_TRACKED_SESSIONS);
1582
+ const lines = await buildLiveLines(
1583
+ client,
1584
+ sessionID,
1585
+ info.modelID,
1586
+ info.providerID,
1587
+ tokens.input,
1588
+ contextTokens,
1589
+ );
1590
+ writeStateFile(
1591
+ sessionID,
1592
+ info.modelID,
1593
+ info.providerID,
1594
+ tokens.input,
1595
+ contextTokens,
1596
+ reasoning,
1597
+ );
1598
+ writeLog(lines);
1599
+ }
1600
+ } catch (err) {
1601
+ logErr("live breakdown write failed", err);
1602
+ }
1603
+ },
1604
+ };
1605
+ }
1606
+
1607
+ function lastKnownModel(sessionID) {
1608
+ return lastKnownModelCache.get(sessionID) || "unknown";
1609
+ }
1610
+ function lastKnownProvider(sessionID) {
1611
+ return lastKnownProviderCache.get(sessionID) || "";
1612
+ }
1613
+ function lastKnownInput(sessionID) {
1614
+ return lastKnownInputCache.get(sessionID) || 0;
1615
+ }
1616
+ function lastKnownReasoning(sessionID) {
1617
+ return lastKnownReasoningCache.get(sessionID) || 0;
1618
+ }
1619
+ function lastKnownCtx(sessionID) {
1620
+ return lastKnownCtxCache.get(sessionID) || 0;
1621
+ }
1622
+
1623
+ // ---------------------------------------------------------------------------
1624
+ // V2 (OpenCode >= 2.0.16) — context hook + event stream, no toasts.
1625
+ // ---------------------------------------------------------------------------
1626
+
1627
+ // Estimate the per-category breakdown directly from a V2 SessionContext.
1628
+ // `context.messages` are @opencode/ai Message objects:
1629
+ // { role, content: [ {type:"text",text} | {type:"media"} |
1630
+ // {type:"tool-call",input} | {type:"tool-result"} |
1631
+ // {type:"reasoning",text} | ... ] }
1632
+ // `context.system` are SystemPart[] { type:"text", text }.
1633
+ // `context.tools` is Record<name, { description, input: JsonSchema }>.
1634
+ // As in V1, tool RESULTS are deliberately not counted (only call args), and
1635
+ // every returned *Chars value is a TOKEN estimate (unicode heuristic).
1636
+ function estimateBreakdownFromContext(context) {
1637
+ let userChars = 0;
1638
+ let assistantChars = 0;
1639
+ let reasoningChars = 0;
1640
+ let toolArgsChars = 0;
1641
+
1642
+ const messages = Array.isArray(context?.messages) ? context.messages : [];
1643
+ for (const msg of messages) {
1644
+ const parts = Array.isArray(msg?.content) ? msg.content : [];
1645
+ if (msg?.role === "user") {
1646
+ for (const p of parts) {
1647
+ if (p?.type === "text") userChars += estimateTokens(p.text);
1648
+ }
1649
+ } else if (msg?.role === "assistant") {
1650
+ for (const p of parts) {
1651
+ if (p?.type === "text") {
1652
+ assistantChars += estimateTokens(p.text);
1653
+ } else if (p?.type === "reasoning") {
1654
+ reasoningChars += estimateTokens(p.text);
1655
+ } else if (p?.type === "tool-call") {
1656
+ toolArgsChars += estJson(p.input);
1657
+ }
1658
+ }
1659
+ }
1660
+ }
1661
+
1662
+ let systemChars = 0;
1663
+ const system = Array.isArray(context?.system) ? context.system : [];
1664
+ for (const s of system) systemChars += estimateTokens(s?.text);
1665
+
1666
+ let toolSchemasChars = 0;
1667
+ const tools = context?.tools;
1668
+ if (tools && typeof tools === "object") {
1669
+ for (const t of Object.values(tools)) {
1670
+ toolSchemasChars += estimateTokens(t?.description) + estJson(t?.input);
1671
+ }
1672
+ }
1673
+
1674
+ return {
1675
+ userChars,
1676
+ assistantChars,
1677
+ reasoningChars,
1678
+ toolArgsChars,
1679
+ systemChars,
1680
+ toolSchemasChars,
1681
+ };
1682
+ }
1683
+
1684
+ // V2 context hook handler: capture the final messages / system prompt / tool
1685
+ // schemas + the request model. READ-ONLY — the context object is never mutated.
1686
+ function onV2Context(context) {
1687
+ try {
1688
+ if (!context || typeof context !== "object") return;
1689
+ const sid = context.sessionID;
1690
+ if (!sid) return;
1691
+
1692
+ const parts = estimateBreakdownFromContext(context);
1693
+ breakdownCache.set(sid, {
1694
+ ...parts,
1695
+ capturedAt: Date.now(),
1696
+ source: "context-hook",
1697
+ });
1698
+ pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
1699
+
1700
+ const model = context.model;
1701
+ if (model && typeof model === "object") {
1702
+ if (typeof model.id === "string") lastKnownModelCache.set(sid, model.id);
1703
+ if (typeof model.providerID === "string") {
1704
+ lastKnownProviderCache.set(sid, model.providerID);
1705
+ }
1706
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
1707
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
1708
+ }
1709
+ } catch (err) {
1710
+ logErr("v2 context hook failed", err);
1711
+ }
1712
+ }
1713
+
1714
+ // ---------------------------------------------------------------------------
1715
+ // V2 fallback: derive the message-based categories from the session's persisted
1716
+ // message list when the context hook did NOT populate the cache for this
1717
+ // session. This happens when the request was served by a DIFFERENT plugin
1718
+ // instance (the public event stream is mirrored to every instance, but the
1719
+ // breakdown cache is process-local) or when the hook did not fire at all.
1720
+ //
1721
+ // Only user / assistant / reasoning / tool-args can be derived from the
1722
+ // persisted SessionMessageInfo[]: the ASSEMBLED system prompt and the tool
1723
+ // schemas are exclusive to the context hook, so they stay null ("n/a" in the
1724
+ // sidebar) unless the hook ran. Tool RESULTS are deliberately not counted
1725
+ // (only call args), exactly like the hook / V1 estimator.
1726
+ function estimateBreakdownFromClientMessages(messages) {
1727
+ let userChars = 0;
1728
+ let assistantChars = 0;
1729
+ let reasoningChars = 0;
1730
+ let toolArgsChars = 0;
1731
+ for (const m of messages || []) {
1732
+ if (!m || typeof m !== "object") continue;
1733
+ if (m.type === "user") {
1734
+ userChars += estimateTokens(m.text);
1735
+ } else if (m.type === "assistant") {
1736
+ const parts = Array.isArray(m.content) ? m.content : [];
1737
+ for (const p of parts) {
1738
+ if (p?.type === "text") {
1739
+ assistantChars += estimateTokens(p.text);
1740
+ } else if (p?.type === "reasoning") {
1741
+ reasoningChars += estimateTokens(p.text);
1742
+ } else if (p?.type === "tool") {
1743
+ // state.input is a string while streaming; only count the resolved
1744
+ // object (running/completed/error) to avoid partial counts.
1745
+ const input = p?.state?.input;
1746
+ if (input && typeof input === "object") toolArgsChars += estJson(input);
1747
+ }
1748
+ }
1749
+ }
1750
+ }
1751
+ return {
1752
+ userChars,
1753
+ assistantChars,
1754
+ reasoningChars,
1755
+ toolArgsChars,
1756
+ systemChars: null,
1757
+ toolSchemasChars: null,
1758
+ };
1759
+ }
1760
+
1761
+ // sessionID -> last time we hit the client session.context() fallback.
1762
+ const v2ContextFetchTimes = new Map();
1763
+ const V2_CONTEXT_FETCH_THROTTLE_MS = 5000; // min gap between fallback fetches
1764
+ // Background fallback requests are fire-and-forget (see the callers in the
1765
+ // event handlers): cap each at 3s so a slow/hung client cannot pin resources.
1766
+ const V2_FALLBACK_TIMEOUT_MS = 3000;
1767
+
1768
+ // Fill breakdownCache for a session when the context hook has not already done
1769
+ // so. Throttled, fault-tolerant, READ-ONLY. Hook data always wins.
1770
+ async function ensureV2Breakdown(ctx, sessionID) {
1771
+ if (!sessionID) return;
1772
+ const cached = breakdownCache.get(sessionID);
1773
+ if (cached && cached.source === "context-hook") return; // hook data wins
1774
+ const now = Date.now();
1775
+ if (
1776
+ now - (v2ContextFetchTimes.get(sessionID) || 0) <
1777
+ V2_CONTEXT_FETCH_THROTTLE_MS
1778
+ ) {
1779
+ return;
1780
+ }
1781
+ v2ContextFetchTimes.set(sessionID, now);
1782
+ pruneMap(v2ContextFetchTimes, MAX_TRACKED_SESSIONS);
1783
+ try {
1784
+ const res = await withTimeout(
1785
+ ctx.session.context({ sessionID }),
1786
+ V2_FALLBACK_TIMEOUT_MS,
1787
+ "session.context",
1788
+ );
1789
+ const msgs = Array.isArray(res) ? res : res?.data;
1790
+ if (Array.isArray(msgs)) {
1791
+ // Re-check AFTER the await: the context hook may have populated the cache
1792
+ // while this fetch was in flight — hook data must win and must never be
1793
+ // overwritten by a late fallback.
1794
+ const latest = breakdownCache.get(sessionID);
1795
+ if (!latest || latest.source !== "context-hook") {
1796
+ breakdownCache.set(sessionID, {
1797
+ ...estimateBreakdownFromClientMessages(msgs),
1798
+ capturedAt: Date.now(),
1799
+ source: "client-context",
1800
+ });
1801
+ pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
1802
+ }
1803
+ }
1804
+ } catch (err) {
1805
+ logErr("v2 session.context fallback failed", err);
1806
+ }
1807
+ // Opportunistically resolve the model: Desktop selects it AFTER
1808
+ // session.created, so without this (or session.model.selected) a session can
1809
+ // stay "unknown" even though its limit is resolvable.
1810
+ if (
1811
+ !lastKnownModelCache.has(sessionID) ||
1812
+ !lastKnownProviderCache.has(sessionID)
1813
+ ) {
1814
+ try {
1815
+ const info = await withTimeout(
1816
+ ctx.session.get({ sessionID }),
1817
+ V2_FALLBACK_TIMEOUT_MS,
1818
+ "session.get",
1819
+ );
1820
+ const model = info?.model ?? info?.data?.model;
1821
+ if (model && typeof model === "object") {
1822
+ if (typeof model.id === "string") lastKnownModelCache.set(sessionID, model.id);
1823
+ if (typeof model.providerID === "string") {
1824
+ lastKnownProviderCache.set(sessionID, model.providerID);
1825
+ }
1826
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
1827
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
1828
+ }
1829
+ } catch (err) {
1830
+ logErr("v2 session.get model fallback failed", err);
1831
+ }
1832
+ }
1833
+ }
1834
+
1835
+ // V2 model limits: effective values from ctx.model.list(), cached + gated.
1836
+ // { data: ModelInfo[] } where ModelInfo = { modelID, providerID, limit:{context} }.
1837
+ async function ensureModelLimits(ctx) {
1838
+ const now = Date.now();
1839
+ if (now - v2LimitsLoadedAt < PROVIDERS_TTL_MS) return; // fresh success
1840
+ if (now - v2LimitsFailAt < PROVIDERS_FAIL_TTL_MS) return; // recent failure
1841
+ if (v2LimitsInFlight) return v2LimitsInFlight; // dedup parallel callers
1842
+ v2LimitsInFlight = (async () => {
1843
+ try {
1844
+ const res = await withTimeout(
1845
+ ctx.model.list(),
1846
+ V2_MODEL_FETCH_TIMEOUT_MS,
1847
+ "model.list",
1848
+ );
1849
+ const models = Array.isArray(res) ? res : res?.data;
1850
+ if (!Array.isArray(models)) {
1851
+ v2LimitsFailAt = Date.now(); // malformed payload -> retry soon
1852
+ return;
1853
+ }
1854
+ let found = 0;
1855
+ for (const m of models) {
1856
+ const modelID = m?.modelID ?? m?.id;
1857
+ const providerID =
1858
+ typeof m?.providerID === "string" && m.providerID
1859
+ ? m.providerID
1860
+ : typeof m?.provider?.id === "string"
1861
+ ? m.provider.id
1862
+ : "";
1863
+ const key = modelKey(providerID, modelID);
1864
+ const lim = m?.limit;
1865
+ if (!key || !lim || typeof lim !== "object") continue;
1866
+ const context =
1867
+ typeof lim.context === "number" && lim.context > 0 ? lim.context : null;
1868
+ const input =
1869
+ typeof lim.input === "number" && lim.input > 0 ? lim.input : null;
1870
+ const output =
1871
+ typeof lim.output === "number" && lim.output > 0 ? lim.output : null;
1872
+ if (context == null && input == null) continue;
1873
+ const prevRec = modelLimits.get(key);
1874
+ modelLimits.set(key, {
1875
+ context,
1876
+ input,
1877
+ output,
1878
+ reserved:
1879
+ typeof m?.compaction?.reserved === "number"
1880
+ ? m.compaction.reserved
1881
+ : (prevRec?.reserved ?? null),
1882
+ });
1883
+ found++;
1884
+ }
1885
+ // An empty / limit-less payload right after boot (registry not ready yet)
1886
+ // must NOT be cached as success, or limits would stay unknown for the
1887
+ // full TTL — treat it as a soft failure and retry after the fail gate.
1888
+ if (found > 0) v2LimitsLoadedAt = Date.now();
1889
+ else v2LimitsFailAt = Date.now();
1890
+ } catch (err) {
1891
+ v2LimitsFailAt = Date.now(); // failed attempt -> short retry gate
1892
+ logErr("model.list failed", err);
1893
+ } finally {
1894
+ v2LimitsInFlight = null;
1895
+ }
1896
+ })();
1897
+ return v2LimitsInFlight;
1898
+ }
1899
+
1900
+ // V2 subagent aggregation: children are discovered from session.created
1901
+ // (parentID), their totals accumulated from session.step.ended. No
1902
+ // session.children/messages API needed. Synchronous (in-memory only).
1903
+ function aggregateSubagentsV2(sessionID) {
1904
+ const result = { count: 0, input: 0, output: 0, reasoning: 0, perChild: [] };
1905
+ for (const [childID, parentID] of sessionParentCache) {
1906
+ if (parentID !== sessionID) continue;
1907
+ result.count++;
1908
+ const t = sessionTotals.get(childID);
1909
+ if (t) {
1910
+ result.input += t.input || 0;
1911
+ result.output += t.output || 0;
1912
+ result.reasoning += t.reasoning || 0;
1913
+ const lim = getModelLimit(t.providerID, t.modelID);
1914
+ const ctxTokens = typeof t.ctx === "number" ? t.ctx : 0;
1915
+ result.perChild.push({
1916
+ id: childID,
1917
+ input: ctxTokens,
1918
+ limit: lim,
1919
+ ratio: lim ? ctxTokens / lim : null,
1920
+ });
1921
+ } else {
1922
+ result.perChild.push({ id: childID, input: 0, limit: null, ratio: null });
1923
+ }
1924
+ }
1925
+ return result;
1926
+ }
1927
+
1928
+ // V2: assemble the live breakdown lines (no client, no fallback fetch —
1929
+ // the context hook fills breakdownCache on every request).
1930
+ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens, reasoningTokens) {
1931
+ const lines = [];
1932
+ const ts = new Date().toISOString();
1933
+ const limit = getModelLimit(providerID, modelID);
1934
+ const usableInfo = getUsableContext(providerID, modelID);
1935
+ const total = typeof ctxTokens === "number" && ctxTokens > 0 ? ctxTokens : inputTokens;
1936
+ const denom =
1937
+ usableInfo != null
1938
+ ? usableInfo.usable
1939
+ : limit != null && limit > 0
1940
+ ? limit
1941
+ : null;
1942
+ const pct =
1943
+ denom != null ? ` (${((total / denom) * 100).toFixed(1)}%)` : "";
1944
+
1945
+ lines.push(
1946
+ `[${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)}`,
1947
+ );
1948
+
1949
+ const bd = breakdownCache.get(sessionID);
1950
+ const source = bd?.source || "none";
1951
+ const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
1952
+
1953
+ const sumEstimates = (bd?.userChars || 0) +
1954
+ (bd?.assistantChars || 0) +
1955
+ (bd?.reasoningChars || 0) +
1956
+ (bd?.toolArgsChars || 0) +
1957
+ (bd?.systemChars || 0) +
1958
+ (bd?.toolSchemasChars || 0);
1959
+ const other = Math.max(0, inputTokens - sumEstimates);
1960
+
1961
+ if (bd) {
1962
+ lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
1963
+ lines.push(` assistant ${fmt(bd.assistantChars || 0)} (est tokens)`);
1964
+ const reasoningNote = rt > 0 ? ` (exact ${fmt(rt)})` : "";
1965
+ lines.push(` reasoning ${fmt(bd.reasoningChars || 0)} (est tokens)${reasoningNote}`);
1966
+ lines.push(` tool args ${fmt(bd.toolArgsChars || 0)} (est tokens, input only)`);
1967
+ lines.push(
1968
+ bd.systemChars != null
1969
+ ? ` system ${fmt(bd.systemChars)} (est tokens)`
1970
+ : ` system n/a (hook не сработал)`,
1971
+ );
1972
+ lines.push(
1973
+ bd.toolSchemasChars != null
1974
+ ? ` tool schemas ${fmt(bd.toolSchemasChars)} (est tokens)`
1975
+ : ` tool schemas n/a`,
1976
+ );
1977
+ } else {
1978
+ lines.push(` user n/a (no breakdown captured yet)`);
1979
+ lines.push(` assistant n/a`);
1980
+ lines.push(` reasoning n/a`);
1981
+ lines.push(` tool args n/a`);
1982
+ lines.push(` system n/a (hook не сработал)`);
1983
+ lines.push(` tool schemas n/a`);
1984
+ }
1985
+ lines.push(` other ${fmt(other)} (= input - sum of estimates)`);
1986
+ lines.push(` source ${source}`);
1987
+
1988
+ try {
1989
+ const sub = aggregateSubagentsV2(sessionID);
1990
+ if (sub.count > 0) {
1991
+ const ratios = (sub.perChild || [])
1992
+ .map((c) => c?.ratio)
1993
+ .filter((r) => typeof r === "number");
1994
+ const worst =
1995
+ ratios.length > 0
1996
+ ? ` worst=${(Math.max(...ratios) * 100).toFixed(0)}%`
1997
+ : "";
1998
+ lines.push(
1999
+ ` subagents ${sub.count} session(s): in=${fmt(sub.input)} out=${fmt(sub.output)} r=${fmt(sub.reasoning)}${worst}`,
2000
+ );
2001
+ } else {
2002
+ lines.push(` subagents 0`);
2003
+ }
2004
+ } catch (err) {
2005
+ logErr("v2 subagent aggregation failed", err);
2006
+ lines.push(` subagents n/a`);
2007
+ }
2008
+
2009
+ return lines;
2010
+ }
2011
+
2012
+ // Append a note to the persisted final-summaries section (claimed once across
2013
+ // instances). Never throws.
2014
+ function pushFinalSummary(claimKey, summaryLines) {
2015
+ try {
2016
+ if (!claimOnce(claimKey, DEDUP_DIR)) return;
2017
+ finalSummaries.push(summaryLines.join("\n"));
2018
+ if (finalSummaries.length > MAX_FINAL_SUMMARIES) {
2019
+ finalSummaries.splice(0, finalSummaries.length - MAX_FINAL_SUMMARIES);
2020
+ }
2021
+ writeLog([]); // persist final summaries
2022
+ } catch (err) {
2023
+ logErr("pushFinalSummary failed", err);
2024
+ }
2025
+ }
2026
+
2027
+ // V2 event handler. `event` is a V2Event ({ type, data, ... }).
2028
+ async function handleV2Event(ctx, event) {
2029
+ if (!event || typeof event.type !== "string") return;
2030
+
2031
+ // DEBUG tap (fault-tolerant, off via flag).
2032
+ debugEvent(event);
2033
+
2034
+ // --- session lifecycle: parent links + model hints ---
2035
+ if (event.type === "session.created") {
2036
+ const data = event.data || {};
2037
+ const sid = data.sessionID;
2038
+ if (!sid) return;
2039
+ sessionParentCache.set(sid, data.parentID || null);
2040
+ pruneMap(sessionParentCache, MAX_TRACKED_SESSIONS);
2041
+ if (data.model && typeof data.model === "object") {
2042
+ if (typeof data.model.id === "string") lastKnownModelCache.set(sid, data.model.id);
2043
+ if (typeof data.model.providerID === "string") {
2044
+ lastKnownProviderCache.set(sid, data.model.providerID);
2045
+ }
2046
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
2047
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
2048
+ }
2049
+ return;
2050
+ }
2051
+
2052
+ // Model chosen after session creation (the normal Desktop flow: the user
2053
+ // picks the model in the UI). Without this the session stays "unknown"
2054
+ // because session.created carries no model and session.step.ended has no
2055
+ // model field.
2056
+ if (event.type === "session.model.selected") {
2057
+ const d = event.data || {};
2058
+ const sid = d.sessionID;
2059
+ if (!sid) return;
2060
+ if (d.model && typeof d.model === "object") {
2061
+ if (typeof d.model.id === "string") lastKnownModelCache.set(sid, d.model.id);
2062
+ if (typeof d.model.providerID === "string") {
2063
+ lastKnownProviderCache.set(sid, d.model.providerID);
2064
+ }
2065
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
2066
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
2067
+ }
2068
+ return;
2069
+ }
2070
+
2071
+ if (event.type === "session.deleted") {
2072
+ const sid = event.data?.sessionID;
2073
+ if (!sid) return;
2074
+ clearSessionTracking(sid);
2075
+ return;
2076
+ }
2077
+
2078
+ if (event.type === "session.compacted") {
2079
+ const sid = event.data?.sessionID;
2080
+ if (!sid) return;
2081
+ try {
2082
+ const lines = buildLiveLinesV2(
2083
+ sid,
2084
+ lastKnownModel(sid),
2085
+ lastKnownProvider(sid),
2086
+ lastKnownInput(sid),
2087
+ lastKnownCtx(sid),
2088
+ lastKnownReasoning(sid),
2089
+ );
2090
+ writeStateFile(
2091
+ sid,
2092
+ lastKnownModel(sid),
2093
+ lastKnownProvider(sid),
2094
+ lastKnownInput(sid),
2095
+ lastKnownCtx(sid),
2096
+ lastKnownReasoning(sid),
2097
+ );
2098
+ pushFinalSummary(
2099
+ `compacted:${event.id ?? sid}`,
2100
+ [
2101
+ `[${new Date().toISOString()}] COMPACTED session=${sid} (auto — возможно, контекст был переполнен)`,
2102
+ ...lines.slice(1),
2103
+ ],
2104
+ );
2105
+ } catch (err) {
2106
+ logErr("v2 session.compacted handling failed", err);
2107
+ }
2108
+ clearSessionTracking(sid, true);
2109
+ return;
2110
+ }
2111
+
2112
+ if (event.type === "session.idle") {
2113
+ const sid = event.data?.sessionID;
2114
+ if (!sid) return;
2115
+ try {
2116
+ // Fire-and-forget: the client-API fallback must NOT block this
2117
+ // instance's event loop (it would delay every other session's events).
2118
+ // The result is picked up by a later state write; the 5s throttle bounds
2119
+ // the cost. Never rejects.
2120
+ void ensureV2Breakdown(ctx, sid).catch(() => {});
2121
+ const lines = buildLiveLinesV2(
2122
+ sid,
2123
+ lastKnownModel(sid),
2124
+ lastKnownProvider(sid),
2125
+ lastKnownInput(sid),
2126
+ lastKnownCtx(sid),
2127
+ lastKnownReasoning(sid),
2128
+ );
2129
+ writeStateFile(
2130
+ sid,
2131
+ lastKnownModel(sid),
2132
+ lastKnownProvider(sid),
2133
+ lastKnownInput(sid),
2134
+ lastKnownCtx(sid),
2135
+ lastKnownReasoning(sid),
2136
+ );
2137
+ pushFinalSummary(`final:${event.id ?? sid}`, [
2138
+ `[${new Date().toISOString()}] FINAL session=${sid}`,
2139
+ ...lines.slice(1),
2140
+ ]);
2141
+ } catch (err) {
2142
+ logErr("v2 final summary on idle failed", err);
2143
+ }
2144
+ clearSessionTracking(sid);
2145
+ return;
2146
+ }
2147
+
2148
+ // --- finished LLM step: tokens + live breakdown snapshot ---
2149
+ if (event.type === "session.step.ended") {
2150
+ const data = event.data || {};
2151
+ const sid = data.sessionID;
2152
+ if (!sid) return;
2153
+ const t = data.tokens || {};
2154
+ const input = typeof t.input === "number" ? t.input : 0;
2155
+ const output = typeof t.output === "number" ? t.output : 0;
2156
+ const reasoning = typeof t.reasoning === "number" ? t.reasoning : 0;
2157
+ const contextTokens = tokenCountOf(t);
2158
+
2159
+ lastKnownInputCache.set(sid, input);
2160
+ lastKnownCtxCache.set(sid, contextTokens);
2161
+ lastKnownReasoningCache.set(sid, reasoning);
2162
+ if (!lastKnownModelCache.has(sid) && data.model) {
2163
+ // Some builds put the model on the step payload; harmless when absent.
2164
+ if (typeof data.model.id === "string") lastKnownModelCache.set(sid, data.model.id);
2165
+ if (typeof data.model.providerID === "string") {
2166
+ lastKnownProviderCache.set(sid, data.model.providerID);
2167
+ }
2168
+ }
2169
+ pruneMap(lastKnownInputCache, MAX_TRACKED_SESSIONS);
2170
+ pruneMap(lastKnownCtxCache, MAX_TRACKED_SESSIONS);
2171
+ pruneMap(lastKnownReasoningCache, MAX_TRACKED_SESSIONS);
2172
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
2173
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
2174
+
2175
+ // Accumulate per-session totals for parent subagent roll-up.
2176
+ const tot = sessionTotals.get(sid) || {
2177
+ input: 0,
2178
+ output: 0,
2179
+ reasoning: 0,
2180
+ ctx: 0,
2181
+ modelID: null,
2182
+ providerID: "",
2183
+ at: 0,
2184
+ };
2185
+ tot.input += input;
2186
+ tot.output += output;
2187
+ tot.reasoning += reasoning;
2188
+ tot.ctx = contextTokens;
2189
+ tot.modelID = lastKnownModel(sid);
2190
+ tot.providerID = lastKnownProvider(sid);
2191
+ tot.at = Date.now();
2192
+ sessionTotals.set(sid, tot);
2193
+ pruneMap(sessionTotals, MAX_TRACKED_SESSIONS);
2194
+
2195
+ // Ensure limits are available (fault-tolerant; await is cheap after warm).
2196
+ try {
2197
+ await ensureModelLimits(ctx);
2198
+ } catch {
2199
+ // ignore — absolute token figures only
2200
+ }
2201
+
2202
+ // Make sure a breakdown exists for this session even if the context hook
2203
+ // did not fire here (cross-instance mirror / hook gap). Fire-and-forget on
2204
+ // purpose: awaiting it would serialize this instance's event handling
2205
+ // behind up to the full client timeout. The cached result is written on the
2206
+ // NEXT event; the 5s throttle bounds the cost. Never rejects.
2207
+ void ensureV2Breakdown(ctx, sid).catch(() => {});
2208
+
2209
+ const now = Date.now();
2210
+ const lastWrite = lastBdWrite.get(sid) || 0;
2211
+ if (now - lastWrite >= BREAKDOWN_THROTTLE_MS) {
2212
+ lastBdWrite.set(sid, now);
2213
+ pruneMap(lastBdWrite, MAX_TRACKED_SESSIONS);
2214
+ try {
2215
+ const lines = buildLiveLinesV2(
2216
+ sid,
2217
+ lastKnownModel(sid),
2218
+ lastKnownProvider(sid),
2219
+ input,
2220
+ contextTokens,
2221
+ reasoning,
2222
+ );
2223
+ writeStateFile(
2224
+ sid,
2225
+ lastKnownModel(sid),
2226
+ lastKnownProvider(sid),
2227
+ input,
2228
+ contextTokens,
2229
+ reasoning,
2230
+ );
2231
+ writeLog(lines);
2232
+ } catch (err) {
2233
+ logErr("v2 live breakdown write failed", err);
2234
+ }
2235
+ }
2236
+ return;
2237
+ }
2238
+
2239
+ // --- failed step / execution: persist a claimed one-line note ---
2240
+ if (
2241
+ event.type === "session.step.failed" ||
2242
+ event.type === "session.execution.failed" ||
2243
+ event.type === "session.execution.interrupted"
2244
+ ) {
2245
+ const data = event.data || {};
2246
+ const sid = data.sessionID || "?";
2247
+ const interrupted = event.type === "session.execution.interrupted";
2248
+ const err = data.error || {};
2249
+ const name = interrupted
2250
+ ? "Interrupted"
2251
+ : typeof err.type === "string"
2252
+ ? err.type
2253
+ : "session error";
2254
+ const detail = String(err.message || data.reason || "").slice(0, 200);
2255
+ const bucket = Math.floor(Date.now() / ERROR_TOAST_DEDUP_MS);
2256
+ pushFinalSummary(`err:${sid}:${name}:${bucket}`, [
2257
+ `[${new Date().toISOString()}] ERROR session=${sid} ${name}${detail ? `: ${detail}` : ""}`,
2258
+ ]);
2259
+ }
2260
+ }
2261
+
2262
+ // ---------------------------------------------------------------------------
2263
+ // Entrypoints
2264
+ // ---------------------------------------------------------------------------
2265
+
2266
+ export default {
2267
+ // V2 definition (equivalent to Plugin.define({ id, setup }) — `define` is
2268
+ // an identity helper, so it is inlined to avoid a package dependency).
2269
+ id: "context-indicator",
2270
+ async setup(ctx) {
2271
+ const registrations = [];
2272
+ try {
2273
+ registrations.push(
2274
+ await ctx.session.hook("context", (context) => onV2Context(context)),
2275
+ );
2276
+ } catch (err) {
2277
+ logErr("v2 context hook registration failed", err);
2278
+ }
2279
+
2280
+ // Effective per-model context limits, captured synchronously from the
2281
+ // model registry whenever it is (re)built. This is the authoritative V2
2282
+ // source and is available immediately (no async race right after boot).
2283
+ // Read-only: the editor is never mutated.
2284
+ try {
2285
+ registrations.push(
2286
+ await ctx.model.transform((editor) => {
2287
+ try {
2288
+ const list = typeof editor?.list === "function" ? editor.list() : [];
2289
+ for (const m of list || []) {
2290
+ const modelID = m?.modelID ?? m?.id;
2291
+ const providerID =
2292
+ typeof m?.providerID === "string" && m.providerID
2293
+ ? m.providerID
2294
+ : typeof m?.provider?.id === "string"
2295
+ ? m.provider.id
2296
+ : "";
2297
+ const key = modelKey(providerID, modelID);
2298
+ const lim = m?.limit;
2299
+ if (!key || !lim || typeof lim !== "object") continue;
2300
+ const context =
2301
+ typeof lim.context === "number" && lim.context > 0 ? lim.context : null;
2302
+ const input =
2303
+ typeof lim.input === "number" && lim.input > 0 ? lim.input : null;
2304
+ const output =
2305
+ typeof lim.output === "number" && lim.output > 0 ? lim.output : null;
2306
+ if (context == null && input == null) continue;
2307
+ const prevRec = modelLimits.get(key);
2308
+ modelLimits.set(key, {
2309
+ context,
2310
+ input,
2311
+ output,
2312
+ reserved:
2313
+ typeof m?.compaction?.reserved === "number"
2314
+ ? m.compaction.reserved
2315
+ : (prevRec?.reserved ?? null),
2316
+ });
2317
+ }
2318
+ } catch (err) {
2319
+ logErr("v2 model transform limits failed", err);
2320
+ }
2321
+ }),
2322
+ );
2323
+ } catch (err) {
2324
+ logErr("v2 model transform registration failed", err);
2325
+ }
2326
+
2327
+ // Fire-and-forget prefetch of effective model limits.
2328
+ ensureModelLimits(ctx).catch((err) =>
2329
+ logErr("model limits prefetch failed", err),
2330
+ );
2331
+
2332
+ const controller = new AbortController();
2333
+ void (async () => {
2334
+ try {
2335
+ for await (const event of ctx.event.subscribe({
2336
+ signal: controller.signal,
2337
+ })) {
2338
+ try {
2339
+ await handleV2Event(ctx, event);
2340
+ } catch (err) {
2341
+ logErr("v2 event handler failed", err);
2342
+ }
2343
+ }
2344
+ } catch (err) {
2345
+ if (!controller.signal.aborted) {
2346
+ logErr("v2 event stream failed", err);
2347
+ }
2348
+ }
2349
+ })();
2350
+
2351
+ return () => {
2352
+ controller.abort();
2353
+ for (const r of registrations) {
2354
+ try {
2355
+ r?.dispose?.();
2356
+ } catch {
2357
+ /* ignore */
2358
+ }
2359
+ }
2360
+ };
2361
+ },
2362
+
2363
+ // V1 entrypoint retained for OpenCode >= 1.18.29.
2364
+ async server({ client }) {
2365
+ return makeV1(client);
2366
+ },
2367
+ };