opencode-context-indicator 1.1.2 → 1.1.3

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 (3) hide show
  1. package/README.md +7 -0
  2. package/index.js +121 -9
  3. package/package.json +1 -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
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
@@ -278,6 +279,7 @@ let providersInFlight = null;
278
279
  // absolute tokens (as in V1).
279
280
  let v2LimitsLoadedAt = 0;
280
281
  let v2LimitsFailAt = 0;
282
+ let v2LimitsFailCount = 0; // consecutive soft-failures -> exponential retry backoff
281
283
  let v2LimitsInFlight = null;
282
284
 
283
285
  // Bounded list of final summaries written on session.idle
@@ -472,6 +474,14 @@ function writeStateFile(
472
474
  const prevEntry = all[sessionID] || null;
473
475
  const pc = all[sessionID]?.categories || null;
474
476
 
477
+ // Same model AND provider? Only then is a previously persisted denominator
478
+ // meaningful — a session that switched model (or provider, even with the
479
+ // same modelID) must not inherit the old window.
480
+ const sameModelAsPrev =
481
+ prevEntry != null &&
482
+ prevEntry.model === (modelID || "unknown") &&
483
+ (prevEntry.providerID || "") === (providerID || "");
484
+
475
485
  const catUser = bd ? bd.userChars || 0 : pc?.user ?? 0;
476
486
  const catAssistant = bd ? bd.assistantChars || 0 : pc?.assistant ?? 0;
477
487
  const catReasoning = bd ? bd.reasoningChars || 0 : pc?.reasoning ?? 0;
@@ -504,13 +514,23 @@ function writeStateFile(
504
514
  sessionID,
505
515
  parentID,
506
516
  role: parentID ? "sub" : "main",
507
- agent: sessionAgentCache.get(sessionID) || null,
517
+ agent: sessionAgentCache.get(sessionID) || prevEntry?.agent || null,
508
518
  model: modelID || "unknown",
509
519
  providerID: providerID || "",
510
520
  ctx: total,
511
521
  input: inputTokens || 0,
512
- usable: denom,
513
- limit: limit != null ? limit : null,
522
+ // Do NOT clobber a known denominator with null: multiple plugin instances
523
+ // share this file and one of them may lack modelLimits (its ctx.model.list
524
+ // returned nothing). Keep the previous model's known value when the live
525
+ // lookup came back empty, so a transient miss never becomes permanent.
526
+ usable:
527
+ denom != null ? denom : sameModelAsPrev ? prevEntry.usable ?? null : null,
528
+ limit:
529
+ limit != null
530
+ ? limit
531
+ : sameModelAsPrev && typeof prevEntry.limit === "number"
532
+ ? prevEntry.limit
533
+ : null,
514
534
  reasoning: typeof reasoningTokens === "number" ? reasoningTokens : 0,
515
535
  categories: {
516
536
  user: catUser,
@@ -1916,7 +1936,14 @@ async function ensureV2Breakdown(ctx, sessionID) {
1916
1936
  async function ensureModelLimits(ctx) {
1917
1937
  const now = Date.now();
1918
1938
  if (now - v2LimitsLoadedAt < PROVIDERS_TTL_MS) return; // fresh success
1919
- if (now - v2LimitsFailAt < PROVIDERS_FAIL_TTL_MS) return; // recent failure
1939
+ // Recent failure: back off exponentially (30s, 60s, 120s … capped at 5 min)
1940
+ // so a registry that is slow to expose the provider is retried without
1941
+ // hammering it, while a one-off blip still recovers quickly.
1942
+ const failBackoff = Math.min(
1943
+ PROVIDERS_FAIL_TTL_MS * Math.pow(2, Math.max(0, v2LimitsFailCount - 1)),
1944
+ V2_LIMITS_MAX_BACKOFF_MS,
1945
+ );
1946
+ if (now - v2LimitsFailAt < failBackoff) return; // recent failure
1920
1947
  if (v2LimitsInFlight) return v2LimitsInFlight; // dedup parallel callers
1921
1948
  v2LimitsInFlight = (async () => {
1922
1949
  try {
@@ -1927,7 +1954,13 @@ async function ensureModelLimits(ctx) {
1927
1954
  );
1928
1955
  const models = Array.isArray(res) ? res : res?.data;
1929
1956
  if (!Array.isArray(models)) {
1957
+ v2LimitsFailCount++;
1930
1958
  v2LimitsFailAt = Date.now(); // malformed payload -> retry soon
1959
+ const seeded = hydrateModelLimitsFromState();
1960
+ logErr(
1961
+ "model.list returned no model array — limits unavailable, will retry",
1962
+ { payloadType: typeof res, hydratedFromState: seeded },
1963
+ );
1931
1964
  return;
1932
1965
  }
1933
1966
  let found = 0;
@@ -1964,11 +1997,36 @@ async function ensureModelLimits(ctx) {
1964
1997
  // An empty / limit-less payload right after boot (registry not ready yet)
1965
1998
  // must NOT be cached as success, or limits would stay unknown for the
1966
1999
  // 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();
2000
+ if (found > 0) {
2001
+ v2LimitsLoadedAt = Date.now();
2002
+ v2LimitsFailCount = 0; // recovered
2003
+ } else {
2004
+ // Soft failure: the registry answered but carried no usable limit for
2005
+ // any model (provider not registered yet, or `limit` absent). Log WHY
2006
+ // once per backoff window, and fall back to the shared snapshot so
2007
+ // denominators do not vanish meanwhile.
2008
+ const withLimit = models.filter(
2009
+ (m) => m?.limit && typeof m.limit === "object",
2010
+ ).length;
2011
+ const lanit = models.filter(
2012
+ (m) => m?.providerID === "lanit" || m?.provider?.id === "lanit",
2013
+ ).length;
2014
+ const seeded = hydrateModelLimitsFromState();
2015
+ v2LimitsFailCount++;
2016
+ v2LimitsFailAt = Date.now();
2017
+ logErr(
2018
+ "model.list yielded 0 usable limits — retrying",
2019
+ { models: models.length, withLimit, lanit, hydratedFromState: seeded },
2020
+ );
2021
+ }
1969
2022
  } catch (err) {
1970
- v2LimitsFailAt = Date.now(); // failed attempt -> short retry gate
1971
- logErr("model.list failed", err);
2023
+ v2LimitsFailCount++;
2024
+ v2LimitsFailAt = Date.now(); // failed attempt -> backoff gate
2025
+ const seeded = hydrateModelLimitsFromState();
2026
+ logErr(
2027
+ `model.list failed (hydrated ${seeded} limit(s) from state.json)`,
2028
+ err,
2029
+ );
1972
2030
  } finally {
1973
2031
  v2LimitsInFlight = null;
1974
2032
  }
@@ -2377,6 +2435,56 @@ function readStateSnapshot() {
2377
2435
  }
2378
2436
  }
2379
2437
 
2438
+ // Seed `modelLimits` from previously persisted state.json entries. state.json is
2439
+ // a SHARED cross-instance snapshot rewritten at every live step, so it carries a
2440
+ // model's window even when THIS instance's ctx.model.list() came back empty (a
2441
+ // cold instance, or one whose model registry did not expose the provider).
2442
+ // Without this, a transient list miss degraded every denominator to "?" — and
2443
+ // because writeStateFile used to rewrite usable/limit from the (empty) in-memory
2444
+ // map, the degradation became permanent. Strictly additive: real in-memory
2445
+ // records always win (never overwritten). Returns the number of records seeded.
2446
+ function hydrateModelLimitsFromState() {
2447
+ try {
2448
+ const all = readStateSnapshot();
2449
+ let added = 0;
2450
+ for (const entry of Object.values(all)) {
2451
+ const providerID =
2452
+ typeof entry?.providerID === "string" ? entry.providerID : "";
2453
+ const modelID = entry?.model;
2454
+ const key = modelKey(providerID, modelID);
2455
+ if (!key || !modelID || modelID === "unknown") continue;
2456
+ if (modelLimits.has(key)) continue; // a real record always wins
2457
+ // A state entry stores the derived usable window (`usable`) and the raw
2458
+ // context window (`limit`) — NOT the input/output/reserved triple. Seed
2459
+ // `usable` as `input` (reserved 0) so getUsableContext() re-derives EXACTLY
2460
+ // the same denominator the warm instance persisted; seeding `limit` as
2461
+ // context instead would let the cold instance show the raw context window
2462
+ // (e.g. 200k) while the warm one shows the true usable window (e.g. 168k).
2463
+ // Fall back to `limit` as context only when no `usable` was recorded.
2464
+ const usable =
2465
+ typeof entry?.usable === "number" && entry.usable > 0
2466
+ ? entry.usable
2467
+ : null;
2468
+ const ctxLimit =
2469
+ typeof entry?.limit === "number" && entry.limit > 0
2470
+ ? entry.limit
2471
+ : null;
2472
+ let rec = null;
2473
+ if (usable != null) {
2474
+ rec = { context: ctxLimit, input: usable, output: null, reserved: 0 };
2475
+ } else if (ctxLimit != null) {
2476
+ rec = { context: ctxLimit, input: null, output: null, reserved: null };
2477
+ }
2478
+ if (rec == null) continue;
2479
+ modelLimits.set(key, rec);
2480
+ added++;
2481
+ }
2482
+ return added;
2483
+ } catch {
2484
+ return 0;
2485
+ }
2486
+ }
2487
+
2380
2488
  // True when a state.json entry carries no measured tokens at all: a session
2381
2489
  // registered (session.created) but with no served step. `writeStateFile` always
2382
2490
  // persists `categories` as an object, so the emptiness test must SUM those
@@ -2796,6 +2904,10 @@ export default {
2796
2904
  // to the generic 200k default), so the transform is not used for limit
2797
2905
  // population.
2798
2906
 
2907
+ // Seed limits from the shared snapshot FIRST: another instance may already
2908
+ // have resolved them, so a cold start never shows "?" denominators.
2909
+ hydrateModelLimitsFromState();
2910
+
2799
2911
  // Fire-and-forget prefetch of effective model limits.
2800
2912
  ensureModelLimits(ctx).catch((err) =>
2801
2913
  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.3",
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",