opencode-context-indicator 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -3
- package/README.md +144 -43
- package/dist/gui-experimental.cjs +1 -0
- package/index.js +16 -5
- package/lib/commands.js +81 -18
- package/lib/desktop.js +102 -0
- package/lib/estimate.js +31 -2
- package/lib/format.js +19 -0
- package/lib/limits.js +16 -11
- package/lib/rpc.d.ts +47 -0
- package/lib/rpc.js +88 -0
- package/lib/state.js +200 -56
- package/lib/tui-data.js +5 -9
- package/lib/v1.js +9 -2
- package/lib/v2.js +140 -15
- package/package.json +15 -6
package/lib/state.js
CHANGED
|
@@ -23,6 +23,8 @@ import { join } from "node:path";
|
|
|
23
23
|
import { claimOnce } from "./dedup.js";
|
|
24
24
|
import {
|
|
25
25
|
finitePos,
|
|
26
|
+
isHookMeasuredEntry,
|
|
27
|
+
isHookSource,
|
|
26
28
|
isSafeSessionKey,
|
|
27
29
|
oneLine,
|
|
28
30
|
safeCount,
|
|
@@ -99,6 +101,10 @@ const DEDUP_DIR = join(tmpdir(), "context-indicator.dedup");
|
|
|
99
101
|
// ~120 ms — a reasonable trade-off between surviving a transient holder and not
|
|
100
102
|
// freezing a plugin tick.
|
|
101
103
|
const STATE_LOCK_FILE = `${STATE_FILE}.lock`;
|
|
104
|
+
// The log file has the same read-merge-write shape (see writeLog) and needs its
|
|
105
|
+
// own lock: sharing the state lock would make every snapshot write contend with
|
|
106
|
+
// the state writers for no reason.
|
|
107
|
+
const LOG_LOCK_FILE = `${LOG_FILE}.lock`;
|
|
102
108
|
const LOCK_MAX_ATTEMPTS = 6;
|
|
103
109
|
const LOCK_RETRY_MS = 20;
|
|
104
110
|
const LOCK_STALE_MS = 5000; // a lock older than this is presumed abandoned
|
|
@@ -128,8 +134,15 @@ function logErr(msg, err) {
|
|
|
128
134
|
}
|
|
129
135
|
}
|
|
130
136
|
|
|
137
|
+
const FINAL_SUMMARY_MARKER = "\n## final summaries\n";
|
|
138
|
+
const LIVE_MARKER = "\n## live snapshot\n";
|
|
139
|
+
// Line cap for the persisted summary section. `MAX_FINAL_SUMMARIES` bounds the
|
|
140
|
+
// in-memory BLOCKS; the merged on-disk section is capped by lines (blocks are
|
|
141
|
+
// dropped oldest-first) so a burst of notes cannot grow the file forever.
|
|
142
|
+
const MAX_FINAL_SUMMARY_LINES = 400;
|
|
143
|
+
|
|
131
144
|
// Build the full file content: header + live snapshot + final summaries.
|
|
132
|
-
function renderFile(liveLines) {
|
|
145
|
+
function renderFile(liveLines, summaryBlocks) {
|
|
133
146
|
const lines = [];
|
|
134
147
|
lines.push(
|
|
135
148
|
"# context-breakdown.log",
|
|
@@ -142,17 +155,73 @@ function renderFile(liveLines) {
|
|
|
142
155
|
"",
|
|
143
156
|
"## final summaries",
|
|
144
157
|
);
|
|
145
|
-
if (
|
|
158
|
+
if (!summaryBlocks || summaryBlocks.length === 0) {
|
|
146
159
|
lines.push("(none yet)");
|
|
147
160
|
} else {
|
|
148
|
-
for (const s of
|
|
161
|
+
for (const s of summaryBlocks) lines.push(s);
|
|
149
162
|
}
|
|
150
163
|
return lines.join("\n") + "\n";
|
|
151
164
|
}
|
|
152
165
|
|
|
166
|
+
// Every summary block starts with its own `[<ISO timestamp>]` header line (all
|
|
167
|
+
// callers of pushFinalSummary do). Splitting the persisted section on that
|
|
168
|
+
// marker recovers the BLOCK structure: de-duplicating line-by-line would delete
|
|
169
|
+
// the repeated table separator / header rows of a second /context-breakdown
|
|
170
|
+
// block and corrupt the persisted table, and it would drop the blank lines that
|
|
171
|
+
// separate the sections inside a block.
|
|
172
|
+
const SUMMARY_BLOCK_START = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
|
173
|
+
|
|
174
|
+
function splitSummaryBlocks(lines) {
|
|
175
|
+
const blocks = [];
|
|
176
|
+
let cur = null;
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
if (line === "(none yet)") continue;
|
|
179
|
+
if (cur === null || SUMMARY_BLOCK_START.test(line)) {
|
|
180
|
+
if (cur !== null) blocks.push(cur.join("\n").trimEnd());
|
|
181
|
+
cur = [line];
|
|
182
|
+
} else {
|
|
183
|
+
cur.push(line);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (cur !== null) blocks.push(cur.join("\n").trimEnd());
|
|
187
|
+
return blocks.filter((b) => b.trim().length > 0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The log file is SHARED by every plugin instance while `finalSummaries` and
|
|
191
|
+
// `lastLiveLines` are per-process. Recover both sections from disk so a write
|
|
192
|
+
// from one instance neither drops a note another instance just appended nor
|
|
193
|
+
// blanks a fresher live snapshot with its own stale copy. Best-effort: any
|
|
194
|
+
// read/parse failure yields empty sections and the caller keeps its own data.
|
|
195
|
+
function readPersistedLog() {
|
|
196
|
+
try {
|
|
197
|
+
const text = readFileSync(LOG_FILE, "utf8");
|
|
198
|
+
const at = text.indexOf(FINAL_SUMMARY_MARKER);
|
|
199
|
+
const head = at < 0 ? text : text.slice(0, at);
|
|
200
|
+
const liveAt = head.indexOf(LIVE_MARKER);
|
|
201
|
+
const liveLines =
|
|
202
|
+
liveAt < 0
|
|
203
|
+
? []
|
|
204
|
+
: head
|
|
205
|
+
.slice(liveAt + LIVE_MARKER.length)
|
|
206
|
+
.split("\n")
|
|
207
|
+
.filter((l) => l.length > 0);
|
|
208
|
+
const summaryBlocks =
|
|
209
|
+
at < 0
|
|
210
|
+
? []
|
|
211
|
+
: splitSummaryBlocks(
|
|
212
|
+
text.slice(at + FINAL_SUMMARY_MARKER.length).split("\n"),
|
|
213
|
+
);
|
|
214
|
+
return { liveLines, summaryBlocks };
|
|
215
|
+
} catch {
|
|
216
|
+
return { liveLines: [], summaryBlocks: [] };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
153
220
|
// Atomically rewrite the whole log file (bounded by design). The temp+rename
|
|
154
|
-
// makes
|
|
155
|
-
//
|
|
221
|
+
// makes a READER see either the previous or the new file, never a half-written
|
|
222
|
+
// one; the log lock serialises the read-merge-write across instances (without it
|
|
223
|
+
// two writers that read the same snapshot can each drop the other's note - the
|
|
224
|
+
// exact "compaction note vanished" defect this merge exists to fix).
|
|
156
225
|
//
|
|
157
226
|
// The last rendered live snapshot is remembered: callers that only add a final
|
|
158
227
|
// summary / compaction note pass no live lines, and an empty snapshot section
|
|
@@ -161,33 +230,65 @@ let lastLiveLines = [];
|
|
|
161
230
|
|
|
162
231
|
function writeLog(liveLines) {
|
|
163
232
|
try {
|
|
164
|
-
|
|
165
|
-
|
|
233
|
+
const lock = acquireLock(LOG_LOCK_FILE);
|
|
234
|
+
if (lock && !lock.ok) {
|
|
235
|
+
// Another instance is rewriting the file right now; its write already
|
|
236
|
+
// carries the merged section (and our in-memory blocks land on the next
|
|
237
|
+
// cycle). Skipping is cheaper than a torn merge.
|
|
238
|
+
logErr("writeLog skipped (log lock busy)");
|
|
239
|
+
return;
|
|
166
240
|
}
|
|
167
|
-
const content = renderFile(
|
|
168
|
-
Array.isArray(liveLines) && liveLines.length > 0 ? liveLines : lastLiveLines,
|
|
169
|
-
);
|
|
170
|
-
// Random temp suffix + exclusive create ("wx"): a predictable name could be
|
|
171
|
-
// pre-created as a symlink for us to follow (CWE-377). "wx" fails instead.
|
|
172
|
-
const tmp = `${LOG_FILE}.${randomBytes(6).toString("hex")}.tmp`;
|
|
173
|
-
// Unlike writeStateFile there is no ensureRegularFile(LOG_FILE) guard: the
|
|
174
|
-
// destination is only ever replaced by rename(2), which swaps the directory
|
|
175
|
-
// entry and never follows a symlink, so a pre-planted link cannot redirect a
|
|
176
|
-
// write. The "wx" temp below already blocks the symlink-attack vector.
|
|
177
|
-
// Create BEFORE the finally-guarded section (mirrors writeStateFile): on an
|
|
178
|
-
// (astronomically unlikely) EEXIST collision with a peer's temp, THEIR file
|
|
179
|
-
// must not be cleaned up by us — the write fails and is logged instead.
|
|
180
|
-
writeFileSync(tmp, content, { encoding: "utf8", flag: "wx" });
|
|
181
241
|
try {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
242
|
+
const persisted = readPersistedLog();
|
|
243
|
+
const hasFreshLive = Array.isArray(liveLines) && liveLines.length > 0;
|
|
244
|
+
if (hasFreshLive) lastLiveLines = liveLines;
|
|
245
|
+
const live = hasFreshLive
|
|
246
|
+
? liveLines
|
|
247
|
+
: persisted.liveLines.length > 0
|
|
248
|
+
? persisted.liveLines
|
|
249
|
+
: lastLiveLines;
|
|
250
|
+
// Blocks, not lines: a repeated markdown table in a second command block
|
|
251
|
+
// must survive verbatim. Identity is the whole block (its timestamped
|
|
252
|
+
// header makes it unique).
|
|
253
|
+
const blocks = persisted.summaryBlocks.slice();
|
|
254
|
+
const seen = new Set(blocks);
|
|
255
|
+
for (const raw of finalSummaries) {
|
|
256
|
+
const block = String(raw).trimEnd();
|
|
257
|
+
if (!block || seen.has(block)) continue;
|
|
258
|
+
seen.add(block);
|
|
259
|
+
blocks.push(block);
|
|
260
|
+
}
|
|
261
|
+
// Line bound: drop the oldest blocks first (never the newest note).
|
|
262
|
+
let totalLines = blocks.reduce((n, b) => n + b.split("\n").length, 0);
|
|
263
|
+
while (blocks.length > 1 && totalLines > MAX_FINAL_SUMMARY_LINES) {
|
|
264
|
+
totalLines -= blocks[0].split("\n").length;
|
|
265
|
+
blocks.shift();
|
|
266
|
+
}
|
|
267
|
+
const content = renderFile(live, blocks);
|
|
268
|
+
// Random temp suffix + exclusive create ("wx"): a predictable name could be
|
|
269
|
+
// pre-created as a symlink for us to follow (CWE-377). "wx" fails instead.
|
|
270
|
+
const tmp = `${LOG_FILE}.${randomBytes(6).toString("hex")}.tmp`;
|
|
271
|
+
// Unlike writeStateFile there is no ensureRegularFile(LOG_FILE) guard: the
|
|
272
|
+
// destination is only ever replaced by rename(2), which swaps the directory
|
|
273
|
+
// entry and never follows a symlink, so a pre-planted link cannot redirect
|
|
274
|
+
// a write. The "wx" temp below already blocks the symlink-attack vector.
|
|
275
|
+
// Create BEFORE the finally-guarded section (mirrors writeStateFile): on an
|
|
276
|
+
// (astronomically unlikely) EEXIST collision with a peer's temp, THEIR file
|
|
277
|
+
// must not be cleaned up by us — the write fails and is logged instead.
|
|
278
|
+
writeFileSync(tmp, content, { encoding: "utf8", flag: "wx" });
|
|
186
279
|
try {
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
280
|
+
renameWithRetry(tmp, LOG_FILE);
|
|
281
|
+
} finally {
|
|
282
|
+
// Same hygiene as writeStateFile: a failed rename must not litter %TEMP%
|
|
283
|
+
// with stale `<hex>.tmp` files.
|
|
284
|
+
try {
|
|
285
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
286
|
+
} catch {
|
|
287
|
+
/* ignore */
|
|
288
|
+
}
|
|
190
289
|
}
|
|
290
|
+
} finally {
|
|
291
|
+
if (lock && lock.ok) releaseLock(LOG_LOCK_FILE, lock.token);
|
|
191
292
|
}
|
|
192
293
|
} catch (err) {
|
|
193
294
|
logErr("writeLog failed", err);
|
|
@@ -227,21 +328,24 @@ function ensureRegularFile(path) {
|
|
|
227
328
|
|
|
228
329
|
// True when the lock file exists and looks abandoned (older than LOCK_STALE_MS).
|
|
229
330
|
// Callers wrap it in try/catch: ENOENT (lock vanished) lands there and retries.
|
|
230
|
-
function isStaleLock() {
|
|
231
|
-
return Date.now() - statSync(
|
|
331
|
+
function isStaleLock(lockPath) {
|
|
332
|
+
return Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS;
|
|
232
333
|
}
|
|
233
334
|
|
|
234
335
|
// Returns `{ ok: true, token }` when the lock was taken (caller MUST release with
|
|
235
336
|
// that token), `{ ok: false, token: null }` when it is busy (skip the write), or
|
|
236
337
|
// `null` on an unexpected error — fail-open: the caller writes WITHOUT the lock
|
|
237
338
|
// and must NOT release (nothing was acquired).
|
|
238
|
-
|
|
339
|
+
//
|
|
340
|
+
// Parameterised by path: the state file and the log file each have their own
|
|
341
|
+
// read-merge-write critical section and must not serialise against each other.
|
|
342
|
+
function acquireLock(lockPath) {
|
|
239
343
|
const token = randomBytes(8).toString("hex"); // 16 hex chars, per acquisition
|
|
240
344
|
for (let i = 0; i < LOCK_MAX_ATTEMPTS; i++) {
|
|
241
345
|
try {
|
|
242
346
|
// "wx" = exclusive create; fails EEXIST if held, and creates+writes the
|
|
243
347
|
// token in ONE atomic step (no empty-file-then-write gap to race).
|
|
244
|
-
writeFileSync(
|
|
348
|
+
writeFileSync(lockPath, token, { flag: "wx" });
|
|
245
349
|
return { ok: true, token };
|
|
246
350
|
} catch (err) {
|
|
247
351
|
if (err && err.code === "EEXIST") {
|
|
@@ -254,12 +358,12 @@ function acquireStateLock() {
|
|
|
254
358
|
// consecutive checks narrow the window to the stat->rename gap
|
|
255
359
|
// (microseconds); the lock stays best-effort: the worst case is a
|
|
256
360
|
// stale single-session entry, self-healed on the next write.
|
|
257
|
-
if (isStaleLock() && isStaleLock()) {
|
|
361
|
+
if (isStaleLock(lockPath) && isStaleLock(lockPath)) {
|
|
258
362
|
// Atomic reclaim: only ONE waiter can rename the stale file away;
|
|
259
363
|
// the loser gets ENOENT and keeps waiting — neither can unlink a
|
|
260
364
|
// lock a peer just re-created.
|
|
261
|
-
const stale = `${
|
|
262
|
-
renameSync(
|
|
365
|
+
const stale = `${lockPath}.stale.${randomBytes(4).toString("hex")}`;
|
|
366
|
+
renameSync(lockPath, stale);
|
|
263
367
|
unlinkSync(stale);
|
|
264
368
|
continue; // retry immediately to claim the now-free lock
|
|
265
369
|
}
|
|
@@ -270,7 +374,7 @@ function acquireStateLock() {
|
|
|
270
374
|
} else {
|
|
271
375
|
// Unexpected error (permissions, tmpdir issue): do not block forever —
|
|
272
376
|
// fail-open so the state file still gets written.
|
|
273
|
-
logErr("
|
|
377
|
+
logErr("lock acquire error (proceeding unlocked)", err);
|
|
274
378
|
return null;
|
|
275
379
|
}
|
|
276
380
|
}
|
|
@@ -280,10 +384,10 @@ function acquireStateLock() {
|
|
|
280
384
|
|
|
281
385
|
// Ownership-safe release: unlink ONLY while the lock still holds OUR token. A
|
|
282
386
|
// peer that reclaimed an abandoned lock (or a later acquisition) is left alone.
|
|
283
|
-
function
|
|
387
|
+
function releaseLock(lockPath, token) {
|
|
284
388
|
try {
|
|
285
|
-
if (readFileSync(
|
|
286
|
-
unlinkSync(
|
|
389
|
+
if (readFileSync(lockPath, "utf8") === token) {
|
|
390
|
+
unlinkSync(lockPath);
|
|
287
391
|
}
|
|
288
392
|
} catch {
|
|
289
393
|
/* already gone / never created -> nothing to release */
|
|
@@ -335,15 +439,15 @@ function writeStateFile(
|
|
|
335
439
|
opts,
|
|
336
440
|
) {
|
|
337
441
|
try {
|
|
338
|
-
if (!sessionID) return;
|
|
442
|
+
if (!sessionID) return false;
|
|
339
443
|
// A poisoned/odd sessionID ("__proto__", "constructor", …) must never be
|
|
340
444
|
// used as an object key: plain assignment through it would mutate the
|
|
341
445
|
// prototype instead of adding an entry (CWE-1321).
|
|
342
446
|
if (!isSafeSessionKey(sessionID)) {
|
|
343
447
|
logErr(`writeStateFile skipped unsafe session key ${oneLine(sessionID)}`);
|
|
344
|
-
return;
|
|
448
|
+
return false;
|
|
345
449
|
}
|
|
346
|
-
const lock =
|
|
450
|
+
const lock = acquireLock(STATE_LOCK_FILE);
|
|
347
451
|
// {ok:false} => busy: skip (value stays in memory, lands next cycle), as
|
|
348
452
|
// before. null => unexpected error: fail-open, write WITHOUT the lock and do
|
|
349
453
|
// NOT release (nothing was acquired — releasing could nuke a peer's lock).
|
|
@@ -351,7 +455,7 @@ function writeStateFile(
|
|
|
351
455
|
logErr(
|
|
352
456
|
`writeStateFile skipped (state lock busy) session=${oneLine(sessionID)}`,
|
|
353
457
|
);
|
|
354
|
-
return;
|
|
458
|
+
return false;
|
|
355
459
|
}
|
|
356
460
|
try {
|
|
357
461
|
const bd = breakdownCache.get(sessionID) || null;
|
|
@@ -413,6 +517,26 @@ function writeStateFile(
|
|
|
413
517
|
: null;
|
|
414
518
|
const pc = prevEntry?.categories || null;
|
|
415
519
|
|
|
520
|
+
// Which breakdown is allowed to OWN the persisted categories?
|
|
521
|
+
//
|
|
522
|
+
// This file is shared by every plugin instance, and the instance that
|
|
523
|
+
// answers the Desktop RPC (the window's) never serves a model request, so
|
|
524
|
+
// its `breakdownCache` entry is always the client-context fallback rebuilt
|
|
525
|
+
// from the persisted message list. That fallback cannot see the assembled
|
|
526
|
+
// system prompt or the tool schemas, folds their whole mass into `other`,
|
|
527
|
+
// and after a compaction it can even read an empty conversation. Letting
|
|
528
|
+
// it overwrite the hook's measurement — the two writes landed 11 ms apart
|
|
529
|
+
// in the field — produced "system 0 / tool schemas 0 / other inflated" and
|
|
530
|
+
// could blank user/assistant. So: a hook-family source (see
|
|
531
|
+
// isHookSource) always wins; the fallback only fills an entry that no hook
|
|
532
|
+
// has ever measured.
|
|
533
|
+
//
|
|
534
|
+
// Legacy entries (written before `source` was persisted) carry no tag, but
|
|
535
|
+
// only a hook can produce a non-null system / tool-schema estimate.
|
|
536
|
+
const prevHookMeasured = isHookMeasuredEntry(prevEntry);
|
|
537
|
+
const bdFromHook = bd != null && isHookSource(bd.source);
|
|
538
|
+
const useBd = bd != null && (bdFromHook || !prevHookMeasured);
|
|
539
|
+
|
|
416
540
|
// Resolve the model identity for THIS write. An unmeasured write from a
|
|
417
541
|
// mirror/idle instance may not know the model (its caches were cleared);
|
|
418
542
|
// an unknown model must not overwrite a known one (that would also drop
|
|
@@ -453,29 +577,29 @@ function writeStateFile(
|
|
|
453
577
|
|
|
454
578
|
// Coerce every persisted category through the safe numeric helpers: the
|
|
455
579
|
// snapshot is untrusted input and must never inject non-numbers.
|
|
456
|
-
const catUser =
|
|
457
|
-
const catAssistant =
|
|
580
|
+
const catUser = useBd ? safeCount(bd.userChars) : safeCount(pc?.user);
|
|
581
|
+
const catAssistant = useBd
|
|
458
582
|
? safeCount(bd.assistantChars)
|
|
459
583
|
: safeCount(pc?.assistant);
|
|
460
|
-
const catReasoning =
|
|
584
|
+
const catReasoning = useBd
|
|
461
585
|
? safeCount(bd.reasoningChars)
|
|
462
586
|
: safeCount(pc?.reasoning);
|
|
463
|
-
const catToolArgs =
|
|
587
|
+
const catToolArgs = useBd
|
|
464
588
|
? safeCount(bd.toolArgsChars)
|
|
465
589
|
: safeCount(pc?.toolArgs);
|
|
466
590
|
// system / tool schemas are hook-only; a fallback breakdown has them null
|
|
467
591
|
// and must not erase a value the hook already persisted.
|
|
468
592
|
const catSystem =
|
|
469
|
-
|
|
593
|
+
useBd && bd.systemChars != null
|
|
470
594
|
? safeCount(bd.systemChars)
|
|
471
595
|
: safeCountOrNull(pc?.system);
|
|
472
596
|
const catToolSchemas =
|
|
473
|
-
|
|
597
|
+
useBd && bd.toolSchemasChars != null
|
|
474
598
|
? safeCount(bd.toolSchemasChars)
|
|
475
599
|
: safeCountOrNull(pc?.toolSchemas);
|
|
476
600
|
// Tool results are counted by both the hook and the fallback path, so
|
|
477
601
|
// this is a plain counted category (never null): a missing value is 0.
|
|
478
|
-
const catToolOut =
|
|
602
|
+
const catToolOut = useBd
|
|
479
603
|
? safeCount(bd.toolOutChars)
|
|
480
604
|
: safeCount(pc?.toolOut);
|
|
481
605
|
|
|
@@ -484,7 +608,7 @@ function writeStateFile(
|
|
|
484
608
|
// split (an empty one means "no tool results now" — do NOT revive a stale
|
|
485
609
|
// peer value); only fall back to the persisted map when this instance has
|
|
486
610
|
// no live breakdown at all.
|
|
487
|
-
const toolOutByTool =
|
|
611
|
+
const toolOutByTool = useBd
|
|
488
612
|
? safeToolOutByTool(bd.toolOutByTool)
|
|
489
613
|
: safeToolOutByTool(prevEntry?.toolOutByTool);
|
|
490
614
|
|
|
@@ -492,14 +616,14 @@ function writeStateFile(
|
|
|
492
616
|
// (keepTarget=false): state.json is a local temp file, but keeping it
|
|
493
617
|
// path-free matches the redacted-export policy. A present breakdown owns
|
|
494
618
|
// the value; otherwise inherit the persisted one.
|
|
495
|
-
const reclaim =
|
|
619
|
+
const reclaim = useBd
|
|
496
620
|
? safeReclaim(bd.reclaim, false)
|
|
497
621
|
: safeReclaim(prevEntry?.reclaim, false);
|
|
498
622
|
|
|
499
623
|
// Heaviest single items still in the request (top-N). A present breakdown
|
|
500
624
|
// OWNS the list; otherwise inherit the persisted one. Persisted WITHOUT
|
|
501
625
|
// targets (path-free policy, same as reclaim).
|
|
502
|
-
const top =
|
|
626
|
+
const top = useBd
|
|
503
627
|
? safeTop(bd.top, false)
|
|
504
628
|
: safeTop(prevEntry?.top, false);
|
|
505
629
|
|
|
@@ -528,8 +652,11 @@ function writeStateFile(
|
|
|
528
652
|
compactStale = true;
|
|
529
653
|
} else if (compactedAt) {
|
|
530
654
|
const cMs = Date.parse(compactedAt);
|
|
655
|
+
// Only a breakdown that actually OWNS the categories may clear the
|
|
656
|
+
// mark: a fallback fetch captured after the compaction never measured
|
|
657
|
+
// the new context, so it must not declare the entry fresh.
|
|
531
658
|
const measuredMs =
|
|
532
|
-
|
|
659
|
+
useBd && typeof bd.capturedAt === "number" ? bd.capturedAt : NaN;
|
|
533
660
|
if (Number.isFinite(cMs) && Number.isFinite(measuredMs)) {
|
|
534
661
|
compactStale = measuredMs <= cMs;
|
|
535
662
|
} else {
|
|
@@ -623,6 +750,20 @@ function writeStateFile(
|
|
|
623
750
|
: null,
|
|
624
751
|
reasoning: safeCount(reasoningTokens),
|
|
625
752
|
categories,
|
|
753
|
+
// Provenance of the persisted CATEGORIES (never of the token counts):
|
|
754
|
+
// "context-hook" / "compaction-hook" (V2) and "transform" /
|
|
755
|
+
// "system-transform" (V1) = a full payload with the system prompt and
|
|
756
|
+
// the tool schemas; "client-context" / "fallback" = rebuilt from the
|
|
757
|
+
// persisted messages. The next write reads it to decide which source may
|
|
758
|
+
// own the categories - see prevHookMeasured above. An untagged breakdown
|
|
759
|
+
// is treated as a fallback: never claim hook authority by default.
|
|
760
|
+
source: useBd
|
|
761
|
+
? bd.source || "client-context"
|
|
762
|
+
: prevHookMeasured
|
|
763
|
+
? prevEntry.source || "context-hook"
|
|
764
|
+
: pc
|
|
765
|
+
? "snapshot"
|
|
766
|
+
: null,
|
|
626
767
|
top,
|
|
627
768
|
previous,
|
|
628
769
|
toolOutByTool,
|
|
@@ -649,9 +790,10 @@ function writeStateFile(
|
|
|
649
790
|
// Random temp suffix + exclusive create (CWE-377), then atomic rename.
|
|
650
791
|
const tmp = `${STATE_FILE}.${randomBytes(6).toString("hex")}.tmp`;
|
|
651
792
|
writeFileSync(tmp, JSON.stringify(all), { encoding: "utf8", flag: "wx" });
|
|
793
|
+
let renamed = false;
|
|
652
794
|
try {
|
|
653
795
|
ensureRegularFile(STATE_FILE); // never rename over a planted symlink
|
|
654
|
-
renameWithRetry(tmp, STATE_FILE);
|
|
796
|
+
renamed = renameWithRetry(tmp, STATE_FILE);
|
|
655
797
|
} finally {
|
|
656
798
|
// A rename that exhausted its retries leaves the temp file behind; drop
|
|
657
799
|
// it so repeated failures cannot litter %TEMP% (best-effort: on a
|
|
@@ -662,12 +804,14 @@ function writeStateFile(
|
|
|
662
804
|
/* best-effort cleanup */
|
|
663
805
|
}
|
|
664
806
|
}
|
|
807
|
+
return renamed;
|
|
665
808
|
} finally {
|
|
666
809
|
// Release ONLY a lock we actually own (lock === null => fail-open, skip).
|
|
667
|
-
if (lock && lock.ok)
|
|
810
|
+
if (lock && lock.ok) releaseLock(STATE_LOCK_FILE, lock.token);
|
|
668
811
|
}
|
|
669
812
|
} catch (err) {
|
|
670
813
|
logErr("writeStateFile failed", err);
|
|
814
|
+
return false;
|
|
671
815
|
}
|
|
672
816
|
}
|
|
673
817
|
|
package/lib/tui-data.js
CHANGED
|
@@ -22,11 +22,16 @@ import { tmpdir } from "node:os";
|
|
|
22
22
|
import { join } from "node:path";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import { isSafeSessionKey, sumCategories } from "./estimate.js";
|
|
25
|
+
import { fmt, num } from "./format.js";
|
|
25
26
|
|
|
26
27
|
// Re-exported so the panel keeps importing it from this module; the
|
|
27
28
|
// implementation is the shared one (lib/estimate.js) — no second copy to drift.
|
|
28
29
|
export { toolOutEntries } from "./estimate.js";
|
|
29
30
|
|
|
31
|
+
// Same for the number helpers: lib/format.js is pure (no node builtins), so the
|
|
32
|
+
// Desktop GUI bundle can inline it, while the panel keeps importing them here.
|
|
33
|
+
export { fmt, num };
|
|
34
|
+
|
|
30
35
|
export const STATE_FILE =
|
|
31
36
|
process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
|
|
32
37
|
join(tmpdir(), "opencode-context-indicator-state.json");
|
|
@@ -123,15 +128,6 @@ export function collectFamily(snapshot, rootID) {
|
|
|
123
128
|
return out
|
|
124
129
|
}
|
|
125
130
|
|
|
126
|
-
export function fmt(n) {
|
|
127
|
-
if (!Number.isFinite(n)) return "0"
|
|
128
|
-
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function num(v) {
|
|
132
|
-
return typeof v === "number" && Number.isFinite(v) ? v : 0
|
|
133
|
-
}
|
|
134
|
-
|
|
135
131
|
// Code-point-safe column fit: truncate on code points (never splitting a
|
|
136
132
|
// surrogate pair at a column boundary), mark a truncated cell with a trailing
|
|
137
133
|
// "~" so clipping is VISIBLE (a silently clipped cell reads as two columns
|
package/lib/v1.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// fallback, subagent aggregation, and the live log assembly). Extracted 1:1
|
|
7
7
|
// (code + WHY comments) from index.js.
|
|
8
8
|
|
|
9
|
-
import { estJson, estimateTokens, fmt, tokenCountOf } from "./estimate.js";
|
|
9
|
+
import { estJson, estimateTokens, fmt, isHookSource, tokenCountOf } from "./estimate.js";
|
|
10
10
|
import {
|
|
11
11
|
BREAKDOWN_THROTTLE_MS,
|
|
12
12
|
breakdownCache,
|
|
@@ -624,7 +624,14 @@ function makeV1(client) {
|
|
|
624
624
|
...prev,
|
|
625
625
|
systemChars,
|
|
626
626
|
capturedAt: Date.now(),
|
|
627
|
-
source
|
|
627
|
+
// Keep the provenance of a HOOK-family source only. A `fallback`
|
|
628
|
+
// record (built from session.messages on idle) can also sit in this
|
|
629
|
+
// cache: tagging the hook's system measurement with it would persist
|
|
630
|
+
// a non-null system prompt under a NON-hook `source`, which the
|
|
631
|
+
// authority rule (isHookMeasuredEntry) would then treat as
|
|
632
|
+
// overwritable by the next message-based fallback - the exact
|
|
633
|
+
// "system 0 / other inflated" defect, from the V1 side.
|
|
634
|
+
source: isHookSource(prev.source) ? prev.source : "system-transform",
|
|
628
635
|
});
|
|
629
636
|
pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
|
|
630
637
|
} catch (err) {
|