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/lib/v2.js CHANGED
@@ -14,9 +14,13 @@ import {
14
14
  estimateTokens,
15
15
  extractToolTarget,
16
16
  fmt,
17
+ isHookMeasuredEntry,
17
18
  isSafeSessionKey,
18
19
  oneLine,
20
+ safeReclaim,
19
21
  safeSaneOrNull,
22
+ safeToolOutByTool,
23
+ safeTop,
20
24
  tokenCountOf,
21
25
  TOOL_OUT_MAX_KEYS,
22
26
  withTimeout,
@@ -60,6 +64,7 @@ import {
60
64
  debugEvent,
61
65
  logErr,
62
66
  pushFinalSummary,
67
+ readStateSnapshot,
63
68
  writeLog,
64
69
  writeStateFile,
65
70
  } from "./state.js";
@@ -422,10 +427,6 @@ function onV2Compaction(context) {
422
427
  pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
423
428
  }
424
429
 
425
- // Arm the fallback guard for the trailing `session.compacted` event.
426
- lastCompactionHookAt.set(sid, Date.now());
427
- pruneMap(lastCompactionHookAt, MAX_TRACKED_SESSIONS);
428
-
429
430
  const ctxTokens = lastKnownCtx(sid);
430
431
  if (ctxTokens > 0) {
431
432
  const lines = buildLiveLinesV2(
@@ -436,7 +437,7 @@ function onV2Compaction(context) {
436
437
  ctxTokens,
437
438
  lastKnownReasoning(sid),
438
439
  );
439
- writeStateFile(
440
+ const wrote = writeStateFile(
440
441
  sid,
441
442
  lastKnownModel(sid),
442
443
  lastKnownProvider(sid),
@@ -449,6 +450,16 @@ function onV2Compaction(context) {
449
450
  cacheWrite: lastKnownCacheWrite(sid),
450
451
  },
451
452
  );
453
+ // Arm the fallback guard for the trailing `session.compacted` event ONLY
454
+ // when this hook actually PERSISTED the measurement (writeStateFile
455
+ // returns false on a busy lock / unsafe key / failed rename). A hook that
456
+ // wrote nothing leaves the entry untouched; arming there would tell the
457
+ // later event "already handled" and the compaction would leave no trace at
458
+ // all - no stale mark, no note ("after compaction nothing happens").
459
+ if (wrote) {
460
+ lastCompactionHookAt.set(sid, Date.now());
461
+ pruneMap(lastCompactionHookAt, MAX_TRACKED_SESSIONS);
462
+ }
452
463
  // Claim key derived from the PAYLOAD shape (message count + total
453
464
  // estimated chars): identical across plugin instances for the SAME
454
465
  // compaction (cross-instance dedup preserved) yet distinct per compaction
@@ -464,7 +475,7 @@ function onV2Compaction(context) {
464
475
  (parts.systemChars || 0) +
465
476
  (parts.toolSchemasChars || 0));
466
477
  pushFinalSummary(`compaction-hook:${sid}:${sig}`, [
467
- `[${new Date().toISOString()}] COMPACTION (hook) session=${sid} — pre-compaction snapshot captured`,
478
+ `[${new Date().toISOString()}] COMPACTION (hook) session=${sid} — pre-compaction snapshot ${wrote ? "captured" : "estimated only (state write refused)"}`,
468
479
  ...lines.slice(1),
469
480
  ]);
470
481
  }
@@ -671,6 +682,40 @@ function aggregateSubagentsV2(sessionID) {
671
682
  return result;
672
683
  }
673
684
 
685
+ // Convert a persisted state.json entry into the in-memory breakdown shape so the
686
+ // diagnostic log can render categories another instance measured with the
687
+ // request hook. Everything here is UNTRUSTED shared-file data: reuse the same
688
+ // sanitisers the writer and the command report use, so a poisoned entry cannot
689
+ // inject unbounded arrays, negative counts or foreign shapes into the log.
690
+ function stateEntryToBreakdown(entry) {
691
+ const c = entry?.categories || {};
692
+ const user = safeSaneOrNull(c.user) ?? 0;
693
+ const assistant = safeSaneOrNull(c.assistant) ?? 0;
694
+ const reasoning = safeSaneOrNull(c.reasoning) ?? 0;
695
+ const toolArgs = safeSaneOrNull(c.toolArgs) ?? 0;
696
+ const toolOut = safeSaneOrNull(c.toolOut) ?? 0;
697
+ return {
698
+ userChars: user,
699
+ assistantChars: assistant,
700
+ reasoningChars: reasoning,
701
+ toolArgsChars: toolArgs,
702
+ toolOutChars: toolOut,
703
+ toolOutByTool: safeToolOutByTool(entry?.toolOutByTool),
704
+ systemChars: safeSaneOrNull(c.system),
705
+ toolSchemasChars: safeSaneOrNull(c.toolSchemas),
706
+ reclaim: safeReclaim(entry?.reclaim, false),
707
+ top: safeTop(entry?.top, false),
708
+ // The entry's residual was computed against the INPUT the hook saw. This
709
+ // instance may have a different `lastKnownInput` (a mirror that only saw the
710
+ // trailing event has 0), so the residual must be carried over instead of
711
+ // recomputed - otherwise the log prints a non-zero breakdown next to
712
+ // "other 0 (= input - sum of estimates)".
713
+ otherChars: safeSaneOrNull(c.other),
714
+ capturedAt: Date.parse(entry?.updatedAt) || 0,
715
+ source: entry?.source || "snapshot",
716
+ };
717
+ }
718
+
674
719
  // V2: assemble the live breakdown lines (no client, no fallback fetch —
675
720
  // the context hook fills breakdownCache on every request).
676
721
  function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens, reasoningTokens) {
@@ -678,7 +723,40 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
678
723
  const ts = new Date().toISOString();
679
724
  const limit = getModelLimit(providerID, modelID);
680
725
  const usableInfo = getUsableContext(providerID, modelID);
681
- const total = typeof ctxTokens === "number" && ctxTokens > 0 ? ctxTokens : inputTokens;
726
+
727
+ // A client-context fallback cannot see the system prompt or the tool schemas:
728
+ // its `other` swallows both and, right after a compaction, it can even read a
729
+ // nearly empty conversation. The log is shared by every instance, so when
730
+ // ANOTHER instance has measured this session with the request hook, that
731
+ // measurement (state.json) is the truthful one to print here - same priority
732
+ // as writeStateFile / collectSessionReport. `!bd` (this instance has no cache
733
+ // entry at all, e.g. it only saw the trailing event) takes the same path.
734
+ // Only the DISPLAY is substituted; the cache itself is left alone.
735
+ //
736
+ // The counters come from the same snapshot on purpose: this instance's
737
+ // input/ctx belong to ITS view (a mirror that only saw the trailing event has
738
+ // 0), and mixing them with the hook's categories made the header contradict
739
+ // the "other = input - sum of estimates" line right below it.
740
+ let bd = breakdownCache.get(sessionID);
741
+ let snapInputTokens = null;
742
+ let snapCtxTokens = null;
743
+ let snapAt = null;
744
+ if (!bd || bd.source === "client-context") {
745
+ const entry = isSafeSessionKey(sessionID)
746
+ ? readStateSnapshot()[sessionID]
747
+ : null;
748
+ if (isHookMeasuredEntry(entry)) {
749
+ bd = stateEntryToBreakdown(entry);
750
+ snapInputTokens = safeSaneOrNull(entry.input);
751
+ snapCtxTokens = safeSaneOrNull(entry.ctx);
752
+ snapAt = typeof entry.updatedAt === "string" ? entry.updatedAt : null;
753
+ }
754
+ }
755
+ const source = bd?.source || "none";
756
+ const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
757
+ const effInput = snapInputTokens != null ? snapInputTokens : inputTokens;
758
+ const effCtx = snapCtxTokens != null ? snapCtxTokens : ctxTokens;
759
+ const total = typeof effCtx === "number" && effCtx > 0 ? effCtx : effInput;
682
760
  const denom =
683
761
  usableInfo != null
684
762
  ? usableInfo.usable
@@ -687,18 +765,23 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
687
765
  : null;
688
766
  const pct =
689
767
  denom != null ? ` (${((total / denom) * 100).toFixed(1)}%)` : "";
768
+ // The reader must be able to tell a fresh local measurement from a borrowed
769
+ // snapshot (and from how old it is).
770
+ const snapNote = snapAt ? ` snapshot=${snapAt}` : "";
690
771
 
691
772
  lines.push(
692
- `[${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)}`,
773
+ `[${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(effInput)}${snapNote}`,
693
774
  );
694
775
 
695
- const bd = breakdownCache.get(sessionID);
696
- const source = bd?.source || "none";
697
- const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
698
-
699
776
  // Residual = input minus every measured category (shared normaliser; `bd`
700
- // may be absent -> everything 0 and the residual is the whole input).
701
- const other = breakdownCategories(bd, inputTokens).other;
777
+ // may be absent -> everything 0 and the residual is the whole input). A
778
+ // breakdown taken from state.json carries the residual the HOOK computed
779
+ // against the input IT saw; this instance's `inputTokens` can differ (a mirror
780
+ // that only saw the trailing event has 0), so that value is used instead.
781
+ const other =
782
+ typeof bd?.otherChars === "number"
783
+ ? bd.otherChars
784
+ : breakdownCategories(bd, effInput).other;
702
785
 
703
786
  if (bd) {
704
787
  lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
@@ -877,8 +960,45 @@ async function handleV2Event(ctx, event) {
877
960
  },
878
961
  );
879
962
  }
963
+ // Claim key: the event id when the host sends a usable one; otherwise the
964
+ // event's own timestamp (a per-event revision, when present); otherwise a
965
+ // per-session minute bucket (same shape as the V1 idle note). A
966
+ // session-only key would log just the FIRST compaction of a session and
967
+ // silently drop every later one whenever the host omits the event id.
968
+ // An empty/zero id is treated as absent: `??` would collapse it to the
969
+ // same key for every compaction and drop all but the first note.
970
+ const evId =
971
+ typeof event.id === "string" && event.id.length > 0 ? event.id : null;
972
+ let evStamp = null;
973
+ if (!evId) {
974
+ for (const v of [
975
+ event.time,
976
+ event.timestamp,
977
+ event.data?.time,
978
+ event.data?.createdAt,
979
+ event.data?.timestamp,
980
+ ]) {
981
+ if (typeof v === "string" && v.length > 0) {
982
+ evStamp = v;
983
+ break;
984
+ }
985
+ // `0` is a common "time unknown" sentinel: accepting it would make
986
+ // the key constant for the whole 24h dedup TTL and re-introduce the
987
+ // "only the first compaction is logged" bug.
988
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) {
989
+ evStamp = String(v);
990
+ break;
991
+ }
992
+ }
993
+ }
994
+ // The session id stays in the key even with an event id: some hosts
995
+ // number ids per session ("1", "2"), and cross-instance dedup only needs
996
+ // the id to be equal for the SAME event of the SAME session.
997
+ const claimKey = evId
998
+ ? `compacted:${sid}:${evId}`
999
+ : `compacted:${sid}:${evStamp ?? Math.floor(Date.now() / 60000)}`;
880
1000
  pushFinalSummary(
881
- `compacted:${event.id ?? sid}`,
1001
+ claimKey,
882
1002
  [
883
1003
  `[${new Date().toISOString()}] COMPACTED session=${sid} (auto — the context may have overflowed)`,
884
1004
  ...lines.slice(1),
@@ -1071,5 +1191,10 @@ export {
1071
1191
  handleV2Event,
1072
1192
  estimateBreakdownFromContext,
1073
1193
  estimateBreakdownFromClientMessages,
1194
+ // Harness-only (test/prepublish.mjs): the live-log assembly and its
1195
+ // state-entry adapter, so the hook-vs-fallback display rule can be tested
1196
+ // without a real opencode runtime.
1197
+ buildLiveLinesV2,
1198
+ stateEntryToBreakdown,
1074
1199
  V2_FALLBACK_TIMEOUT_MS,
1075
1200
  };
package/package.json CHANGED
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.6.0",
4
- "description": "Real-time context-window usage for OpenCode: per-category token breakdown, /context-diff, a zero-LLM /cx panel with per-session cost and a searchable family-session picker, and a TUI sidebar. Dual V1 + V2 plugin entry.",
3
+ "version": "1.7.0",
4
+ "description": "Real-time context-window usage for OpenCode: per-category token breakdown, /context-diff, a zero-LLM /cx panel with per-session cost and a searchable family-session picker, a TUI sidebar, and an experimental Desktop panel. Dual V1 + V2 plugin entry.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
- "./tui": "./tui.tsx"
9
+ "./tui": "./tui.tsx",
10
+ "./rpc": "./lib/rpc.js",
11
+ "./gui-experimental": "./dist/gui-experimental.cjs"
10
12
  },
11
13
  "files": [
12
14
  "index.js",
13
15
  "lib/",
14
16
  "tui.tsx",
17
+ "dist/",
15
18
  "docs/",
16
19
  "README.md",
17
20
  "CHANGELOG.md",
@@ -31,7 +34,9 @@
31
34
  "breakdown",
32
35
  "tui",
33
36
  "sidebar",
34
- "panel"
37
+ "panel",
38
+ "desktop",
39
+ "gui"
35
40
  ],
36
41
  "publishConfig": {
37
42
  "access": "public"
@@ -71,14 +76,18 @@
71
76
  "esbuild": "^0.28.2",
72
77
  "node-pty": "^1.1.0",
73
78
  "solid-js": "^1.9.12",
74
- "typescript": "5.9.3"
79
+ "typescript": "5.9.3",
80
+ "vite": "^8.3.5",
81
+ "vite-plugin-solid": "^2.11.14"
75
82
  },
76
83
  "scripts": {
84
+ "build:gui": "vite build --config vite.gui.config.mjs",
85
+ "test:types": "tsc --noEmit && tsc -p tsconfig.gui.json --noEmit",
77
86
  "test:prepublish": "node test/prepublish.mjs",
78
87
  "test:e2e": "node test/e2e-tui.mjs --full",
79
88
  "test:e2e:quick": "node test/e2e-tui.mjs --quick",
80
89
  "test:drift": "node test/drift-watch.mjs",
81
90
  "test:drift:strict": "node test/drift-watch.mjs --strict"
82
91
  },
83
- "prepublishOnly": "node test/prepublish.mjs && node test/e2e-tui.mjs --full"
92
+ "prepublishOnly": "npm run build:gui && node test/prepublish.mjs && node test/e2e-tui.mjs --full"
84
93
  }