opencode-context-indicator 1.6.0 → 1.7.1

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,14 @@ import {
14
14
  estimateTokens,
15
15
  extractToolTarget,
16
16
  fmt,
17
+ isHookMeasuredEntry,
18
+ isHookSource,
17
19
  isSafeSessionKey,
18
20
  oneLine,
21
+ safeReclaim,
19
22
  safeSaneOrNull,
23
+ safeToolOutByTool,
24
+ safeTop,
20
25
  tokenCountOf,
21
26
  TOOL_OUT_MAX_KEYS,
22
27
  withTimeout,
@@ -60,6 +65,7 @@ import {
60
65
  debugEvent,
61
66
  logErr,
62
67
  pushFinalSummary,
68
+ readStateSnapshot,
63
69
  writeLog,
64
70
  writeStateFile,
65
71
  } from "./state.js";
@@ -422,10 +428,6 @@ function onV2Compaction(context) {
422
428
  pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
423
429
  }
424
430
 
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
431
  const ctxTokens = lastKnownCtx(sid);
430
432
  if (ctxTokens > 0) {
431
433
  const lines = buildLiveLinesV2(
@@ -436,7 +438,7 @@ function onV2Compaction(context) {
436
438
  ctxTokens,
437
439
  lastKnownReasoning(sid),
438
440
  );
439
- writeStateFile(
441
+ const wrote = writeStateFile(
440
442
  sid,
441
443
  lastKnownModel(sid),
442
444
  lastKnownProvider(sid),
@@ -449,6 +451,16 @@ function onV2Compaction(context) {
449
451
  cacheWrite: lastKnownCacheWrite(sid),
450
452
  },
451
453
  );
454
+ // Arm the fallback guard for the trailing `session.compacted` event ONLY
455
+ // when this hook actually PERSISTED the measurement (writeStateFile
456
+ // returns false on a busy lock / unsafe key / failed rename). A hook that
457
+ // wrote nothing leaves the entry untouched; arming there would tell the
458
+ // later event "already handled" and the compaction would leave no trace at
459
+ // all - no stale mark, no note ("after compaction nothing happens").
460
+ if (wrote) {
461
+ lastCompactionHookAt.set(sid, Date.now());
462
+ pruneMap(lastCompactionHookAt, MAX_TRACKED_SESSIONS);
463
+ }
452
464
  // Claim key derived from the PAYLOAD shape (message count + total
453
465
  // estimated chars): identical across plugin instances for the SAME
454
466
  // compaction (cross-instance dedup preserved) yet distinct per compaction
@@ -464,7 +476,7 @@ function onV2Compaction(context) {
464
476
  (parts.systemChars || 0) +
465
477
  (parts.toolSchemasChars || 0));
466
478
  pushFinalSummary(`compaction-hook:${sid}:${sig}`, [
467
- `[${new Date().toISOString()}] COMPACTION (hook) session=${sid} — pre-compaction snapshot captured`,
479
+ `[${new Date().toISOString()}] COMPACTION (hook) session=${sid} — pre-compaction snapshot ${wrote ? "captured" : "estimated only (state write refused)"}`,
468
480
  ...lines.slice(1),
469
481
  ]);
470
482
  }
@@ -671,6 +683,40 @@ function aggregateSubagentsV2(sessionID) {
671
683
  return result;
672
684
  }
673
685
 
686
+ // Convert a persisted state.json entry into the in-memory breakdown shape so the
687
+ // diagnostic log can render categories another instance measured with the
688
+ // request hook. Everything here is UNTRUSTED shared-file data: reuse the same
689
+ // sanitisers the writer and the command report use, so a poisoned entry cannot
690
+ // inject unbounded arrays, negative counts or foreign shapes into the log.
691
+ function stateEntryToBreakdown(entry) {
692
+ const c = entry?.categories || {};
693
+ const user = safeSaneOrNull(c.user) ?? 0;
694
+ const assistant = safeSaneOrNull(c.assistant) ?? 0;
695
+ const reasoning = safeSaneOrNull(c.reasoning) ?? 0;
696
+ const toolArgs = safeSaneOrNull(c.toolArgs) ?? 0;
697
+ const toolOut = safeSaneOrNull(c.toolOut) ?? 0;
698
+ return {
699
+ userChars: user,
700
+ assistantChars: assistant,
701
+ reasoningChars: reasoning,
702
+ toolArgsChars: toolArgs,
703
+ toolOutChars: toolOut,
704
+ toolOutByTool: safeToolOutByTool(entry?.toolOutByTool),
705
+ systemChars: safeSaneOrNull(c.system),
706
+ toolSchemasChars: safeSaneOrNull(c.toolSchemas),
707
+ reclaim: safeReclaim(entry?.reclaim, false),
708
+ top: safeTop(entry?.top, false),
709
+ // The entry's residual was computed against the INPUT the hook saw. This
710
+ // instance may have a different `lastKnownInput` (a mirror that only saw the
711
+ // trailing event has 0), so the residual must be carried over instead of
712
+ // recomputed - otherwise the log prints a non-zero breakdown next to
713
+ // "other 0 (= input - sum of estimates)".
714
+ otherChars: safeSaneOrNull(c.other),
715
+ capturedAt: Date.parse(entry?.updatedAt) || 0,
716
+ source: entry?.source || "snapshot",
717
+ };
718
+ }
719
+
674
720
  // V2: assemble the live breakdown lines (no client, no fallback fetch —
675
721
  // the context hook fills breakdownCache on every request).
676
722
  function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens, reasoningTokens) {
@@ -678,7 +724,74 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
678
724
  const ts = new Date().toISOString();
679
725
  const limit = getModelLimit(providerID, modelID);
680
726
  const usableInfo = getUsableContext(providerID, modelID);
681
- const total = typeof ctxTokens === "number" && ctxTokens > 0 ? ctxTokens : inputTokens;
727
+
728
+ // A client-context fallback cannot see the system prompt or the tool schemas:
729
+ // its `other` swallows both and, right after a compaction, it can even read a
730
+ // nearly empty conversation. The log is shared by every instance, so when
731
+ // ANOTHER instance has measured this session with the request hook, that
732
+ // measurement (state.json) is the truthful one to print here - same priority
733
+ // as writeStateFile / collectSessionReport. `!bd` (this instance has no cache
734
+ // entry at all, e.g. it only saw the trailing event) takes the same path.
735
+ // Only the DISPLAY is substituted; the cache itself is left alone.
736
+ //
737
+ // The counters come from the same snapshot on purpose: this instance's
738
+ // input/ctx belong to ITS view (a mirror that only saw the trailing event has
739
+ // 0), and mixing them with the hook's categories made the header contradict
740
+ // the "other = input - sum of estimates" line right below it.
741
+ let bd = breakdownCache.get(sessionID);
742
+ let snapInputTokens = null;
743
+ let snapCtxTokens = null;
744
+ let snapAt = null;
745
+ let borrowedSnapshot = false;
746
+ // A hook breakdown whose instance has NO request counters is just as
747
+ // unprintable as a fallback: the categories are measured, but the total this
748
+ // instance knows is 0 (it never completed a step - e.g. a mirror that only
749
+ // saw the compaction hook). Such an entry also takes the state.json path, so
750
+ // the header and the categories come from ONE measurement instead of a
751
+ // `ctx=0` header sitting above non-zero categories.
752
+ const haveLocalCounters =
753
+ (typeof inputTokens === "number" && inputTokens > 0) ||
754
+ (typeof ctxTokens === "number" && ctxTokens > 0);
755
+ if (
756
+ !bd ||
757
+ bd.source === "client-context" ||
758
+ (isHookSource(bd.source) && !haveLocalCounters)
759
+ ) {
760
+ const entry = isSafeSessionKey(sessionID)
761
+ ? readStateSnapshot()[sessionID]
762
+ : null;
763
+ if (isHookMeasuredEntry(entry)) {
764
+ bd = stateEntryToBreakdown(entry);
765
+ snapInputTokens = safeSaneOrNull(entry.input);
766
+ snapCtxTokens = safeSaneOrNull(entry.ctx);
767
+ snapAt = typeof entry.updatedAt === "string" ? entry.updatedAt : null;
768
+ borrowedSnapshot = true;
769
+ }
770
+ }
771
+ const source = bd?.source || "none";
772
+ const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
773
+ const effInput = snapInputTokens != null ? snapInputTokens : inputTokens;
774
+ const effCtx = snapCtxTokens != null ? snapCtxTokens : ctxTokens;
775
+ // Only a POSITIVE counter is knowledge. A zero is exactly what the broken
776
+ // case looks like (a state entry written by the trailing-event fallback of a
777
+ // mirror instance carries `ctx: 0, input: 0`), and treating it as "known"
778
+ // would reprint the very `ctx=0` this path exists to avoid.
779
+ const haveSnapCounters =
780
+ (typeof snapInputTokens === "number" && snapInputTokens > 0) ||
781
+ (typeof snapCtxTokens === "number" && snapCtxTokens > 0);
782
+ // Still unknown (no counters here AND no usable hook measurement on disk):
783
+ // print "?" rather than a fabricated 0 that contradicts the categories below.
784
+ const countersKnown = haveLocalCounters || haveSnapCounters;
785
+ // `other = input - sum` needs the input itself; a ctx-only snapshot (the
786
+ // compaction hook knows ctx but a mirror never saw the request) cannot
787
+ // produce an honest residual.
788
+ const inputKnown =
789
+ countersKnown && typeof effInput === "number" && effInput > 0;
790
+ const total = !countersKnown
791
+ ? null
792
+ : typeof effCtx === "number" && effCtx > 0
793
+ ? effCtx
794
+ : effInput;
682
795
  const denom =
683
796
  usableInfo != null
684
797
  ? usableInfo.usable
@@ -686,19 +799,31 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
686
799
  ? limit
687
800
  : null;
688
801
  const pct =
689
- denom != null ? ` (${((total / denom) * 100).toFixed(1)}%)` : "";
802
+ denom != null && total != null
803
+ ? ` (${((total / denom) * 100).toFixed(1)}%)`
804
+ : "";
805
+ // The reader must be able to tell a fresh local measurement from a borrowed
806
+ // snapshot (and from how old it is). A snapshot without a timestamp still
807
+ // must not look local.
808
+ const snapNote = borrowedSnapshot
809
+ ? ` snapshot=${snapAt || "unknown"}`
810
+ : "";
690
811
 
812
+ const shownTotal = total != null ? fmt(total) : "?";
691
813
  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)}`,
814
+ `[${ts}] session=${sessionID} model=${modelID} ctx=${shownTotal}/${denom != null ? fmt(denom) : "?"}${pct} count=${shownTotal} usable=${usableInfo != null ? fmt(usableInfo.usable) : "?"} limit=${limit != null ? fmt(limit) : "?"} maxOutput=${usableInfo != null ? fmt(usableInfo.maxOutput) : "?"} reserved=${usableInfo != null ? fmt(usableInfo.reserved) : "?"} input=${inputKnown ? fmt(effInput) : "?"}${snapNote}`,
693
815
  );
694
816
 
695
- const bd = breakdownCache.get(sessionID);
696
- const source = bd?.source || "none";
697
- const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
698
-
699
817
  // 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;
818
+ // may be absent -> everything 0 and the residual is the whole input). A
819
+ // breakdown taken from state.json carries the residual the HOOK computed
820
+ // against the input IT saw; this instance's `inputTokens` can differ (a mirror
821
+ // that only saw the trailing event has 0), so that value is used instead.
822
+ const other = inputKnown
823
+ ? typeof bd?.otherChars === "number"
824
+ ? bd.otherChars
825
+ : breakdownCategories(bd, effInput).other
826
+ : null;
702
827
 
703
828
  if (bd) {
704
829
  lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
@@ -728,7 +853,11 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
728
853
  lines.push(` system n/a (hook did not run)`);
729
854
  lines.push(` tool schemas n/a`);
730
855
  }
731
- lines.push(` other ${fmt(other)} (= input - sum of estimates)`);
856
+ lines.push(
857
+ other != null
858
+ ? ` other ${fmt(other)} (= input - sum of estimates)`
859
+ : ` other n/a (input unknown on this instance)`,
860
+ );
732
861
  lines.push(` source ${source}`);
733
862
 
734
863
  // Provider-reported prompt cache (EXACT, not an estimate; a subset of input —
@@ -877,8 +1006,45 @@ async function handleV2Event(ctx, event) {
877
1006
  },
878
1007
  );
879
1008
  }
1009
+ // Claim key: the event id when the host sends a usable one; otherwise the
1010
+ // event's own timestamp (a per-event revision, when present); otherwise a
1011
+ // per-session minute bucket (same shape as the V1 idle note). A
1012
+ // session-only key would log just the FIRST compaction of a session and
1013
+ // silently drop every later one whenever the host omits the event id.
1014
+ // An empty/zero id is treated as absent: `??` would collapse it to the
1015
+ // same key for every compaction and drop all but the first note.
1016
+ const evId =
1017
+ typeof event.id === "string" && event.id.length > 0 ? event.id : null;
1018
+ let evStamp = null;
1019
+ if (!evId) {
1020
+ for (const v of [
1021
+ event.time,
1022
+ event.timestamp,
1023
+ event.data?.time,
1024
+ event.data?.createdAt,
1025
+ event.data?.timestamp,
1026
+ ]) {
1027
+ if (typeof v === "string" && v.length > 0) {
1028
+ evStamp = v;
1029
+ break;
1030
+ }
1031
+ // `0` is a common "time unknown" sentinel: accepting it would make
1032
+ // the key constant for the whole 24h dedup TTL and re-introduce the
1033
+ // "only the first compaction is logged" bug.
1034
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) {
1035
+ evStamp = String(v);
1036
+ break;
1037
+ }
1038
+ }
1039
+ }
1040
+ // The session id stays in the key even with an event id: some hosts
1041
+ // number ids per session ("1", "2"), and cross-instance dedup only needs
1042
+ // the id to be equal for the SAME event of the SAME session.
1043
+ const claimKey = evId
1044
+ ? `compacted:${sid}:${evId}`
1045
+ : `compacted:${sid}:${evStamp ?? Math.floor(Date.now() / 60000)}`;
880
1046
  pushFinalSummary(
881
- `compacted:${event.id ?? sid}`,
1047
+ claimKey,
882
1048
  [
883
1049
  `[${new Date().toISOString()}] COMPACTED session=${sid} (auto — the context may have overflowed)`,
884
1050
  ...lines.slice(1),
@@ -1071,5 +1237,10 @@ export {
1071
1237
  handleV2Event,
1072
1238
  estimateBreakdownFromContext,
1073
1239
  estimateBreakdownFromClientMessages,
1240
+ // Harness-only (test/prepublish.mjs): the live-log assembly and its
1241
+ // state-entry adapter, so the hook-vs-fallback display rule can be tested
1242
+ // without a real opencode runtime.
1243
+ buildLiveLinesV2,
1244
+ stateEntryToBreakdown,
1074
1245
  V2_FALLBACK_TIMEOUT_MS,
1075
1246
  };
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.1",
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
  }