opencode-context-indicator 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/state.js ADDED
@@ -0,0 +1,595 @@
1
+ // lib/state.js
2
+ //
3
+ // Filesystem layer for the context-indicator plugin: the log / state / event /
4
+ // dedup paths, the best-effort state lock, the atomic temp+rename writers, the
5
+ // TUI state bridge (writeStateFile), the state.json reader, and the claimed
6
+ // final-summary log. It reads the in-memory caches from ./cache.js and the model
7
+ // limits from ./limits.js (a call-time-only import cycle). Extracted 1:1 (code
8
+ // + WHY comments) from index.js.
9
+
10
+ import {
11
+ appendFileSync,
12
+ existsSync,
13
+ lstatSync,
14
+ readFileSync,
15
+ renameSync,
16
+ statSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { randomBytes } from "node:crypto";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { claimOnce } from "./dedup.js";
24
+ import {
25
+ finitePos,
26
+ isSafeSessionKey,
27
+ oneLine,
28
+ safeCount,
29
+ safeCountOrNull,
30
+ } from "./estimate.js";
31
+ import {
32
+ breakdownCache,
33
+ MAX_TRACKED_SESSIONS,
34
+ sessionAgentCache,
35
+ sessionParentCache,
36
+ } from "./cache.js";
37
+ import { getModelLimit, getUsableContext } from "./limits.js";
38
+
39
+ const MAX_FINAL_SUMMARIES = 50; // bound the in-memory final-summary log
40
+
41
+ // --- DEBUG instrumentation (behaviour probing on Desktop) ---
42
+ // When true, every incoming event.type is appended to %TEMP%\context-events.log
43
+ // (full JSON payload for session.error, sessionID for session.compacted).
44
+ // The file is truncated once it exceeds ~1MB. Flip to false to disable.
45
+ const DEBUG_EVENTS = false;
46
+ const EVENT_LOG_MAX_BYTES = 1024 * 1024; // ~1MB truncate threshold
47
+
48
+ const LOG_FILE = join(tmpdir(), "context-breakdown.log");
49
+
50
+ // Machine-readable snapshot consumed by the optional TUI sidebar (./tui.tsx,
51
+ // package entry "./tui"). Written next to each log update — same moment, same
52
+ // data. See writeStateFile() below.
53
+ // State file path — overridable via env for test isolation.
54
+ // In production the env is never set, so behaviour is unchanged.
55
+ // In tests: export OPENCODE_CONTEXT_INDICATOR_STATE_FILE=/tmp/test-state.json
56
+ // and the harness writes a controlled seed; no live file is touched.
57
+ const STATE_FILE =
58
+ process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
59
+ join(tmpdir(), "opencode-context-indicator-state.json");
60
+
61
+ // Separate tap log for raw events (written only when DEBUG_EVENTS is true).
62
+ const EVENT_LOG_FILE = join(tmpdir(), "context-events.log");
63
+
64
+ // Cross-instance dedup markers for the context-indicator (see ./lib/dedup.js).
65
+ // Used for final summaries / compacted / error notes: the live snapshot itself
66
+ // is an idempotent rewrite and needs no claim.
67
+ const DEDUP_DIR = join(tmpdir(), "context-indicator.dedup");
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // State-file locking (best-effort) + atomic rename.
71
+ // ---------------------------------------------------------------------------
72
+ // The state file is a SHARED read-merge-write target: Desktop, the TUI sidebar
73
+ // and every loaded plugin instance may write it concurrently. A temp+rename is
74
+ // atomic, but it does NOT prevent lost updates between the read and the write.
75
+ // A short-lived exclusive lock file (`<STATE_FILE>.lock`) serialises the
76
+ // read-merge-write critical section. This is best-effort by design: if the lock
77
+ // cannot be taken, the write is SKIPPED (the caller keeps its value in memory and
78
+ // it lands on the next cycle) rather than risking a torn merge.
79
+ //
80
+ // Lock ownership: each acquisition writes a random token INTO the lock file and
81
+ // only releases while the file still holds that token, so a late release can
82
+ // never unlink a lock another instance has since taken. Reclaim of an abandoned
83
+ // lock uses an atomic rename, not unlink, so two waiters racing on a stale lock
84
+ // cannot delete each other's freshly re-created lock.
85
+ //
86
+ // Retry budget is deliberately small: this blocks the event loop synchronously
87
+ // (Atomics.wait), contention is rare, and 6 x 20 ms caps the worst-case stall at
88
+ // ~120 ms — a reasonable trade-off between surviving a transient holder and not
89
+ // freezing a plugin tick.
90
+ const STATE_LOCK_FILE = `${STATE_FILE}.lock`;
91
+ const LOCK_MAX_ATTEMPTS = 6;
92
+ const LOCK_RETRY_MS = 20;
93
+ const LOCK_STALE_MS = 5000; // a lock older than this is presumed abandoned
94
+
95
+ // Bounded list of final summaries written on session.idle
96
+ const finalSummaries = [];
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Logging helpers (all fault-tolerant; never throw into the caller).
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function logErr(msg, err) {
103
+ try {
104
+ // Bound/sanitise the message (untrusted values are interpolated into it);
105
+ // keep structured objects untouched so their diagnostics survive.
106
+ const detail =
107
+ err === undefined || err === null
108
+ ? ""
109
+ : err instanceof Error
110
+ ? oneLine(err.message)
111
+ : typeof err === "string"
112
+ ? oneLine(err)
113
+ : err;
114
+ console.error(`[context-indicator] ${oneLine(msg)}:`, detail);
115
+ } catch {
116
+ /* ignore */
117
+ }
118
+ }
119
+
120
+ // Build the full file content: header + live snapshot + final summaries.
121
+ function renderFile(liveLines) {
122
+ const lines = [];
123
+ lines.push(
124
+ "# context-breakdown.log",
125
+ `# generated ${new Date().toISOString()} by context-indicator plugin`,
126
+ "# estimates: unicode heuristic (cyrillic 2.5, CJK 1.5, other 4 chars/token) — estimate, not a tokenizer",
127
+ "# live snapshot is rewritten; final summaries are preserved (bounded)",
128
+ "",
129
+ "## live snapshot",
130
+ ...liveLines,
131
+ "",
132
+ "## final summaries",
133
+ );
134
+ if (finalSummaries.length === 0) {
135
+ lines.push("(none yet)");
136
+ } else {
137
+ for (const s of finalSummaries) lines.push(s);
138
+ }
139
+ return lines.join("\n") + "\n";
140
+ }
141
+
142
+ // Atomically rewrite the whole log file (bounded by design). The temp+rename
143
+ // makes concurrent writers from multiple plugin instances safe: a reader sees
144
+ // either the previous or the new file, never a half-written one.
145
+ function writeLog(liveLines) {
146
+ try {
147
+ const content = renderFile(liveLines);
148
+ // Random temp suffix + exclusive create ("wx"): a predictable name could be
149
+ // pre-created as a symlink for us to follow (CWE-377). "wx" fails instead.
150
+ const tmp = `${LOG_FILE}.${randomBytes(6).toString("hex")}.tmp`;
151
+ // Create BEFORE the finally-guarded section (mirrors writeStateFile): on an
152
+ // (astronomically unlikely) EEXIST collision with a peer's temp, THEIR file
153
+ // must not be cleaned up by us — the write fails and is logged instead.
154
+ writeFileSync(tmp, content, { encoding: "utf8", flag: "wx" });
155
+ try {
156
+ renameWithRetry(tmp, LOG_FILE);
157
+ } finally {
158
+ // Same hygiene as writeStateFile: a failed rename must not litter %TEMP%
159
+ // with stale `<hex>.tmp` files.
160
+ try {
161
+ if (existsSync(tmp)) unlinkSync(tmp);
162
+ } catch {
163
+ /* ignore */
164
+ }
165
+ }
166
+ } catch (err) {
167
+ logErr("writeLog failed", err);
168
+ }
169
+ }
170
+
171
+ // Append a single line to the file (used for final summaries / notes).
172
+ function appendLogLine(line) {
173
+ try {
174
+ ensureRegularFile(LOG_FILE); // refuse to follow a symlink planted at the path
175
+ appendFileSync(LOG_FILE, line + "\n", "utf8");
176
+ } catch (err) {
177
+ logErr("appendLogLine failed", err);
178
+ }
179
+ }
180
+
181
+ // Synchronous sleep for the lock retry loop. Atomics.wait is the documented
182
+ // non-busy way to block this thread; fall back to a bounded spin if unavailable.
183
+ function sleepSync(ms) {
184
+ try {
185
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
186
+ } catch {
187
+ const end = Date.now() + ms;
188
+ while (Date.now() < end) {
189
+ /* bounded spin fallback */
190
+ }
191
+ }
192
+ }
193
+
194
+ // Best-effort: if `path` exists but is NOT a regular file (symlink / FIFO /
195
+ // device), remove it so the following create writes a real file instead of
196
+ // following the link (CWE-377 / symlink redirection). Never throws.
197
+ //
198
+ // Residual TOCTOU (lstat here vs. the caller's append/create): accepted. This is
199
+ // a defence-in-depth guard, not a sandbox — a local attacker able to write into
200
+ // %TEMP% already holds broader powers (they can swap any path this process
201
+ // touches). Closing the race would need O_NOFOLLOW-style semantics that the
202
+ // callers' appendFileSync/writeFileSync API cannot express, for no real gain.
203
+ function ensureRegularFile(path) {
204
+ try {
205
+ const st = lstatSync(path);
206
+ if (!st.isFile()) unlinkSync(path);
207
+ } catch {
208
+ /* missing -> the following create makes a fresh regular file */
209
+ }
210
+ }
211
+
212
+ // True when the lock file exists and looks abandoned (older than LOCK_STALE_MS).
213
+ // Callers wrap it in try/catch: ENOENT (lock vanished) lands there and retries.
214
+ function isStaleLock() {
215
+ return Date.now() - statSync(STATE_LOCK_FILE).mtimeMs > LOCK_STALE_MS;
216
+ }
217
+
218
+ // Returns `{ ok: true, token }` when the lock was taken (caller MUST release with
219
+ // that token), `{ ok: false, token: null }` when it is busy (skip the write), or
220
+ // `null` on an unexpected error — fail-open: the caller writes WITHOUT the lock
221
+ // and must NOT release (nothing was acquired).
222
+ function acquireStateLock() {
223
+ const token = randomBytes(8).toString("hex"); // 16 hex chars, per acquisition
224
+ for (let i = 0; i < LOCK_MAX_ATTEMPTS; i++) {
225
+ try {
226
+ // "wx" = exclusive create; fails EEXIST if held, and creates+writes the
227
+ // token in ONE atomic step (no empty-file-then-write gap to race).
228
+ writeFileSync(STATE_LOCK_FILE, token, { flag: "wx" });
229
+ return { ok: true, token };
230
+ } catch (err) {
231
+ if (err && err.code === "EEXIST") {
232
+ // Held by someone. Reclaim it if it looks abandoned (holder crashed);
233
+ // otherwise wait a little and retry.
234
+ try {
235
+ // Stale check TWICE: a peer may reclaim the stale lock and yet another
236
+ // process re-create a fresh one between our first stat and the rename
237
+ // — renaming then steals a FRESH lock (two holders, torn merge). Two
238
+ // consecutive checks narrow the window to the stat->rename gap
239
+ // (microseconds); the lock stays best-effort: the worst case is a
240
+ // stale single-session entry, self-healed on the next write.
241
+ if (isStaleLock() && isStaleLock()) {
242
+ // Atomic reclaim: only ONE waiter can rename the stale file away;
243
+ // the loser gets ENOENT and keeps waiting — neither can unlink a
244
+ // lock a peer just re-created.
245
+ const stale = `${STATE_LOCK_FILE}.stale.${randomBytes(4).toString("hex")}`;
246
+ renameSync(STATE_LOCK_FILE, stale);
247
+ unlinkSync(stale);
248
+ continue; // retry immediately to claim the now-free lock
249
+ }
250
+ } catch {
251
+ /* vanished / already reclaimed by a peer -> just retry */
252
+ }
253
+ sleepSync(LOCK_RETRY_MS);
254
+ } else {
255
+ // Unexpected error (permissions, tmpdir issue): do not block forever —
256
+ // fail-open so the state file still gets written.
257
+ logErr("state lock acquire error (proceeding unlocked)", err);
258
+ return null;
259
+ }
260
+ }
261
+ }
262
+ return { ok: false, token: null }; // busy: could not acquire in the budget
263
+ }
264
+
265
+ // Ownership-safe release: unlink ONLY while the lock still holds OUR token. A
266
+ // peer that reclaimed an abandoned lock (or a later acquisition) is left alone.
267
+ function releaseStateLock(token) {
268
+ try {
269
+ if (readFileSync(STATE_LOCK_FILE, "utf8") === token) {
270
+ unlinkSync(STATE_LOCK_FILE);
271
+ }
272
+ } catch {
273
+ /* already gone / never created -> nothing to release */
274
+ }
275
+ }
276
+
277
+ // Rename with a couple of retries: on Windows renameSync can throw EPERM/EBUSY
278
+ // while a reader (the TUI polls every second) or an AV scanner holds the
279
+ // destination open. A short backoff lets the handle close.
280
+ function renameWithRetry(from, to, attempts = 3, delayMs = 50) {
281
+ for (let i = 0; i < attempts; i++) {
282
+ try {
283
+ renameSync(from, to);
284
+ return true;
285
+ } catch (err) {
286
+ if (i === attempts - 1) {
287
+ logErr(`rename failed after ${attempts} attempts (${to})`, err);
288
+ return false;
289
+ }
290
+ sleepSync(delayMs);
291
+ }
292
+ }
293
+ return false;
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // TUI state bridge (opencode-context-indicator)
298
+ // ---------------------------------------------------------------------------
299
+ // Persist a tiny machine-readable snapshot of the CURRENT breakdown for one
300
+ // session so the optional TUI sidebar (./tui.tsx, package entry "./tui") can
301
+ // render it without talking to the server. The file is one JSON object keyed by
302
+ // sessionID, rewritten atomically (temp + rename) at every log write — same
303
+ // moment, same data. Strictly additive: any failure is swallowed and must never
304
+ // affect the log / toast / event paths.
305
+ function writeStateFile(
306
+ sessionID,
307
+ modelID,
308
+ providerID,
309
+ inputTokens,
310
+ ctxTokens,
311
+ reasoningTokens,
312
+ ) {
313
+ try {
314
+ if (!sessionID) return;
315
+ // A poisoned/odd sessionID ("__proto__", "constructor", …) must never be
316
+ // used as an object key: plain assignment through it would mutate the
317
+ // prototype instead of adding an entry (CWE-1321).
318
+ if (!isSafeSessionKey(sessionID)) {
319
+ logErr(`writeStateFile skipped unsafe session key ${oneLine(sessionID)}`);
320
+ return;
321
+ }
322
+ const lock = acquireStateLock();
323
+ // {ok:false} => busy: skip (value stays in memory, lands next cycle), as
324
+ // before. null => unexpected error: fail-open, write WITHOUT the lock and do
325
+ // NOT release (nothing was acquired — releasing could nuke a peer's lock).
326
+ if (lock && !lock.ok) {
327
+ logErr(
328
+ `writeStateFile skipped (state lock busy) session=${oneLine(sessionID)}`,
329
+ );
330
+ return;
331
+ }
332
+ try {
333
+ const bd = breakdownCache.get(sessionID) || null;
334
+ const limit = getModelLimit(providerID, modelID);
335
+ const usableInfo = getUsableContext(providerID, modelID);
336
+ const denom =
337
+ usableInfo != null
338
+ ? usableInfo.usable
339
+ : limit != null && limit > 0
340
+ ? limit
341
+ : null;
342
+ const total =
343
+ typeof ctxTokens === "number" && ctxTokens > 0
344
+ ? ctxTokens
345
+ : inputTokens || 0;
346
+
347
+ // Read the existing file FIRST: when THIS instance has no fresh breakdown
348
+ // (the context hook did not fire here — e.g. the request was served by
349
+ // another plugin instance whose event stream is mirrored to us), keep the
350
+ // values already persisted instead of clobbering them with nulls/zeros.
351
+ let rawAll = {};
352
+ try {
353
+ rawAll = JSON.parse(readFileSync(STATE_FILE, "utf8"));
354
+ } catch {
355
+ rawAll = {}; // missing / empty / corrupt -> start fresh
356
+ }
357
+ if (!rawAll || typeof rawAll !== "object" || Array.isArray(rawAll)) {
358
+ rawAll = {};
359
+ }
360
+ // Drop prototype-pollution keys from the shared file so a poisoned
361
+ // snapshot is neither consulted via the prototype chain nor re-persisted.
362
+ let all = {};
363
+ for (const [k, v] of Object.entries(rawAll)) {
364
+ if (isSafeSessionKey(k)) all[k] = v;
365
+ }
366
+ const prevEntry = Object.prototype.hasOwnProperty.call(all, sessionID)
367
+ ? all[sessionID]
368
+ : null;
369
+ const pc = prevEntry?.categories || null;
370
+
371
+ // Same model AND provider? Only then is a previously persisted denominator
372
+ // meaningful — a session that switched model (or provider, even with the
373
+ // same modelID) must not inherit the old window.
374
+ const sameModelAsPrev =
375
+ prevEntry != null &&
376
+ prevEntry.model === (modelID || "unknown") &&
377
+ (prevEntry.providerID || "") === (providerID || "");
378
+
379
+ // Coerce every persisted category through the safe numeric helpers: the
380
+ // snapshot is untrusted input and must never inject non-numbers.
381
+ const catUser = bd ? safeCount(bd.userChars) : safeCount(pc?.user);
382
+ const catAssistant = bd
383
+ ? safeCount(bd.assistantChars)
384
+ : safeCount(pc?.assistant);
385
+ const catReasoning = bd
386
+ ? safeCount(bd.reasoningChars)
387
+ : safeCount(pc?.reasoning);
388
+ const catToolArgs = bd
389
+ ? safeCount(bd.toolArgsChars)
390
+ : safeCount(pc?.toolArgs);
391
+ // system / tool schemas are hook-only; a fallback breakdown has them null
392
+ // and must not erase a value the hook already persisted.
393
+ const catSystem =
394
+ bd && bd.systemChars != null
395
+ ? safeCount(bd.systemChars)
396
+ : safeCountOrNull(pc?.system);
397
+ const catToolSchemas =
398
+ bd && bd.toolSchemasChars != null
399
+ ? safeCount(bd.toolSchemasChars)
400
+ : safeCountOrNull(pc?.toolSchemas);
401
+
402
+ const sumEstimates =
403
+ catUser +
404
+ catAssistant +
405
+ catReasoning +
406
+ catToolArgs +
407
+ (catSystem || 0) +
408
+ (catToolSchemas || 0);
409
+ const other = Math.max(0, (inputTokens || 0) - sumEstimates);
410
+ // Parent link for the /context-breakdown command. Prefer the live cache;
411
+ // fall back to the previously persisted value (backward compatible with
412
+ // snapshots written before parentID existed -> null). role mirrors it.
413
+ const parentID =
414
+ sessionParentCache.has(sessionID)
415
+ ? sessionParentCache.get(sessionID) ?? null
416
+ : prevEntry?.parentID ?? null;
417
+ const entry = {
418
+ sessionID,
419
+ parentID,
420
+ role: parentID ? "sub" : "main",
421
+ agent: sessionAgentCache.get(sessionID) || prevEntry?.agent || null,
422
+ model: modelID || "unknown",
423
+ providerID: providerID || "",
424
+ ctx: total,
425
+ input: inputTokens || 0,
426
+ // Do NOT clobber a known denominator with null: multiple plugin instances
427
+ // share this file and one of them may lack modelLimits (its ctx.model.list
428
+ // returned nothing). Keep the previous model's known value when the live
429
+ // lookup came back empty, so a transient miss never becomes permanent.
430
+ usable:
431
+ denom != null
432
+ ? denom
433
+ : sameModelAsPrev && finitePos(prevEntry.usable)
434
+ ? prevEntry.usable
435
+ : null,
436
+ limit:
437
+ limit != null
438
+ ? limit
439
+ : sameModelAsPrev && finitePos(prevEntry.limit)
440
+ ? prevEntry.limit
441
+ : null,
442
+ reasoning: safeCount(reasoningTokens),
443
+ categories: {
444
+ user: catUser,
445
+ assistant: catAssistant,
446
+ reasoning: catReasoning,
447
+ toolArgs: catToolArgs,
448
+ system: catSystem,
449
+ toolSchemas: catToolSchemas,
450
+ other,
451
+ },
452
+ updatedAt: new Date().toISOString(),
453
+ };
454
+ all[sessionID] = entry;
455
+ // Bound the file: keep the most recently updated MAX_TRACKED_SESSIONS.
456
+ const keys = Object.keys(all);
457
+ if (keys.length > MAX_TRACKED_SESSIONS) {
458
+ keys
459
+ .sort((a, b) =>
460
+ String(all[b]?.updatedAt || "").localeCompare(
461
+ String(all[a]?.updatedAt || ""),
462
+ ),
463
+ )
464
+ .slice(MAX_TRACKED_SESSIONS)
465
+ .forEach((k) => delete all[k]);
466
+ }
467
+ // Random temp suffix + exclusive create (CWE-377), then atomic rename.
468
+ const tmp = `${STATE_FILE}.${randomBytes(6).toString("hex")}.tmp`;
469
+ writeFileSync(tmp, JSON.stringify(all), { encoding: "utf8", flag: "wx" });
470
+ try {
471
+ ensureRegularFile(STATE_FILE); // never rename over a planted symlink
472
+ renameWithRetry(tmp, STATE_FILE);
473
+ } finally {
474
+ // A rename that exhausted its retries leaves the temp file behind; drop
475
+ // it so repeated failures cannot litter %TEMP% (best-effort: on a
476
+ // SUCCESSFUL rename the temp is already gone, existsSync filters that).
477
+ try {
478
+ if (existsSync(tmp)) unlinkSync(tmp);
479
+ } catch {
480
+ /* best-effort cleanup */
481
+ }
482
+ }
483
+ } finally {
484
+ // Release ONLY a lock we actually own (lock === null => fail-open, skip).
485
+ if (lock && lock.ok) releaseStateLock(lock.token);
486
+ }
487
+ } catch (err) {
488
+ logErr("writeStateFile failed", err);
489
+ }
490
+ }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // DEBUG event tap (DEBUG_EVENTS only; fully fault-tolerant — any failure here
494
+ // is swallowed and must never affect the main path).
495
+ // ---------------------------------------------------------------------------
496
+
497
+ // Append one line to the event log, truncating it when it grows past ~1MB.
498
+ function appendEventLog(line) {
499
+ try {
500
+ // Refuse to follow a symlink planted at the log path before writing to it.
501
+ ensureRegularFile(EVENT_LOG_FILE);
502
+ try {
503
+ const st = statSync(EVENT_LOG_FILE);
504
+ if (st.size > EVENT_LOG_MAX_BYTES) {
505
+ // truncate, keep it bounded (explicit "w": create/replace a regular file)
506
+ writeFileSync(EVENT_LOG_FILE, "", { encoding: "utf8", flag: "w" });
507
+ }
508
+ } catch {
509
+ // file does not exist yet — nothing to truncate
510
+ }
511
+ appendFileSync(EVENT_LOG_FILE, line + "\n", "utf8");
512
+ } catch (err) {
513
+ logErr("appendEventLog failed", err);
514
+ }
515
+ }
516
+
517
+ // Tap every incoming event: timestamp + type. session.error gets the full
518
+ // JSON payload, session.compacted gets the sessionID. Never throws.
519
+ function debugEvent(event) {
520
+ if (!DEBUG_EVENTS || !event) return;
521
+ try {
522
+ let line = `[${new Date().toISOString()}] ${event.type}`;
523
+ if (event.type === "session.error") {
524
+ try {
525
+ line += ` ${JSON.stringify(event.properties ?? event.data ?? {})}`.slice(
526
+ 0,
527
+ 4000,
528
+ );
529
+ } catch {
530
+ line += " <unserializable payload>";
531
+ }
532
+ } else if (event.type === "session.compacted") {
533
+ const sid =
534
+ event.properties?.sessionID ?? event.data?.sessionID ?? "?";
535
+ line += ` sessionID=${sid}`;
536
+ }
537
+ appendEventLog(line);
538
+ } catch (err) {
539
+ logErr("debugEvent failed", err);
540
+ }
541
+ }
542
+
543
+ // Read the whole state.json snapshot ({ sessionID: entry }) or {} on any error.
544
+ // The file is UNTRUSTED (shared across processes / user-writable): rebuild it
545
+ // with a plain object, dropping prototype-pollution keys ("__proto__",
546
+ // "constructor", "prototype") so no downstream lookup can hit the prototype.
547
+ function readStateSnapshot() {
548
+ try {
549
+ const parsed = JSON.parse(readFileSync(STATE_FILE, "utf8"));
550
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
551
+ return {};
552
+ }
553
+ const out = {};
554
+ for (const [k, v] of Object.entries(parsed)) {
555
+ if (isSafeSessionKey(k)) out[k] = v;
556
+ }
557
+ return out;
558
+ } catch {
559
+ return {};
560
+ }
561
+ }
562
+
563
+ // Append a note to the persisted final-summaries section (claimed once across
564
+ // instances). Never throws.
565
+ function pushFinalSummary(claimKey, summaryLines) {
566
+ try {
567
+ if (!claimOnce(claimKey, DEDUP_DIR)) return;
568
+ finalSummaries.push(summaryLines.join("\n"));
569
+ if (finalSummaries.length > MAX_FINAL_SUMMARIES) {
570
+ finalSummaries.splice(0, finalSummaries.length - MAX_FINAL_SUMMARIES);
571
+ }
572
+ writeLog([]); // persist final summaries
573
+ } catch (err) {
574
+ logErr("pushFinalSummary failed", err);
575
+ }
576
+ }
577
+
578
+ export {
579
+ logErr,
580
+ renderFile,
581
+ writeLog,
582
+ appendLogLine,
583
+ sleepSync,
584
+ ensureRegularFile,
585
+ isStaleLock,
586
+ acquireStateLock,
587
+ releaseStateLock,
588
+ renameWithRetry,
589
+ writeStateFile,
590
+ appendEventLog,
591
+ debugEvent,
592
+ readStateSnapshot,
593
+ finalSummaries,
594
+ pushFinalSummary,
595
+ };