opencode-context-indicator 1.5.4 → 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/commands.js CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  breakdownCategories,
10
10
  fmt,
11
11
  fmtCell,
12
+ isHookMeasuredEntry,
13
+ isHookSource,
12
14
  isSafeSessionKey,
13
15
  oneLine,
14
16
  safeCategories,
@@ -70,6 +72,23 @@ import {
70
72
  // from state.json / live, but the category cells stay n/a.
71
73
  const COMMAND_COLLECT_BUDGET_MS = 3500;
72
74
 
75
+ // Session ids for which this instance already tried a live enrichment and found
76
+ // nothing better than state.json. The Desktop panel polls collectFamilyReports
77
+ // (and therefore collectSessionReport) every 1.5 s; without this, a session
78
+ // whose state entry has no agent/model would issue session.get() on every poll,
79
+ // forever. Bounded: an instance only ever tracks the sessions it has seen.
80
+ const ENRICH_ATTEMPTED_MAX = 256;
81
+ const enrichAttempted = new Set();
82
+
83
+ function markEnrichAttempted(sessionID) {
84
+ if (enrichAttempted.has(sessionID)) return;
85
+ if (enrichAttempted.size >= ENRICH_ATTEMPTED_MAX) {
86
+ const oldest = enrichAttempted.values().next().value;
87
+ enrichAttempted.delete(oldest);
88
+ }
89
+ enrichAttempted.add(sessionID);
90
+ }
91
+
73
92
  // `resume` for the full /context-breakdown command. resume:true admits the
74
93
  // payload to the transcript AND wakes the session, so the agent echoes the table
75
94
  // as a normal message that the Desktop renders in full (the notice chip only
@@ -140,10 +159,14 @@ function collectDescendantSessionIDs(rootID, state) {
140
159
  // Moved to lib/estimate.js (shared with the V2 live log); re-used here so the
141
160
  // residual computation has exactly one implementation.
142
161
 
143
- // Short source tag for the table (kept tiny on purpose).
162
+ // Short source tag for the table (kept tiny on purpose). A hook-family source
163
+ // is a real measurement of the request payload, so it reads "live".
144
164
  function sourceTag(source) {
145
165
  switch (source) {
146
166
  case "context-hook":
167
+ case "compaction-hook":
168
+ case "transform":
169
+ case "system-transform":
147
170
  return "live";
148
171
  case "client-context":
149
172
  return "fallback";
@@ -192,9 +215,16 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
192
215
  // state.json is stale. ParentID comes from state.json ONLY (entry?.parentID)
193
216
  // so the table is root-consistent; in-memory sessionParentCache is cross-session
194
217
  // and must NOT be used for attribution.
218
+ //
219
+ // A state.json entry that already carries the agent/model counts as warm, and
220
+ // one live attempt per session is enough: this path is polled (the Desktop
221
+ // panel calls it every 1.5 s) and a session this instance never observed live
222
+ // would otherwise issue session.get() on every poll, for the root and every
223
+ // descendant.
195
224
  const needEnrich =
196
- !sessionAgentCache.has(sessionID) ||
197
- lastKnownModel(sessionID) === "unknown";
225
+ !enrichAttempted.has(sessionID) &&
226
+ ((!sessionAgentCache.has(sessionID) && !entry?.agent) ||
227
+ (lastKnownModel(sessionID) === "unknown" && !entry?.model));
198
228
 
199
229
  // Fallback categories only when neither the live cache nor a state snapshot
200
230
  // has them: derive user/assistant/reasoning/tool-args from the persisted
@@ -204,6 +234,7 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
204
234
  // Enrichment and the context fallback run CONCURRENTLY, both bounded by the
205
235
  // shared budget. Non-fatal: any failure resolves to null and keeps whatever
206
236
  // the cache / state.json already provides.
237
+ if (needEnrich && budget > 0) markEnrichAttempted(sessionID);
207
238
  const [live, contextMsgs] = await Promise.all([
208
239
  needEnrich && budget > 0
209
240
  ? withTimeout(ctx.session.get({ sessionID }), budget, "session.get").then(
@@ -228,7 +259,25 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
228
259
  let categories = null;
229
260
  let source = "none";
230
261
  let fallbackParts = null;
231
- if (bd) {
262
+ // In-memory breakdown priority. Hook data (the full request payload: system
263
+ // prompt AND tool schemas) wins outright. The client-context fallback only
264
+ // fills a session that has no hook-measured categories: it is rebuilt from the
265
+ // persisted messages, so it cannot see the system prompt or the tool schemas -
266
+ // reporting them as 0 would push their whole mass into `other` and make a
267
+ // second instance (the Desktop window's, which answered the RPC without ever
268
+ // running the hook) show 0 / 0 / inflated-other for a session that state.json
269
+ // measured correctly.
270
+ //
271
+ // A state entry is hook-measured when it carries a hook-family provenance
272
+ // (`source`, see isHookSource: the V2 context / compaction hooks and the V1
273
+ // transform hooks), or - for legacy entries without `source` - when
274
+ // system/tool schemas are non-null (only a hook can estimate those). An entry
275
+ // written by the fallback stays a fair game for a newer fallback: it is the
276
+ // best data available for that session.
277
+ const entryHookMeasured = isHookMeasuredEntry(entry);
278
+ const bdFromHook = !!bd && isHookSource(bd.source);
279
+ const useBd = !!bd && (bdFromHook || !entryHookMeasured);
280
+ if (useBd) {
232
281
  // Sanitise: every value reaches a markdown/log sink and must be a number.
233
282
  categories = safeCategories(
234
283
  breakdownCategories(bd, lastKnownInput(sessionID) || entry?.input || 0),
@@ -273,9 +322,10 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
273
322
  const updatedAt =
274
323
  entry?.updatedAt ||
275
324
  (bd?.capturedAt ? new Date(bd.capturedAt).toISOString() : null);
276
- const toolOutByTool = bd
325
+ const toolOutByTool = useBd
277
326
  ? safeToolOutByTool(bd.toolOutByTool)
278
327
  : safeToolOutByTool(entry?.toolOutByTool) ||
328
+ safeToolOutByTool(bd?.toolOutByTool) ||
279
329
  safeToolOutByTool(fallbackParts?.toolOutByTool);
280
330
  const compactStale = entry?.compactStale === true;
281
331
  // Provider-reported prompt cache (exact; subset of input). Live cache first,
@@ -287,13 +337,16 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
287
337
  safeSaneOrNull(lastKnownCacheWrite(sessionID)) ??
288
338
  safeSaneOrNull(entry?.cacheWrite);
289
339
  // Redundant tool output. The live breakdown owns targets; the persisted entry
290
- // has them stripped (path-free) — sanitise both.
291
- const reclaim = bd
340
+ // has them stripped (path-free) — sanitise both. The fallback-derived entry
341
+ // loses to the persisted one, for the same reason as the categories above.
342
+ const reclaim = useBd
292
343
  ? safeReclaim(bd.reclaim, true)
293
- : safeReclaim(entry?.reclaim, false);
344
+ : safeReclaim(entry?.reclaim, false) || safeReclaim(bd?.reclaim, false);
294
345
  // Top heaviest single items. The live breakdown has targets; the persisted
295
346
  // copy is path-free (sanitised on write), so keepTarget is false there.
296
- const top = bd ? safeTop(bd.top, true) : safeTop(entry?.top, false);
347
+ const top = useBd
348
+ ? safeTop(bd.top, true)
349
+ : safeTop(entry?.top, false) || safeTop(bd?.top, false);
297
350
  // Previous distinct measurement (state.json only) for /context-diff.
298
351
  const previous = safeSnapshot(entry?.previous);
299
352
  return {
@@ -319,6 +372,21 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
319
372
  };
320
373
  }
321
374
 
375
+ // Root report plus the reports of every descendant that has a measurement,
376
+ // collected in parallel under one shared wall-clock budget. Shared by
377
+ // /context-breakdown and the Desktop RPC (lib/desktop.js), so the family walk,
378
+ // the budget and the per-session collection exist once.
379
+ async function collectFamilyReports(ctx, sessionID, budgetMs) {
380
+ const state = readStateSnapshot();
381
+ const childIDs = collectDescendantSessionIDs(sessionID, state);
382
+ const deadline = Date.now() + (budgetMs || COMMAND_COLLECT_BUDGET_MS);
383
+ const [root, ...children] = await Promise.all([
384
+ collectSessionReport(ctx, sessionID, state, deadline),
385
+ ...childIDs.map((cid) => collectSessionReport(ctx, cid, state, deadline)),
386
+ ]);
387
+ return { root, children };
388
+ }
389
+
322
390
  // One markdown table row. Categories sit in columns (short headers) to keep the
323
391
  // table compact; the ctx column already carries the percentage.
324
392
  function renderSessionRow(r, isMain) {
@@ -731,20 +799,14 @@ async function commandContextBreakdown(ctx, invocation, opts) {
731
799
  // state.json is read immediately and synchronously; the root and every
732
800
  // descendant are then collected IN PARALLEL under one cumulative budget, so
733
801
  // the command costs ~budget regardless of the subagent count (not N × 3s).
734
- const state = readStateSnapshot();
735
- const childIDs = collectDescendantSessionIDs(sid, state);
736
- const deadline = Date.now() + COMMAND_COLLECT_BUDGET_MS;
737
- const [root, ...children] = await Promise.all([
738
- collectSessionReport(ctx, sid, state, deadline),
739
- ...childIDs.map((cid) => collectSessionReport(ctx, cid, state, deadline)),
740
- ]);
802
+ const { root, children } = await collectFamilyReports(sid);
741
803
  const rows = [renderSessionRow(root, true)];
742
804
  for (const rep of children) rows.push(renderSessionRow(rep, false));
743
- const table = renderBreakdownTable(rows, childIDs.length, root);
805
+ const table = renderBreakdownTable(rows, children.length, root);
744
806
  const payload = resume
745
807
  ? `${BREAKDOWN_MODEL_INSTRUCTION}\n\n<context-breakdown>\n${table}\n</context-breakdown>`
746
808
  : table;
747
- const description = `Full context breakdown — ${1 + childIDs.length} session(s)`;
809
+ const description = `Full context breakdown — ${1 + children.length} session(s)`;
748
810
 
749
811
  const delivered = await deliverSynthetic(ctx, sid, payload, description, resume);
750
812
 
@@ -765,6 +827,7 @@ export {
765
827
  commandContextBreakdown,
766
828
  commandContextDiff,
767
829
  CONTEXT_BREAKDOWN_RESUME,
830
+ collectFamilyReports,
768
831
  collectSessionReport,
769
832
  renderSessionRow,
770
833
  renderBreakdownTable,
package/lib/desktop.js ADDED
@@ -0,0 +1,102 @@
1
+ // lib/desktop.js
2
+ //
3
+ // Server side of the Desktop GUI panel: registers the RPC contract from
4
+ // lib/rpc.js and answers it from the SAME collection the /context-breakdown
5
+ // command uses (lib/commands.js collectFamilyReports). The GUI is a window in
6
+ // the desktop app and cannot read state.json or the plugin's memory, so this is
7
+ // its only data path.
8
+ //
9
+ // Nothing here runs on hosts without the GUI API: the registration is guarded
10
+ // and a failure is logged, never thrown, so the TUI and the slash commands are
11
+ // unaffected. For the same reason lib/rpc.js - which imports
12
+ // `@opencode/plugin/rpc`, an OPTIONAL peer - is loaded lazily inside the guard:
13
+ // a host that does not provide that module must still load index.js, not fail
14
+ // at import time.
15
+
16
+ import { collectFamilyReports } from "./commands.js";
17
+ import { num } from "./format.js";
18
+ import { logErr } from "./state.js";
19
+
20
+ // A GUI panel polls this while it is open, so the budget is much smaller than
21
+ // the command's: the collection is state.json + in-memory caches only (the live
22
+ // lookups are skipped once the state entry carries the agent/model), and a poll
23
+ // must never hold a hook longer than a fraction of a second.
24
+ const DESKTOP_COLLECT_BUDGET_MS = 2000;
25
+
26
+ // One wire row (see lib/rpc.d.ts). JSON has no undefined/null in this contract:
27
+ // every optional value is flattened to "" or 0, which the GUI treats as
28
+ // "unknown". `report` is the sanitized object from collectSessionReport.
29
+ function toDesktopRow(report) {
30
+ const c = report?.categories || {};
31
+ return {
32
+ sessionID: typeof report?.sessionID === "string" ? report.sessionID : "",
33
+ parentID: typeof report?.parentID === "string" ? report.parentID : "",
34
+ agent: typeof report?.agent === "string" ? report.agent : "",
35
+ modelID: typeof report?.modelID === "string" ? report.modelID : "",
36
+ providerID: typeof report?.providerID === "string" ? report.providerID : "",
37
+ ctx: num(report?.ctxTokens),
38
+ input: num(report?.inputTokens),
39
+ usable: num(report?.usable),
40
+ reserve: num(report?.reserve),
41
+ user: num(c.user),
42
+ assistant: num(c.assistant),
43
+ reasoning: num(c.reasoning),
44
+ toolArgs: num(c.toolArgs),
45
+ toolOut: num(c.toolOut),
46
+ system: num(c.system),
47
+ toolSchemas: num(c.toolSchemas),
48
+ other: num(c.other),
49
+ source: typeof report?.source === "string" ? report.source : "",
50
+ updatedAt: typeof report?.updatedAt === "string" ? report.updatedAt : "",
51
+ stale: report?.compactStale === true,
52
+ };
53
+ }
54
+
55
+ // A row is worth showing only when the session has been measured at least once:
56
+ // no tokens, no window and no timestamp means "never seen by this plugin" (a
57
+ // foreign or already-pruned session), and a full row of zeros would read as a
58
+ // real, empty context. The GUI turns an empty list into its empty state.
59
+ function hasMeasurement(row) {
60
+ return row.ctx > 0 || row.usable > 0 || row.updatedAt !== "";
61
+ }
62
+
63
+ // Registers the contract on a V2 plugin context. Returns the registration (so
64
+ // the caller can keep it alive / dispose it) or null when this host has no RPC
65
+ // support (opencode 2.0.x), does not provide `@opencode/plugin/rpc`, or the
66
+ // registration failed.
67
+ async function registerDesktopRpc(ctx) {
68
+ try {
69
+ if (!ctx?.rpc || typeof ctx.rpc.register !== "function") return null;
70
+ const { DesktopContextRpc } = await import("./rpc.js");
71
+ return await ctx.rpc.register(DesktopContextRpc, {
72
+ family: async (input) => {
73
+ const sessionID =
74
+ input && typeof input.sessionID === "string" ? input.sessionID : "";
75
+ if (!sessionID) return { rows: [] };
76
+ const { root, children } = await collectFamilyReports(
77
+ ctx,
78
+ sessionID,
79
+ DESKTOP_COLLECT_BUDGET_MS,
80
+ );
81
+ const rootRow = toDesktopRow(root);
82
+ if (!hasMeasurement(rootRow)) return { rows: [] };
83
+ return {
84
+ rows: [
85
+ rootRow,
86
+ ...children.map(toDesktopRow).filter(hasMeasurement),
87
+ ],
88
+ };
89
+ },
90
+ });
91
+ } catch (err) {
92
+ logErr("desktop RPC registration failed", err);
93
+ return null;
94
+ }
95
+ }
96
+
97
+ export {
98
+ registerDesktopRpc,
99
+ toDesktopRow,
100
+ hasMeasurement,
101
+ DESKTOP_COLLECT_BUDGET_MS,
102
+ };
package/lib/estimate.js CHANGED
@@ -111,8 +111,6 @@ function safeCategories(raw) {
111
111
  };
112
112
  }
113
113
 
114
- // A session key safe to use as an object property / map key. Rejects the
115
- // prototype-pollution vectors ("__proto__", "constructor", "prototype").
116
114
  // Sum of the 8 canonical category fields (null `system`/`toolSchemas` count as
117
115
  // 0). The single place the "total measured tokens" is derived — shared by the
118
116
  // residual-`other` computation, the continuity test and the log/summary writers.
@@ -158,6 +156,35 @@ function breakdownCategories(rec, inputTokens) {
158
156
  return { user, assistant, reasoning, toolArgs, toolOut, system, toolSchemas, other };
159
157
  }
160
158
 
159
+ // True when a persisted state.json entry carries a categories object measured by
160
+ // a request/transform payload that saw the assembled prompt: the V2 context and
161
+ // compaction hooks ("context-hook" / "compaction-hook") and the V1 transform
162
+ // hooks ("transform" / "system-transform"). Those see the system prompt and the
163
+ // tool schemas. `source` is persisted by lib/state.js since 1.7.1; legacy
164
+ // entries have none, and for them only a non-null system / tool-schema estimate
165
+ // proves a hook ran (the message-based fallback cannot produce one). Shared by
166
+ // the state writer, the command report and the live log so the priority rule
167
+ // exists once.
168
+ const HOOK_SOURCES = new Set([
169
+ "context-hook",
170
+ "compaction-hook",
171
+ "transform",
172
+ "system-transform",
173
+ ]);
174
+
175
+ function isHookSource(source) {
176
+ return typeof source === "string" && HOOK_SOURCES.has(source);
177
+ }
178
+
179
+ function isHookMeasuredEntry(entry) {
180
+ if (!entry || typeof entry !== "object" || !entry.categories) return false;
181
+ if (isHookSource(entry.source)) return true;
182
+ return (
183
+ entry.source == null &&
184
+ (entry.categories.system != null || entry.categories.toolSchemas != null)
185
+ );
186
+ }
187
+
161
188
  // A session key safe to use as an object property / map key. Rejects the
162
189
  // prototype-pollution vectors ("__proto__", "constructor", "prototype").
163
190
  function isSafeSessionKey(k) {
@@ -607,6 +634,8 @@ export {
607
634
  safeCategories,
608
635
  sumCategories,
609
636
  breakdownCategories,
637
+ isHookSource,
638
+ isHookMeasuredEntry,
610
639
  isSafeSessionKey,
611
640
  safeToolOutByTool,
612
641
  toolOutEntries,
package/lib/format.js ADDED
@@ -0,0 +1,19 @@
1
+ // lib/format.js
2
+ //
3
+ // Number formatting shared by every surface: the TUI sidebar and breakdown
4
+ // panel (re-exported from lib/tui-data.js, so existing imports keep working)
5
+ // and the Desktop GUI panel.
6
+ //
7
+ // Pure JS with no imports at all: the Desktop GUI bundle inlines this file, and
8
+ // a `node:*` import here would break the browser-side bundle.
9
+
10
+ // Compact token count: 12846 -> "12.8k", 940 -> "940".
11
+ export function fmt(n) {
12
+ if (!Number.isFinite(n)) return "0"
13
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
14
+ }
15
+
16
+ // Coerce any untrusted value (state.json is user-writable) to a finite number.
17
+ export function num(v) {
18
+ return typeof v === "number" && Number.isFinite(v) ? v : 0
19
+ }
package/lib/limits.js CHANGED
@@ -7,11 +7,16 @@
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
11
- // packages/core/src/session/compaction.ts derives the auto-compaction trigger
12
- // ceiling as window - reserve, where window = limit.input || limit.context
13
- // (null/zero falls through to the context window) and
14
- // reserve = max(floor(window * 0.15), window >= 2 * 16_000 ? 16_000 : 0)
10
+ // Compaction ceiling (opencode calculateCeiling): opencode's core
11
+ // calculateCeiling() in packages/core/src/session/compaction.ts derives the
12
+ // auto-compaction trigger ceiling as window - reserve, where
13
+ // window = limit.input || limit.context (null/zero falls through to the context
14
+ // window) and
15
+ // reserve = max(floor(window * 0.10), window >= 2 * 16_000 ? 16_000 : 0)
16
+ // The share was 15% up to v2.1.x and is 10% since v2.2.0 (PR #51271 "fit output
17
+ // limits to the context window"); the `2 * RESERVE_MIN` gate is unchanged. A
18
+ // wrong reserve makes every displayed percentage wrong, so this mirrors the
19
+ // host exactly.
15
20
  // A compaction.buffer configured in opencode itself is NOT visible to the
16
21
  // plugin (the plugin cannot read opencode's internal compaction config); the
17
22
  // env var OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER overrides the reserve for
@@ -79,10 +84,12 @@ function getModelLimit(providerID, modelID) {
79
84
  return typeof rec.context === "number" && rec.context > 0 ? rec.context : null;
80
85
  }
81
86
 
82
- // Compute the reserve for a given window, matching opencode 2.1.4
83
- // packages/core/src/session/compaction.ts calculateCeiling(). Returns null when
87
+ // Compute the reserve for a given window, matching opencode's
88
+ // packages/core/src/session/compaction.ts calculateCeiling() in 2.2.x (the
89
+ // share became 10% in 2.2.0; the 2 * RESERVE_MIN gate is older and unchanged).
90
+ // Returns null when
84
91
  // window is not a finite positive number.
85
- const RESERVE_SHARE = 0.15; // core: RESERVE_SHARE
92
+ const RESERVE_SHARE = 0.1; // core: 10% of the window (15% before v2.2.0)
86
93
  const RESERVE_MIN = 16_000; // core: RESERVE_MIN
87
94
  function reserveFor(window, bufferOverride) {
88
95
  if (
@@ -102,7 +109,7 @@ function reserveFor(window, bufferOverride) {
102
109
  return bufferOverride;
103
110
  }
104
111
  // Default reserve formula (mirror of opencode core):
105
- // max(floor(window * 0.15), window >= 2 * RESERVE_MIN ? RESERVE_MIN : 0)
112
+ // max(floor(window * 0.10), window >= 2 * RESERVE_MIN ? RESERVE_MIN : 0)
106
113
  // The threshold is 2 * RESERVE_MIN (32_000), not RESERVE_MIN.
107
114
  return Math.max(
108
115
  Math.floor(window * RESERVE_SHARE),
@@ -110,7 +117,7 @@ function reserveFor(window, bufferOverride) {
110
117
  );
111
118
  }
112
119
 
113
- // Native opencode compaction ceiling (2.1.4 calculateCeiling):
120
+ // Native opencode compaction ceiling (calculateCeiling, core v2.2.x):
114
121
  // window = limit.input || limit.context (zero/null falls through to context)
115
122
  // reserve = reserveFor(window) [or bufferOverride when provided]
116
123
  // usable = window - reserve
package/lib/rpc.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ // lib/rpc.d.ts
2
+ //
3
+ // Type surface of the Desktop RPC contract (lib/rpc.js). The GUI part is
4
+ // TypeScript, the server part is JS, so the row type lives here once and the
5
+ // GUI imports it: `DesktopRow` must stay in sync with ROW_PROPERTIES, which the
6
+ // T30 gate check enforces at runtime.
7
+
8
+ import type { Rpc } from "@opencode/plugin/rpc"
9
+
10
+ /** One row of the Desktop context panel: one session, its own window. */
11
+ export interface DesktopRow {
12
+ readonly sessionID: string
13
+ /** "" for the root session of the family. */
14
+ readonly parentID: string
15
+ readonly agent: string
16
+ readonly modelID: string
17
+ readonly providerID: string
18
+ /** Context tokens in use (prompt + tool schemas + system). */
19
+ readonly ctx: number
20
+ readonly input: number
21
+ /** The engine's usable window; 0 when the model limits are not known yet. */
22
+ readonly usable: number
23
+ /** The compaction reserve subtracted from the window; 0 when unknown. */
24
+ readonly reserve: number
25
+ readonly user: number
26
+ readonly assistant: number
27
+ readonly reasoning: number
28
+ readonly toolArgs: number
29
+ readonly toolOut: number
30
+ readonly system: number
31
+ readonly toolSchemas: number
32
+ readonly other: number
33
+ /** "live" | "snapshot" | "client-context" | "no-data" (or an engine tag). */
34
+ readonly source: string
35
+ /** ISO timestamp of the measurement; "" when unknown. */
36
+ readonly updatedAt: string
37
+ /** The session was compacted after this measurement. */
38
+ readonly stale: boolean
39
+ }
40
+
41
+ /** Output of the `family` method. */
42
+ export interface DesktopFamilyResult {
43
+ readonly rows: readonly DesktopRow[]
44
+ }
45
+
46
+ export declare const DesktopContextRpc: Rpc.PortableDefinition
47
+ export declare const DESKTOP_ROW_KEYS: readonly string[]
package/lib/rpc.js ADDED
@@ -0,0 +1,88 @@
1
+ // lib/rpc.js
2
+ //
3
+ // The RPC contract between the server part and the Desktop GUI part. The server
4
+ // registers it in V2 setup (lib/desktop.js) and the GUI calls it through
5
+ // `ctx.client.rpc()`; it is also published as the `./rpc` export so other
6
+ // plugins and clients can call it without loading the implementation.
7
+ //
8
+ // JSON Schema only. A Standard Schema validator (zod/valibot) would give
9
+ // inferred TypeScript types but adds a runtime dependency to the published
10
+ // package; every consumer here casts the one response it reads anyway, and the
11
+ // app validates both formats identically.
12
+ //
13
+ // The wire shape is FLAT on purpose: `lib/desktop.js` builds every row from one
14
+ // report, and the T30 gate check compares the schema keys with that builder's
15
+ // output keys, so a field added to one side without the other fails the gate.
16
+
17
+ import { Rpc } from "@opencode/plugin/rpc"
18
+
19
+ const ROW_PROPERTIES = {
20
+ // Session ids and the parent link, as reported by the state snapshot.
21
+ sessionID: { type: "string" },
22
+ // "" for the root session (JSON has no undefined; nulls would need a union
23
+ // type every consumer has to narrow).
24
+ parentID: { type: "string" },
25
+ agent: { type: "string" },
26
+ modelID: { type: "string" },
27
+ providerID: { type: "string" },
28
+ // Token counts. `usable` and `reserve` are 0 when the window is unknown.
29
+ ctx: { type: "number" },
30
+ input: { type: "number" },
31
+ usable: { type: "number" },
32
+ reserve: { type: "number" },
33
+ // The eight breakdown categories, in tokens.
34
+ user: { type: "number" },
35
+ assistant: { type: "number" },
36
+ reasoning: { type: "number" },
37
+ toolArgs: { type: "number" },
38
+ toolOut: { type: "number" },
39
+ system: { type: "number" },
40
+ toolSchemas: { type: "number" },
41
+ other: { type: "number" },
42
+ // Provenance of the numbers and the measurement time ("" when unknown).
43
+ source: { type: "string" },
44
+ updatedAt: { type: "string" },
45
+ // True when the session was compacted after this measurement.
46
+ stale: { type: "boolean" },
47
+ }
48
+
49
+ const ROW_REQUIRED = Object.keys(ROW_PROPERTIES)
50
+
51
+ export const DesktopContextRpc = Rpc.define({
52
+ // Same id as the plugin itself: one RPC per plugin keeps the event names and
53
+ // client subclients unambiguous.
54
+ id: "context-indicator",
55
+ methods: {
56
+ // Root session plus every descendant that has a measurement, in the same
57
+ // order /context-breakdown uses (root first, then breadth-first children).
58
+ family: {
59
+ input: {
60
+ type: "object",
61
+ properties: { sessionID: { type: "string" } },
62
+ required: ["sessionID"],
63
+ additionalProperties: false,
64
+ },
65
+ output: {
66
+ type: "object",
67
+ properties: {
68
+ rows: {
69
+ type: "array",
70
+ items: {
71
+ type: "object",
72
+ properties: ROW_PROPERTIES,
73
+ required: ROW_REQUIRED,
74
+ additionalProperties: false,
75
+ },
76
+ },
77
+ },
78
+ required: ["rows"],
79
+ additionalProperties: false,
80
+ },
81
+ },
82
+ },
83
+ events: {},
84
+ })
85
+
86
+ // Exported for the T30 gate check (schema/row drift) without a second copy of
87
+ // the key list.
88
+ export const DESKTOP_ROW_KEYS = ROW_REQUIRED