opencode-context-indicator 1.1.2 → 1.1.4

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.
Files changed (4) hide show
  1. package/README.md +67 -0
  2. package/index.js +210 -10
  3. package/package.json +17 -4
  4. package/tui.tsx +3 -1
package/README.md CHANGED
@@ -196,6 +196,13 @@ back to a plain notice if needed.
196
196
  with no category data (no cache/snapshot and the fallback failed or ran out of
197
197
  budget) is tagged `no-data`; `ctx` / `model` / `updatedAt` still come from
198
198
  `state.json` / the live lookup, while its category cells stay `n/a`.
199
+ * **The denominator survives a transient model-registry miss.** If the serving
200
+ instance's `ctx.model.list()` returns no usable limits (freshly loaded instance,
201
+ provider not yet registered, malformed payload), the window is recovered from
202
+ the `state.json` snapshot instead of degrading the `ctx` column to `?`; limits
203
+ are also seeded from the snapshot at startup, and the retry backs off
204
+ (30 s → 5 min, capped). A known `usable`/`limit`/`agent` is never overwritten
205
+ with `null` when another instance rewrites the snapshot.
199
206
  * Both commands append their payload to `context-breakdown.log` (final-summaries
200
207
  section), so it stays recoverable even if delivery fails.
201
208
  * If `ctx.session.synthetic` throws, the command logs an actionable error and the
@@ -438,6 +445,66 @@ caller.
438
445
 
439
446
  ---
440
447
 
448
+ ## Development / Testing
449
+
450
+ ### Running tests
451
+
452
+ ```bash
453
+ npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T12 unit
454
+ npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
455
+ npm run test:e2e:quick # E2E load-only, no LLM call
456
+ ```
457
+
458
+ `prepublishOnly` runs **both** gates automatically on `npm publish`
459
+ (`node test/prepublish.mjs && node test/e2e-tui.mjs --full`) and is skipped with
460
+ `--ignore-scripts`. The whole chain takes roughly **1 minute** (fast gate ~20 s,
461
+ isolated E2E ~10 s warm / up to ~90 s cold); the E2E is bounded by INIT 45 s +
462
+ STEP 80 s, ~3 min total.
463
+
464
+ ### What the gates check
465
+
466
+ **Fast gate — `test/prepublish.mjs`**
467
+
468
+ | Step | What | Why |
469
+ |---|---|---|
470
+ | (a) tarball composition | `package.json` `files[]` field expands to exactly 6 members | no test/node_modules/tsconfig in the tarball |
471
+ | (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 |
472
+ | (c) resolve chain | replica install → `import.meta.resolve` for all tui.tsx imports | broken peer/optional dep silently breaks the sidebar |
473
+ | (d) unit harness T1–T12 | all limit/agent/state fix assertions | regressions in modelLimits, writeStateFile, hydrate |
474
+
475
+ **Isolated E2E — `test/e2e-tui.mjs`** (requires the OpenCode CLI)
476
+
477
+ | Check | What | Why |
478
+ |---|---|---|
479
+ | A — plugin load | spins a private `opencode --standalone` PTY, asserts `msg="loading plugin" → opencode-context-indicator` and that the `file:///` entrypoint is **this checkout** | catches a plugin that no longer imports cleanly, or a config pointing at the published npm copy |
480
+ | B — live update | one `opencode run "Say OK" --standalone` round-trip; asserts the LLM answered **and** the isolated `state.json` gained a session with `ctx > 0` | catches a regression in the `session.idle` → `writeStateFile` path |
481
+
482
+ The E2E is fully **non-invasive** to your running OpenCode Desktop:
483
+
484
+ - `--standalone` starts a *private* server instead of the background service Desktop uses;
485
+ - `OPENCODE_DB` points at a throwaway DB in `%TEMP%` (your sessions are untouched);
486
+ - `OPENCODE_CONTEXT_INDICATOR_STATE_FILE` points at a throwaway state file in `%TEMP%`
487
+ — the live `opencode-context-indicator-state.json` is never read or written;
488
+ - `OPENCODE_DISABLE_PROJECT_CONFIG=1` avoids project-level config;
489
+ - exactly **one** minimal LLM call (`Say OK`) is made, in the isolated session.
490
+
491
+ The script prints `[e2e] running against ISOLATED opencode instance — your running
492
+ sessions are not touched` at start and reports the Desktop service/GUI PIDs before
493
+ and after the run. Override the binary with `OPENCODE_CLI`. The full run needs a
494
+ model for the one round-trip: set `OPENCODE_E2E_MODEL` to *your* `provider/model`
495
+ id (e.g. `OPENCODE_E2E_MODEL=myprovider/some-model`); it is **required** in full
496
+ mode and the script exits with an instruction if it is unset. `--quick` needs no
497
+ model.
498
+
499
+ ### CI
500
+
501
+ `.github/workflows/ci.yml` runs `test/prepublish.mjs` on every push/PR and additionally
502
+ does a standalone esbuild transpile check (pragmas, no React). The Node-pty E2E is not
503
+ run in CI (requires the OpenCode Desktop binary) and is skipped gracefully when the CLI
504
+ is absent (`[SKIP] opencode CLI not found`).
505
+
506
+ ---
507
+
441
508
  ## License
442
509
 
443
510
  [MIT](./LICENSE)
package/index.js CHANGED
@@ -177,7 +177,8 @@ const CRITICAL_THROTTLE_MS = 10000; // min gap between critical (>90%) toasts
177
177
  const ERROR_TOAST_DEDUP_MS = 60000; // same error -> at most one toast / window
178
178
  const COMPACTED_TOAST_DEDUP_MS = 60000; // compacted-toast dedup window
179
179
  const PROVIDERS_TTL_MS = 5 * 60 * 1000; // providers()/model.list() cache TTL after SUCCESS
180
- const PROVIDERS_FAIL_TTL_MS = 30 * 1000; // retry-no-sooner gate after FAILURE
180
+ const PROVIDERS_FAIL_TTL_MS = 30 * 1000; // retry-no-sooner base after FAILURE (doubles per consecutive miss)
181
+ const V2_LIMITS_MAX_BACKOFF_MS = 5 * 60 * 1000; // cap for the model.list retry backoff
181
182
  const PROVIDERS_FETCH_TIMEOUT_MS = 2000; // hard cap for one providers() call (V1)
182
183
  const V2_MODEL_FETCH_TIMEOUT_MS = 8000; // hard cap for one ctx.model.list() call (V2)
183
184
  const TOAST_MS_ERROR = 8000; // duration for error / overflow toasts
@@ -194,7 +195,13 @@ const LOG_FILE = join(tmpdir(), "context-breakdown.log");
194
195
  // Machine-readable snapshot consumed by the optional TUI sidebar (./tui.tsx,
195
196
  // package entry "./tui"). Written next to each log update — same moment, same
196
197
  // data. See writeStateFile() below.
197
- const STATE_FILE = join(tmpdir(), "opencode-context-indicator-state.json");
198
+ // State file path — overridable via env for test isolation.
199
+ // In production the env is never set, so behaviour is unchanged.
200
+ // In tests: export OPENCODE_CONTEXT_INDICATOR_STATE_FILE=/tmp/test-state.json
201
+ // and the harness writes a controlled seed; no live file is touched.
202
+ const STATE_FILE =
203
+ process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
204
+ join(tmpdir(), "opencode-context-indicator-state.json");
198
205
 
199
206
  // Separate tap log for raw events (written only when DEBUG_EVENTS is true).
200
207
  const EVENT_LOG_FILE = join(tmpdir(), "context-events.log");
@@ -278,6 +285,7 @@ let providersInFlight = null;
278
285
  // absolute tokens (as in V1).
279
286
  let v2LimitsLoadedAt = 0;
280
287
  let v2LimitsFailAt = 0;
288
+ let v2LimitsFailCount = 0; // consecutive soft-failures -> exponential retry backoff
281
289
  let v2LimitsInFlight = null;
282
290
 
283
291
  // Bounded list of final summaries written on session.idle
@@ -472,6 +480,14 @@ function writeStateFile(
472
480
  const prevEntry = all[sessionID] || null;
473
481
  const pc = all[sessionID]?.categories || null;
474
482
 
483
+ // Same model AND provider? Only then is a previously persisted denominator
484
+ // meaningful — a session that switched model (or provider, even with the
485
+ // same modelID) must not inherit the old window.
486
+ const sameModelAsPrev =
487
+ prevEntry != null &&
488
+ prevEntry.model === (modelID || "unknown") &&
489
+ (prevEntry.providerID || "") === (providerID || "");
490
+
475
491
  const catUser = bd ? bd.userChars || 0 : pc?.user ?? 0;
476
492
  const catAssistant = bd ? bd.assistantChars || 0 : pc?.assistant ?? 0;
477
493
  const catReasoning = bd ? bd.reasoningChars || 0 : pc?.reasoning ?? 0;
@@ -504,13 +520,23 @@ function writeStateFile(
504
520
  sessionID,
505
521
  parentID,
506
522
  role: parentID ? "sub" : "main",
507
- agent: sessionAgentCache.get(sessionID) || null,
523
+ agent: sessionAgentCache.get(sessionID) || prevEntry?.agent || null,
508
524
  model: modelID || "unknown",
509
525
  providerID: providerID || "",
510
526
  ctx: total,
511
527
  input: inputTokens || 0,
512
- usable: denom,
513
- limit: limit != null ? limit : null,
528
+ // Do NOT clobber a known denominator with null: multiple plugin instances
529
+ // share this file and one of them may lack modelLimits (its ctx.model.list
530
+ // returned nothing). Keep the previous model's known value when the live
531
+ // lookup came back empty, so a transient miss never becomes permanent.
532
+ usable:
533
+ denom != null ? denom : sameModelAsPrev ? prevEntry.usable ?? null : null,
534
+ limit:
535
+ limit != null
536
+ ? limit
537
+ : sameModelAsPrev && typeof prevEntry.limit === "number"
538
+ ? prevEntry.limit
539
+ : null,
514
540
  reasoning: typeof reasoningTokens === "number" ? reasoningTokens : 0,
515
541
  categories: {
516
542
  user: catUser,
@@ -1916,7 +1942,14 @@ async function ensureV2Breakdown(ctx, sessionID) {
1916
1942
  async function ensureModelLimits(ctx) {
1917
1943
  const now = Date.now();
1918
1944
  if (now - v2LimitsLoadedAt < PROVIDERS_TTL_MS) return; // fresh success
1919
- if (now - v2LimitsFailAt < PROVIDERS_FAIL_TTL_MS) return; // recent failure
1945
+ // Recent failure: back off exponentially (30s, 60s, 120s … capped at 5 min)
1946
+ // so a registry that is slow to expose the provider is retried without
1947
+ // hammering it, while a one-off blip still recovers quickly.
1948
+ const failBackoff = Math.min(
1949
+ PROVIDERS_FAIL_TTL_MS * Math.pow(2, Math.max(0, v2LimitsFailCount - 1)),
1950
+ V2_LIMITS_MAX_BACKOFF_MS,
1951
+ );
1952
+ if (now - v2LimitsFailAt < failBackoff) return; // recent failure
1920
1953
  if (v2LimitsInFlight) return v2LimitsInFlight; // dedup parallel callers
1921
1954
  v2LimitsInFlight = (async () => {
1922
1955
  try {
@@ -1927,7 +1960,13 @@ async function ensureModelLimits(ctx) {
1927
1960
  );
1928
1961
  const models = Array.isArray(res) ? res : res?.data;
1929
1962
  if (!Array.isArray(models)) {
1963
+ v2LimitsFailCount++;
1930
1964
  v2LimitsFailAt = Date.now(); // malformed payload -> retry soon
1965
+ const seeded = hydrateModelLimitsFromState();
1966
+ logErr(
1967
+ "model.list returned no model array — limits unavailable, will retry",
1968
+ { payloadType: typeof res, hydratedFromState: seeded },
1969
+ );
1931
1970
  return;
1932
1971
  }
1933
1972
  let found = 0;
@@ -1964,11 +2003,36 @@ async function ensureModelLimits(ctx) {
1964
2003
  // An empty / limit-less payload right after boot (registry not ready yet)
1965
2004
  // must NOT be cached as success, or limits would stay unknown for the
1966
2005
  // full TTL — treat it as a soft failure and retry after the fail gate.
1967
- if (found > 0) v2LimitsLoadedAt = Date.now();
1968
- else v2LimitsFailAt = Date.now();
2006
+ if (found > 0) {
2007
+ v2LimitsLoadedAt = Date.now();
2008
+ v2LimitsFailCount = 0; // recovered
2009
+ } else {
2010
+ // Soft failure: the registry answered but carried no usable limit for
2011
+ // any model (provider not registered yet, or `limit` absent). Log WHY
2012
+ // once per backoff window, and fall back to the shared snapshot so
2013
+ // denominators do not vanish meanwhile.
2014
+ const withLimit = models.filter(
2015
+ (m) => m?.limit && typeof m.limit === "object",
2016
+ ).length;
2017
+ const withProvider = models.filter(
2018
+ (m) => m?.providerID || m?.provider?.id,
2019
+ ).length;
2020
+ const seeded = hydrateModelLimitsFromState();
2021
+ v2LimitsFailCount++;
2022
+ v2LimitsFailAt = Date.now();
2023
+ logErr(
2024
+ "model.list yielded 0 usable limits — retrying",
2025
+ { models: models.length, withLimit, withProvider, hydratedFromState: seeded },
2026
+ );
2027
+ }
1969
2028
  } catch (err) {
1970
- v2LimitsFailAt = Date.now(); // failed attempt -> short retry gate
1971
- logErr("model.list failed", err);
2029
+ v2LimitsFailCount++;
2030
+ v2LimitsFailAt = Date.now(); // failed attempt -> backoff gate
2031
+ const seeded = hydrateModelLimitsFromState();
2032
+ logErr(
2033
+ `model.list failed (hydrated ${seeded} limit(s) from state.json)`,
2034
+ err,
2035
+ );
1972
2036
  } finally {
1973
2037
  v2LimitsInFlight = null;
1974
2038
  }
@@ -2377,6 +2441,115 @@ function readStateSnapshot() {
2377
2441
  }
2378
2442
  }
2379
2443
 
2444
+ // Seed `modelLimits` from previously persisted state.json entries. state.json is
2445
+ // a SHARED cross-instance snapshot rewritten at every live step, so it carries a
2446
+ // model's window even when THIS instance's ctx.model.list() came back empty (a
2447
+ // cold instance, or one whose model registry did not expose the provider).
2448
+ // Without this, a transient list miss degraded every denominator to "?" — and
2449
+ // because writeStateFile used to rewrite usable/limit from the (empty) in-memory
2450
+ // map, the degradation became permanent.
2451
+ //
2452
+ // The snapshot also holds history: during an earlier outage some instances wrote
2453
+ // a WRONG usable window (e.g. 168000 instead of 1015808). A naive
2454
+ // first-match seed would then re-inject that stale garbage. Therefore, per
2455
+ // `<provider>:<model>`:
2456
+ // * candidates are sanity-filtered (drop `usable >= limit`, `usable <= 0`);
2457
+ // * when any candidate carries a precise `usable`, only those are considered;
2458
+ // * the FRESHEST (max `updatedAt`) candidate wins;
2459
+ // * a divergent spread (max > 3× min usable) is logged for diagnosis.
2460
+ // Strictly additive: real in-memory records always win (never overwritten).
2461
+ // Returns the number of records seeded.
2462
+ function hydrateModelLimitsFromState() {
2463
+ try {
2464
+ const all = readStateSnapshot();
2465
+ // Group candidate entries by key first, so "freshest" is decided against
2466
+ // ALL records of that model — not the first one encountered.
2467
+ const byKey = new Map(); // key -> [ { usable, limit, updatedAt } ]
2468
+ for (const entry of Object.values(all)) {
2469
+ const providerID =
2470
+ typeof entry?.providerID === "string" ? entry.providerID : "";
2471
+ const modelID = entry?.model;
2472
+ const key = modelKey(providerID, modelID);
2473
+ if (!key || !modelID || modelID === "unknown") continue;
2474
+ if (modelLimits.has(key)) continue; // a real in-memory record always wins
2475
+ const usable =
2476
+ typeof entry?.usable === "number" && entry.usable > 0
2477
+ ? entry.usable
2478
+ : null;
2479
+ const limit =
2480
+ typeof entry?.limit === "number" && entry.limit > 0 ? entry.limit : null;
2481
+ if (usable == null && limit == null) continue;
2482
+ let arr = byKey.get(key);
2483
+ if (!arr) {
2484
+ arr = [];
2485
+ byKey.set(key, arr);
2486
+ }
2487
+ arr.push({
2488
+ usable,
2489
+ limit,
2490
+ updatedAt: typeof entry?.updatedAt === "string" ? entry.updatedAt : "",
2491
+ });
2492
+ }
2493
+
2494
+ let added = 0;
2495
+ for (const [key, candidates] of byKey) {
2496
+ // (b) Sanity: drop impossible records (usable >= limit, usable <= 0).
2497
+ // Candidates without a usable (context-only) are kept as-is.
2498
+ const sane = candidates.filter(
2499
+ (c) =>
2500
+ c.usable == null ||
2501
+ (c.usable > 0 && (c.limit == null || c.usable <= c.limit)),
2502
+ );
2503
+ if (sane.length === 0) continue; // every record was impossible -> skip
2504
+
2505
+ // (a) Prefer the precise variant (a recorded usable) when any exists.
2506
+ const precise = sane.filter((c) => c.usable != null);
2507
+ const pool = precise.length > 0 ? precise : sane;
2508
+
2509
+ // (1) Freshest wins (ISO timestamps compare lexicographically).
2510
+ let best = pool[0];
2511
+ for (const c of pool) {
2512
+ if (String(c.updatedAt) > String(best.updatedAt)) best = c;
2513
+ }
2514
+
2515
+ // (c) Diagnostic: a >3× spread means the snapshot is polluted by a
2516
+ // limited/fallback instance — log the numbers once for this model.
2517
+ const usables = pool.map((c) => c.usable).filter((u) => u != null);
2518
+ if (usables.length > 1) {
2519
+ const max = Math.max(...usables);
2520
+ const min = Math.min(...usables);
2521
+ if (min > 0 && max / min > 3) {
2522
+ logErr(
2523
+ `limit seed spread for ${key} — using freshest`,
2524
+ { min, max, candidates: pool.length, chosen: best.usable ?? best.limit },
2525
+ );
2526
+ }
2527
+ }
2528
+
2529
+ // Build the record: precise usable-as-input when known (so getUsableContext
2530
+ // re-derives EXACTLY the persisted window), else the raw context window.
2531
+ let rec = null;
2532
+ if (best.usable != null) {
2533
+ rec = { context: best.limit, input: best.usable, output: null, reserved: 0 };
2534
+ } else if (best.limit != null) {
2535
+ rec = { context: best.limit, input: null, output: null, reserved: null };
2536
+ }
2537
+ if (rec == null) continue;
2538
+ modelLimits.set(key, rec);
2539
+ added++;
2540
+ // (3) One seed line per model: chosen value + provenance.
2541
+ console.log(
2542
+ `[context-indicator] limit seed ${key} -> usable=${
2543
+ best.usable != null ? best.usable : best.limit
2544
+ } (from ${best.updatedAt || "unknown"}, ${pool.length} candidates)`,
2545
+ );
2546
+ }
2547
+ return added;
2548
+ } catch {
2549
+ return 0;
2550
+ }
2551
+ }
2552
+
2380
2553
  // True when a state.json entry carries no measured tokens at all: a session
2381
2554
  // registered (session.created) but with no served step. `writeStateFile` always
2382
2555
  // persists `categories` as an object, so the emptiness test must SUM those
@@ -2767,6 +2940,29 @@ async function commandContextBreakdown(ctx, invocation, opts) {
2767
2940
  }
2768
2941
  }
2769
2942
 
2943
+ // ---------------------------------------------------------------------------
2944
+ // Named exports for the prepublish unit harness (test/prepublish.mjs).
2945
+ // These are module-level const variables that the harness needs to mutate
2946
+ // (modelLimits, sessionAgentCache, etc.) and functions it needs to call
2947
+ // directly (hydrateModelLimitsFromState, writeStateFile, etc.).
2948
+ // The plugin itself works without any of these — they are additive.
2949
+ // ---------------------------------------------------------------------------
2950
+ export {
2951
+ modelLimits,
2952
+ sessionAgentCache,
2953
+ sessionParentCache,
2954
+ writeStateFile,
2955
+ hydrateModelLimitsFromState,
2956
+ getUsableContext,
2957
+ getModelLimit,
2958
+ collectSessionReport,
2959
+ renderSessionRow,
2960
+ shortSummaryText,
2961
+ shortSummaryDescription,
2962
+ ensureModelLimits,
2963
+ readStateSnapshot,
2964
+ };
2965
+
2770
2966
  // ---------------------------------------------------------------------------
2771
2967
  // Entrypoints
2772
2968
  // ---------------------------------------------------------------------------
@@ -2796,6 +2992,10 @@ export default {
2796
2992
  // to the generic 200k default), so the transform is not used for limit
2797
2993
  // population.
2798
2994
 
2995
+ // Seed limits from the shared snapshot FIRST: another instance may already
2996
+ // have resolved them, so a cold start never shows "?" denominators.
2997
+ hydrateModelLimitsFromState();
2998
+
2799
2999
  // Fire-and-forget prefetch of effective model limits.
2800
3000
  ensureModelLimits(ctx).catch((err) =>
2801
3001
  logErr("model limits prefetch failed", err),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Real-time OpenCode context-window usage indicator: per-category token breakdown written to a log file, plus an optional TUI sidebar. Dual V1 + V2 plugin entry.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,9 +31,14 @@
31
31
  "access": "public"
32
32
  },
33
33
  "author": "VladSmr",
34
- "repository": { "type": "git", "url": "git+https://github.com/VladSmr/opencode-context-indicator.git" },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/VladSmr/opencode-context-indicator.git"
37
+ },
35
38
  "homepage": "https://github.com/VladSmr/opencode-context-indicator#readme",
36
- "bugs": { "url": "https://github.com/VladSmr/opencode-context-indicator/issues" },
39
+ "bugs": {
40
+ "url": "https://github.com/VladSmr/opencode-context-indicator/issues"
41
+ },
37
42
  "peerDependencies": {
38
43
  "@opencode/plugin": ">=2.0.16",
39
44
  "@opentui/core": ">=0.5.12",
@@ -56,6 +61,14 @@
56
61
  "devDependencies": {
57
62
  "@opencode/plugin": "^2.0.19",
58
63
  "@opentui/core": "^0.5.12",
64
+ "esbuild": "^0.28.2",
65
+ "node-pty": "^1.1.0",
59
66
  "solid-js": "^1.9.12"
60
- }
67
+ },
68
+ "scripts": {
69
+ "test:prepublish": "node test/prepublish.mjs",
70
+ "test:e2e": "node test/e2e-tui.mjs --full",
71
+ "test:e2e:quick": "node test/e2e-tui.mjs --quick"
72
+ },
73
+ "prepublishOnly": "node test/prepublish.mjs && node test/e2e-tui.mjs --full"
61
74
  }
package/tui.tsx CHANGED
@@ -45,7 +45,9 @@ import {tmpdir} from "node:os"
45
45
  import {join} from "node:path"
46
46
  import {fileURLToPath} from "node:url"
47
47
 
48
- const STATE_FILE = join(tmpdir(), "opencode-context-indicator-state.json")
48
+ const STATE_FILE =
49
+ process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
50
+ join(tmpdir(), "opencode-context-indicator-state.json")
49
51
  const POLL_MS = 1000
50
52
  const LABEL_WIDTH = 12
51
53