opencode-context-indicator 1.5.3 → 1.6.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 CHANGED
@@ -4,6 +4,67 @@ Notable user-facing changes per release. Dates are commit days (UTC).
4
4
  The package also ships a dual V1/V2 plugin — see the README for the surface
5
5
  matrix; everything below concerns the V2 path unless stated otherwise.
6
6
 
7
+ ## 1.6.0 — 2026-10-27
8
+
9
+ Host alignment release: opencode 2.2.0 moved TUI plugins to OpenTUI 0.7.
10
+
11
+ - **OpenTUI 0.7.0.** `@opencode/plugin` 2.2.0 declares `@opentui/core` and
12
+ `@opentui/solid` **0.7.0** as peers, and the host release notes state that a
13
+ plugin left on OpenTUI 0.6 no longer installs against it (upstream wording;
14
+ this repo's gates only ever run against the current host). The pinned direct
15
+ dependency is now `@opentui/solid@0.7.0` (dev pins `@opentui/core@0.7.0` and
16
+ `@opencode/plugin@2.2.0`). None of the 0.7 breaking changes apply to this
17
+ plugin: it renders intrinsic OpenTUI elements and registers slots, and defines
18
+ no custom renderables, native integrations or `nativeSceneWorkBudget` options.
19
+ - **Host support.** The server-only V2 path is unchanged and still targets
20
+ opencode 2.0.16+. The TUI module now ships compiled against OpenTUI 0.7, so
21
+ the sidebar and the `/cx` panel require **opencode ≥ 2.2.0** and cannot load on
22
+ an older host. Older-host installation was not re-verified for this release.
23
+ - **Compaction ceiling unchanged.** `calculateCeiling()` in 2.2.0 is identical
24
+ to 2.1.x, so the `0.15 / 16k / 32k` reserve formula and every `%` denominator
25
+ are unaffected. (2.2.0 changed how a *compaction request* is sized - measured
26
+ prompt instead of characters - not the trigger.)
27
+ - Dev: `drift-watch` is green again (host 2.2.0, OpenTUI 0.7.0); `tsc`,
28
+ `prepublish` and the isolated e2e pass against the 2.2.0 CLI.
29
+
30
+ ## 1.5.4 — 2026-10-27
31
+
32
+ Fix-and-hygiene release after a full-project review. No new commands.
33
+
34
+ - **Subagent roll-up no longer collapses on a child's idle.** `session.idle`
35
+ wiped the child's parent attribution and accumulated totals, so the very next
36
+ live line reported `subagents 0 session(s)` right after any subagent
37
+ finished, even though its parent was still running. V2 now keeps a finished
38
+ child's entries until the parent itself goes idle or is deleted
39
+ (`clearChildSubagentTracking`, breadth-first, so sub-subagents are collected
40
+ too); `/context-breakdown` and `/cx` were never affected (they read
41
+ `state.json`). T29 covers the lifetime.
42
+ - **TUI panel no longer re-measures every message once per second.** The
43
+ measured-contributors memo was keyed on the 1000 ms snapshot tick and the host
44
+ rebuilds the message array on every poll, so the full walk (per-character
45
+ token estimates plus `JSON.stringify` of tool args) ran on the render thread
46
+ every second. It is now keyed on a cheap signature (list length, last message
47
+ id, last message size including tool results and status), so a static session
48
+ costs almost nothing while streaming text and completing tool calls still
49
+ refresh.
50
+ - **A summary-only log write keeps the live snapshot.** `pushFinalSummary` used
51
+ to rewrite `context-breakdown.log` with an empty live section, so every idle /
52
+ compaction / command payload blanked it until the next measured write. The
53
+ module now remembers the last rendered snapshot and re-renders it.
54
+ - Doc fixes: the log is rewritten atomically rather than "appended" with
55
+ `appendFileSync`, and both its sections are process-local (survive rewrites,
56
+ not a restart); the `state.json` field list now includes `toolOutByTool`,
57
+ `reclaim`, `cacheRead`/`cacheWrite` and `compactedAt`/`compactStale`; "tested
58
+ against opencode" now says 2.1.4 (was a stale v2.0.28); the gate table now
59
+ states the actual scan scope.
60
+ - Packaging: `docs/` is published, so the README screenshot resolves on the npm
61
+ page. Gate (a) expands `docs/` the same way it expands `lib/` and rejects any
62
+ non-image file there.
63
+ - Tests: new `OPENCODE_CONTEXT_INDICATOR_LOG_FILE` override (test isolation -
64
+ idle/compacted handlers no longer rewrite the user's real
65
+ `context-breakdown.log`), T29 added (child / grand-child lifetime). 75 unit
66
+ checks.
67
+
7
68
  ## 1.5.3 — 2026-10-26
8
69
 
9
70
  - **Compaction ceiling aligned with opencode 2.1.4 (#51271).** `lib/limits.js`
package/README.md CHANGED
@@ -13,7 +13,7 @@ arguments, tool results (the outputs fed back into context) and the residual
13
13
  The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
14
14
  generations) plus an optional TUI module (`./tui` entry) that renders a **live
15
15
  sidebar** and a **zero-LLM `/cx` breakdown panel** with measured contributors
16
- and a redacted export. The V2 path is **tested against opencode v2.0.28**; the
16
+ and a redacted export. The V2 path is **tested against opencode 2.2.0**; the
17
17
  V1 path against 1.18.29+.
18
18
 
19
19
  > **V1 is feature-frozen.** It is kept working for compatibility (bug fixes
@@ -70,7 +70,8 @@ For every served request the plugin estimates how the context window is split:
70
70
  duplicate group), reported honestly as *"N redundant tool outputs ≈ X tokens"*
71
71
  — never as "the model wasted effort". Findings are per-session and **path-free
72
72
  in the persisted/exported form**.
73
- * The denominator is the **opencode 2.1.4 compaction ceiling**: `window =
73
+ * The denominator is the **opencode 2.2.0 compaction ceiling** (unchanged since
74
+ 2.1.4): `window =
74
75
  limit.input || limit.context`; `reserve = max(floor(window * 0.15), window >=
75
76
  2 * 16_000 ? 16_000 : 0)`; the ceiling is `window - reserve`. This mirrors
76
77
  `calculateCeiling()` in `packages/core/src/session/compaction.ts`. When opencode
@@ -302,8 +303,9 @@ notice if needed.
302
303
  are also seeded from the snapshot at startup, and the retry backs off
303
304
  (30 s → 5 min, capped). A known `usable`/`limit`/`agent` is never overwritten
304
305
  with `null` when another instance rewrites the snapshot.
305
- * All three commands append their payload to `context-breakdown.log` (final-summaries
306
- section), so it stays recoverable even if delivery fails.
306
+ * All three commands copy their payload into `context-breakdown.log` (final-summaries
307
+ section) while the plugin instance lives, so it stays recoverable even if
308
+ delivery fails (see the log lifetime note under "Log / state file locations").
307
309
  * If `ctx.session.synthetic` throws, the command logs an actionable error and the
308
310
  payload is preserved in the log — there is no other delivery channel in this
309
311
  runtime (no `noReply`/`prompt` option exists).
@@ -405,16 +407,24 @@ and `$TMPDIR` (usually `/tmp`) elsewhere:
405
407
  | File | Purpose |
406
408
  | --- | --- |
407
409
  | `context-breakdown.log` | Human-readable live snapshot + bounded final summaries |
408
- | `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar and the `/cx` panel. Fields per entry: `sessionID`, `parentID`, `role`, `agent`, `model`, `providerID`, `ctx`, `input`, `usable` (compaction ceiling), `reserve` (tokens reserved for compaction), `limit` (context window), `reasoning`, `categories{user,assistant,reasoning,toolArgs,toolOut,system,toolSchemas,other}`, `top` (top-5 heaviest single items, path-free), `previous` (previous distinct measurement for `/context-diff`), `updatedAt`. |
410
+ | `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar and the `/cx` panel. Fields per entry: `sessionID`, `parentID`, `role`, `agent`, `model`, `providerID`, `ctx`, `input`, `usable` (compaction ceiling), `reserve` (tokens reserved for compaction), `limit` (context window), `reasoning`, `categories{user,assistant,reasoning,toolArgs,toolOut,system,toolSchemas,other}`, `toolOutByTool` (per-tool repeated-output estimate), `reclaim` (duplicate tool output, bounded), `cacheRead`/`cacheWrite` (provider-reported prompt-cache tokens), `compactedAt`/`compactStale`, `top` (top-5 heaviest single items, path-free), `previous` (previous distinct measurement for `/context-diff`), `updatedAt`. |
409
411
  | `context-events.log` | Raw event tap — **only** when `DEBUG_EVENTS` is flipped to `true` in the source (off by default) |
410
412
 
411
- The live `context-breakdown.log` snapshot is **rewritten** (never grows); final
412
- summaries (on `session.idle`) and compaction notes are **appended** — log writes
413
- use plain `appendFileSync`, not atomic replacement. Only the machine-readable
414
- `opencode-context-indicator-state.json` is written **atomically** (temp file +
415
- rename), so concurrent plugin instances cannot corrupt it. Cross-instance
416
- duplicate final summaries are prevented separately by an atomic claim marker
417
- (see `lib/dedup.js`).
413
+ The whole `context-breakdown.log` is **rewritten atomically** (temp file +
414
+ rename, like the state file): the live snapshot is always regenerated and the
415
+ final summaries (on `session.idle`) plus compaction notes are re-rendered from
416
+ an in-memory buffer (bounded, last 50 per log), so the file never grows and
417
+ concurrent plugin instances cannot observe a half-written file. A summary-only
418
+ write (idle, compaction, command payload) re-renders the last live snapshot, so
419
+ adding a note never blanks that section. That snapshot may predate a compaction
420
+ or a deleted session - the leading `[<timestamp>]` line is the freshness cue.
421
+ Both parts are **process-local**: they
422
+ survive every rewrite and failed command delivery within the lifetime of the
423
+ running instance, but they are dropped when the plugin instance is re-created
424
+ (opencode restarts, or about 24 h of idling). A different instance that rewrites
425
+ the file keeps its own view of the summary buffer; cross-instance duplicate
426
+ summaries are prevented separately by an atomic claim marker (see
427
+ `lib/dedup.js`).
418
428
 
419
429
  ---
420
430
 
@@ -537,14 +547,17 @@ the sidebar instead.
537
547
  indicator degrades
538
548
  gracefully (the breakdown falls back to throttled `session.messages` fetches
539
549
  and toasts keep working).
540
- * **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
541
- **2.1.4**).
550
+ * **OpenCode ≥ 2.2.0** for the V2 path with the TUI surfaces (built and verified
551
+ against **2.2.0**). The server-only V2 path keeps working on 2.0.16+; the TUI
552
+ module pins the OpenTUI generation it was compiled against, so a host on
553
+ OpenTUI 0.6 (opencode ≤ 2.1.4) will not load the TUI surfaces - neither the
554
+ sidebar nor the `/cx` panel - from a 1.6.x install.
542
555
  * **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
543
556
  ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
544
557
  dependencies resolved by OpenCode at runtime; the main plugin installs and runs
545
558
  fine without them (the sidebar simply is not available). `@opentui/solid` is
546
- instead shipped as a **pinned direct dependency** (exact `0.6.8`, matching
547
- the OpenTUI stack OpenCode 2.1.4 ships): OpenCode's
559
+ instead shipped as a **pinned direct dependency** (exact `0.7.0`, matching
560
+ the OpenTUI stack OpenCode 2.2.0 ships): OpenCode's
548
561
  TUI loader does not expose a host instance of it, so for npm-installed plugins
549
562
  the JSX pragma would otherwise fail to resolve `@opentui/solid/jsx-runtime`
550
563
  (upstream: opencode issue #33884 — `node_modules` plugins are excluded from the
@@ -617,7 +630,7 @@ caller.
617
630
  ### Running tests
618
631
 
619
632
  ```bash
620
- npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1-T28 unit checks
633
+ npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1-T29 unit checks
621
634
  npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
622
635
  npm run test:e2e:quick # E2E load-only, no LLM call
623
636
  npm run test:drift # advisory: warns on non-breaking host drift (also in CI)
@@ -636,12 +649,12 @@ STEP 80 s, ~3 min total.
636
649
 
637
650
  | Step | What | Why |
638
651
  |---|---|---|
639
- | (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically) | no test/node_modules/tsconfig in the tarball |
652
+ | (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically); `docs/` may contain images only | no test/node_modules/tsconfig in the tarball |
640
653
  | (b) pragma regression | esbuild `--jsx=automatic` → `@opentui/solid/jsx-runtime` present, `react/jsx-runtime` absent | a removed/broken pragma kills the sidebar on npm install |
641
654
  | (c) resolve chain | replica install: host-bridged + peer packages resolve; local tui.tsx relative imports resolve via node from the entry (resolve-bridge.cjs) | broken peer/optional dep or a bad local import silently breaks the sidebar |
642
- | (d) unit harness T1-T28 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper, sanitizer/unicode, prompt-cache, waste-detector, compaction-reserve, top-consumer and diff checks (74 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export |
643
- | (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
644
- | (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
655
+ | (d) unit harness T1-T29 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper, sanitizer/unicode, prompt-cache, waste-detector, compaction-reserve, top-consumer, diff and subagent-rollup checks (75 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export, subagent lifetime |
656
+ | (e) corporate-identifier scan | no forbidden identifiers in the published surface (`index.js`, `tui.tsx`, `lib/*`, `README.md`, `CHANGELOG.md`, `package.json`) or the test tree | compliance |
657
+ | (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in the same published surface and test tree (`docs/` is image-only and is not text-scanned) | English-only source rule |
645
658
 
646
659
  **Isolated E2E — `test/e2e-tui.mjs`** (requires the OpenCode CLI)
647
660
 
@@ -655,7 +668,10 @@ The E2E is fully **non-invasive** to your running OpenCode Desktop:
655
668
  - `--standalone` starts a *private* server instead of the background service Desktop uses;
656
669
  - `OPENCODE_DB` points at a throwaway DB in `%TEMP%` (your sessions are untouched);
657
670
  - `OPENCODE_CONTEXT_INDICATOR_STATE_FILE` points at a throwaway state file in `%TEMP%`
658
- — the live `opencode-context-indicator-state.json` is never read or written;
671
+ - the live `opencode-context-indicator-state.json` is never read or written;
672
+ - `OPENCODE_CONTEXT_INDICATOR_LOG_FILE` does the same for the human-readable
673
+ `context-breakdown.log` (used by the unit gate, which drives idle/compacted
674
+ handlers);
659
675
  - `OPENCODE_CONFIG_DIR` points at a throwaway global config written by the script
660
676
  (plugins: this checkout; `update: "disable"`) **for Check A only** — the load
661
677
  source is deterministic and fail-closed instead of following your live config;
Binary file
package/index.js CHANGED
@@ -127,7 +127,8 @@
127
127
  *
128
128
  * - Metric: the native opencode overflow count — tokens.total when present,
129
129
  * else input + output + cache.read + cache.write — compared against the
130
- * model's compaction ceiling (opencode 2.1.4 calculateCeiling): window =
130
+ * model's compaction ceiling (opencode calculateCeiling, unchanged since
131
+ * 2.1.4): window =
131
132
  * limit.input || limit.context, reserve = max(15% of window, 16k when
132
133
  * window >= 32k), usable = window - reserve. Percentages are NOT
133
134
  * clamped at 100% - exceeding the ceiling must stay visible.
package/lib/cache.js CHANGED
@@ -120,9 +120,15 @@ function lastKnownCacheWrite(sessionID) {
120
120
  // Clear per-session tracking. Full wipe on session.idle / session.deleted;
121
121
  // on session.compacted (keepToastDedup=true) the toast-dedup entries survive
122
122
  // the cleanup so consecutive compacted events still dedup.
123
+ //
124
+ // `keepSubagentRollup` preserves THIS session's parent attribution and token
125
+ // totals. A finished subagent goes idle while its parent is still running; the
126
+ // parent's live roll-up must keep counting that child, so the V2 idle handler
127
+ // passes `true` for children and lets the parent's own idle/deleted wipe them
128
+ // (see clearChildSubagentTracking).
123
129
  // ---------------------------------------------------------------------------
124
130
 
125
- function clearSessionTracking(sessionID, keepToastDedup = false) {
131
+ function clearSessionTracking(sessionID, keepToastDedup = false, keepSubagentRollup = false) {
126
132
  if (!sessionID) return;
127
133
  lastShown.delete(sessionID);
128
134
  breakdownCache.delete(sessionID);
@@ -134,7 +140,7 @@ function clearSessionTracking(sessionID, keepToastDedup = false) {
134
140
  lastCompactedToast.delete(sessionID);
135
141
  }
136
142
  // Attribution cache (re-resolved on the next toast if needed).
137
- sessionParentCache.delete(sessionID);
143
+ if (!keepSubagentRollup) sessionParentCache.delete(sessionID);
138
144
  // Idle-summary caches were previously left dirty — clean them too.
139
145
  lastKnownModelCache.delete(sessionID);
140
146
  lastKnownProviderCache.delete(sessionID);
@@ -145,7 +151,7 @@ function clearSessionTracking(sessionID, keepToastDedup = false) {
145
151
  lastKnownCacheWriteCache.delete(sessionID);
146
152
  lastBdWrite.delete(sessionID);
147
153
  // V2 subagent accumulation for this session.
148
- sessionTotals.delete(sessionID);
154
+ if (!keepSubagentRollup) sessionTotals.delete(sessionID);
149
155
  // V2 agent label + context-fallback throttle (previously left dirty).
150
156
  sessionAgentCache.delete(sessionID);
151
157
  v2ContextFetchTimes.delete(sessionID);
@@ -155,6 +161,33 @@ function clearSessionTracking(sessionID, keepToastDedup = false) {
155
161
  lastCompactionHookAt.delete(sessionID);
156
162
  }
157
163
 
164
+ // Drop the roll-up entries of every descendant of `rootID` (attribution plus
165
+ // accumulated totals). Called when a session itself goes idle or is deleted:
166
+ // until then, finished children stay in the cache so the live roll-up keeps
167
+ // reporting them instead of collapsing to "0 session(s)" the moment a child
168
+ // finishes. Breadth-first, because a sub-subagent hangs off a child and a
169
+ // single-level sweep would leave it as a permanent orphan. Deletion during Map
170
+ // iteration is well-defined in JS.
171
+ function clearChildSubagentTracking(rootID) {
172
+ if (!rootID) return;
173
+ const queue = [rootID];
174
+ const seen = new Set(queue);
175
+ while (queue.length > 0) {
176
+ const parent = queue.shift();
177
+ for (const [childID, parentID] of sessionParentCache) {
178
+ if (parentID !== parent) continue;
179
+ // Full per-session wipe, not just the two roll-up maps: the child's
180
+ // lastKnown*/breakdown/tool-schema caches would otherwise linger until
181
+ // FIFO eviction and never be updated again.
182
+ clearSessionTracking(childID);
183
+ if (!seen.has(childID)) {
184
+ seen.add(childID);
185
+ queue.push(childID);
186
+ }
187
+ }
188
+ }
189
+ }
190
+
158
191
  export {
159
192
  MAX_TRACKED_SESSIONS,
160
193
  BREAKDOWN_THROTTLE_MS,
@@ -190,4 +223,5 @@ export {
190
223
  lastKnownCacheRead,
191
224
  lastKnownCacheWrite,
192
225
  clearSessionTracking,
226
+ clearChildSubagentTracking,
193
227
  };
package/lib/limits.js CHANGED
@@ -7,7 +7,8 @@
7
7
  // hydrateModelLimitsFromState() seeds the maps from the shared state.json
8
8
  // snapshot via ./state.js. Extracted 1:1 (code + WHY comments) from index.js.
9
9
  //
10
- // Compaction ceiling (opencode 2.1.4): opencode's core calculateCeiling() in
10
+ // Compaction ceiling (opencode calculateCeiling, unchanged since 2.1.4 and the
11
+ // same in 2.2.0): opencode's core calculateCeiling() in
11
12
  // packages/core/src/session/compaction.ts derives the auto-compaction trigger
12
13
  // ceiling as window - reserve, where window = limit.input || limit.context
13
14
  // (null/zero falls through to the context window) and
@@ -79,8 +80,9 @@ function getModelLimit(providerID, modelID) {
79
80
  return typeof rec.context === "number" && rec.context > 0 ? rec.context : null;
80
81
  }
81
82
 
82
- // Compute the reserve for a given window, matching opencode 2.1.4
83
- // packages/core/src/session/compaction.ts calculateCeiling(). Returns null when
83
+ // Compute the reserve for a given window, matching opencode's
84
+ // packages/core/src/session/compaction.ts calculateCeiling() (unchanged since
85
+ // 2.1.4). Returns null when
84
86
  // window is not a finite positive number.
85
87
  const RESERVE_SHARE = 0.15; // core: RESERVE_SHARE
86
88
  const RESERVE_MIN = 16_000; // core: RESERVE_MIN
@@ -110,7 +112,7 @@ function reserveFor(window, bufferOverride) {
110
112
  );
111
113
  }
112
114
 
113
- // Native opencode compaction ceiling (2.1.4 calculateCeiling):
115
+ // Native opencode compaction ceiling (calculateCeiling, unchanged since 2.1.4):
114
116
  // window = limit.input || limit.context (zero/null falls through to context)
115
117
  // reserve = reserveFor(window) [or bufferOverride when provided]
116
118
  // usable = window - reserve
package/lib/state.js CHANGED
@@ -52,7 +52,11 @@ const MAX_FINAL_SUMMARIES = 50; // bound the in-memory final-summary log
52
52
  const DEBUG_EVENTS = false;
53
53
  const EVENT_LOG_MAX_BYTES = 1024 * 1024; // ~1MB truncate threshold
54
54
 
55
- const LOG_FILE = join(tmpdir(), "context-breakdown.log");
55
+ // Human-readable live log. Overridable via env for test isolation (same
56
+ // contract as STATE_FILE); in production the env is never set.
57
+ const LOG_FILE =
58
+ process.env.OPENCODE_CONTEXT_INDICATOR_LOG_FILE ||
59
+ join(tmpdir(), "context-breakdown.log");
56
60
 
57
61
  // Machine-readable snapshot consumed by the optional TUI sidebar (./tui.tsx,
58
62
  // package entry "./tui"). Written next to each log update — same moment, same
@@ -149,12 +153,27 @@ function renderFile(liveLines) {
149
153
  // Atomically rewrite the whole log file (bounded by design). The temp+rename
150
154
  // makes concurrent writers from multiple plugin instances safe: a reader sees
151
155
  // either the previous or the new file, never a half-written one.
156
+ //
157
+ // The last rendered live snapshot is remembered: callers that only add a final
158
+ // summary / compaction note pass no live lines, and an empty snapshot section
159
+ // after every idle would make the log useless.
160
+ let lastLiveLines = [];
161
+
152
162
  function writeLog(liveLines) {
153
163
  try {
154
- const content = renderFile(liveLines);
164
+ if (Array.isArray(liveLines) && liveLines.length > 0) {
165
+ lastLiveLines = liveLines;
166
+ }
167
+ const content = renderFile(
168
+ Array.isArray(liveLines) && liveLines.length > 0 ? liveLines : lastLiveLines,
169
+ );
155
170
  // Random temp suffix + exclusive create ("wx"): a predictable name could be
156
171
  // pre-created as a symlink for us to follow (CWE-377). "wx" fails instead.
157
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.
158
177
  // Create BEFORE the finally-guarded section (mirrors writeStateFile): on an
159
178
  // (astronomically unlikely) EEXIST collision with a peer's temp, THEIR file
160
179
  // must not be cleaned up by us — the write fails and is logged instead.
package/lib/tui-panel.tsx CHANGED
@@ -53,6 +53,74 @@ type PanelInput = {
53
53
  // (never once per poll) when it is unavailable and hide the whole section.
54
54
  let warnedMessagesUnavailable = false
55
55
 
56
+ // Cheap change detector for the live message list. The host hands back a fresh
57
+ // array on every poll, so identity cannot be used as a memo key, while walking
58
+ // the whole list (per-character estimates + JSON.stringify of tool args) once
59
+ // per 1000 ms tick is exactly the cost we want to avoid. Length + last message
60
+ // id + created stamp + last message payload size covers appends, streaming and
61
+ // tool completion; an in-place edit of an OLDER message lands one tick later,
62
+ // when the tail changes. Known blind spot by design: the size is a SUM, so a
63
+ // same-length substitution inside the last message is not detected.
64
+ function listSignature(list: unknown[]): string {
65
+ const n = list.length
66
+ if (n === 0) return "0"
67
+ const last: unknown = list[n - 1]
68
+ if (last == null || typeof last !== "object") return `${n}:?`
69
+ const rec = last as Record<string, unknown>
70
+ const id = typeof rec.id === "string" ? rec.id : ""
71
+ const time = rec.time
72
+ const created =
73
+ time != null && typeof time === "object"
74
+ ? String((time as Record<string, unknown>).created ?? "")
75
+ : ""
76
+ let size = typeof rec.text === "string" ? rec.text.length : 0
77
+ const parts: unknown[] = Array.isArray(rec.content) ? rec.content : []
78
+ for (const raw of parts) {
79
+ if (raw == null || typeof raw !== "object") continue
80
+ const part = raw as Record<string, unknown>
81
+ if (typeof part.text === "string") {
82
+ size += part.text.length
83
+ continue
84
+ }
85
+ const state = part.state
86
+ if (state == null || typeof state !== "object") continue
87
+ const st = state as Record<string, unknown>
88
+ if (typeof st.input === "string") size += st.input.length
89
+ else if (st.input != null) {
90
+ try {
91
+ size += JSON.stringify(st.input).length
92
+ } catch {
93
+ /* unstringifiable input contributes no size */
94
+ }
95
+ }
96
+ // Tool RESULTS matter as much as args: measureContributors reads
97
+ // state.content, and a call that finishes between two ticks changes nothing
98
+ // else on the message. Without this the measured section would freeze for
99
+ // the whole remainder of the turn.
100
+ const content = st.content
101
+ if (typeof content === "string") {
102
+ size += content.length
103
+ } else if (Array.isArray(content)) {
104
+ for (const rawItem of content) {
105
+ if (rawItem == null || typeof rawItem !== "object") continue
106
+ const item = rawItem as Record<string, unknown>
107
+ if (typeof item.text === "string") {
108
+ size += item.text.length
109
+ } else {
110
+ // Non-text parts (e.g. file refs with long/base64 URIs) are not
111
+ // measured by measureContributors, so their marker is enough: never
112
+ // JSON.stringify a potentially megabyte-sized reference per tick.
113
+ size += 1
114
+ }
115
+ }
116
+ }
117
+ // Status transitions (pending -> completed/error) are cheap and always
118
+ // accompany a visible change.
119
+ if (typeof st.status === "string") size += st.status.length
120
+ }
121
+ return `${n}:${id}:${created}:${size}`
122
+ }
123
+
56
124
  // Panel column layout (monospace), derived from the host-provided panel width
57
125
  // so cells never collide on narrow terminals. Gaps between columns keep a
58
126
  // truncated cell visually separated from its neighbour.
@@ -253,14 +321,38 @@ export function BreakdownPanel(props: {
253
321
  }
254
322
  })
255
323
 
324
+ // Memo for the measured contributors section: the signature/key pair keeps
325
+ // the expensive walk out of the per-tick path (see listSignature).
326
+ let contributorKey: string | null = null
327
+ let contributorValue: Contributor[] = []
328
+
256
329
  const contributors = createMemo<Contributor[]>(() => {
257
330
  const m = messages()
258
- if (!m.available) return []
331
+ if (!m.available) {
332
+ contributorKey = null
333
+ contributorValue = []
334
+ return contributorValue
335
+ }
336
+ // Re-measure ONLY when the message list actually changed. The host rebuilds
337
+ // the array (and `props.snapshot()` ticks) every second, so keying on array
338
+ // identity would re-walk every message — per-character token estimates plus
339
+ // JSON.stringify of tool args — once per tick on the render thread.
340
+ let key: string
341
+ try {
342
+ key = listSignature(m.list)
343
+ } catch {
344
+ key = ""
345
+ }
346
+ if (key && key === contributorKey) return contributorValue
259
347
  try {
260
- return measureContributors(m.list)
348
+ contributorValue = measureContributors(m.list)
349
+ contributorKey = key || contributorKey
261
350
  } catch {
262
- return []
351
+ // Transient failure: leave the key unset so the next tick retries.
352
+ contributorValue = []
353
+ contributorKey = null
263
354
  }
355
+ return contributorValue
264
356
  })
265
357
 
266
358
  // Redacted export (key `e`). No message text, no full session id, no tool
package/lib/v2.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  import {
25
25
  BREAKDOWN_THROTTLE_MS,
26
26
  breakdownCache,
27
+ clearChildSubagentTracking,
27
28
  clearSessionTracking,
28
29
  ERROR_TOAST_DEDUP_MS,
29
30
  lastBdWrite,
@@ -833,6 +834,10 @@ async function handleV2Event(ctx, event) {
833
834
  // stale entry lingers, via the phantom filter — a deleted session has no
834
835
  // step). No dedicated in-memory index to clean up.
835
836
  clearSessionTracking(sid);
837
+ // Whatever this session was, its own descendants must go too: a deleted
838
+ // parent takes the whole subtree, and a deleted child takes its own
839
+ // sub-subagents (they would otherwise never be swept).
840
+ clearChildSubagentTracking(sid);
836
841
  return;
837
842
  }
838
843
 
@@ -922,7 +927,13 @@ async function handleV2Event(ctx, event) {
922
927
  } catch (err) {
923
928
  logErr("v2 final summary on idle failed", err);
924
929
  }
925
- clearSessionTracking(sid);
930
+ // Children keep their parent attribution + totals while the parent runs:
931
+ // otherwise the very next live line reports "subagents 0" right after a
932
+ // subagent finishes. A root idle sweeps the whole child set instead.
933
+ const parentID = sessionParentCache.get(sid);
934
+ const isSubagent = parentID != null;
935
+ clearSessionTracking(sid, false, isSubagent);
936
+ if (!isSubagent) clearChildSubagentTracking(sid);
926
937
  return;
927
938
  }
928
939
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.5.3",
3
+ "version": "1.6.0",
4
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.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,6 +12,7 @@
12
12
  "index.js",
13
13
  "lib/",
14
14
  "tui.tsx",
15
+ "docs/",
15
16
  "README.md",
16
17
  "CHANGELOG.md",
17
18
  "LICENSE"
@@ -61,11 +62,11 @@
61
62
  }
62
63
  },
63
64
  "dependencies": {
64
- "@opentui/solid": "0.6.8"
65
+ "@opentui/solid": "0.7.0"
65
66
  },
66
67
  "devDependencies": {
67
- "@opencode/plugin": "2.1.4",
68
- "@opentui/core": "0.6.8",
68
+ "@opencode/plugin": "2.2.0",
69
+ "@opentui/core": "0.7.0",
69
70
  "@types/node": "26.6.3",
70
71
  "esbuild": "^0.28.2",
71
72
  "node-pty": "^1.1.0",
package/tui.tsx CHANGED
@@ -38,9 +38,11 @@
38
38
  *
39
39
  * Runtime requirements: @opentui/core and solid-js are optional peers resolved
40
40
  * by OpenCode at runtime. @opentui/solid is a pinned direct dependency (exact
41
- * 0.6.8): OpenCode's TUI loader does not expose a host instance of it, so for
42
- * npm-installed plugins the JSX pragma would otherwise fail to resolve
43
- * `@opentui/solid/jsx-runtime` (upstream opencode issue #33884).
41
+ * 0.7.0, the version opencode 2.2.0 ships): OpenCode's TUI loader does not
42
+ * expose a host instance of it, so for npm-installed plugins the JSX pragma
43
+ * would otherwise fail to resolve `@opentui/solid/jsx-runtime` (upstream
44
+ * opencode issue #33884). @opencode/plugin 2.2.0 declares OpenTUI 0.7.0 as its
45
+ * peer, so a plugin left on 0.6.x no longer installs against that host.
44
46
  *
45
47
  * Graceful mode: when this file is loaded from an npm install (its own path is
46
48
  * inside a `node_modules` directory), OpenCode skips the host Solid transform