opencode-clm 0.2.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-clm",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "OpenCode plugin for Context Language Models: the model edits a file mirror of its own context, and the edited mirror becomes its next input.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -63,11 +63,11 @@
63
63
  }
64
64
  },
65
65
  "devDependencies": {
66
- "@opentui/core": "0.4.5",
67
- "@opentui/keymap": "0.4.5",
68
- "@opentui/solid": "0.4.5",
69
- "@types/bun": "1.3.14",
66
+ "@opentui/core": "0.5.14",
67
+ "@opentui/keymap": "0.5.14",
68
+ "@opentui/solid": "0.5.14",
69
+ "@types/bun": "1.4.2",
70
70
  "@xterm/headless": "6.0.0",
71
- "typescript": "5.9.3"
71
+ "typescript": "7.0.2"
72
72
  }
73
73
  }
package/src/budget.ts CHANGED
@@ -7,36 +7,53 @@
7
7
  *
8
8
  * Two measurements are kept apart and always labelled:
9
9
  *
10
- * - `estimated`: the harness estimate of the conversation in the *next* request (pinned
11
- * task + editable context + notices). Available before every call.
10
+ * - `estimated`: the harness estimate of the *next* request (pinned task + editable
11
+ * context + notices, plus the system prompt and tool schemas once their sizes are
12
+ * known). Available before every call.
12
13
  * - `observed`: the provider-reported input size of the *previous* request (OpenCode's
13
- * assistant `tokens`). Authoritative but one call late, and it includes the system
14
- * prompt and tool schemas, which the budget does not cover.
14
+ * assistant `tokens`). Authoritative but one call late.
15
15
  *
16
- * The estimate alone governs reminder tiers and the reminder text; `observed` is shown
17
- * for reference only, because it covers more than the budget does. The harness never
16
+ * The estimate alone governs reminder tiers and the reminder text; `observed` calibrates
17
+ * the estimate and is shown for reference. The harness never
18
18
  * blocks a request; the overflow guard (overflow.ts) withholds old tool output when the
19
19
  * estimate exceeds budget - reserve.
20
20
  */
21
21
  export interface BudgetPolicyConfig {
22
22
  /**
23
23
  * Context budget in tokens the reminders are measured against. Advisory: requests are
24
- * never blocked or rolled back. `undefined` means "use the model context window".
24
+ * never blocked or rolled back. When undefined, `contextFraction` decides; when both are
25
+ * undefined, the budget is the model context window (the `window` setting).
25
26
  */
26
27
  contextBudget?: number;
28
+ /**
29
+ * Budget as a fraction (0 < f <= 1) of the model window minus its output limit, used when
30
+ * `contextBudget` is undefined (the `50%` setting). With the window unknown the budget is
31
+ * `FALLBACK_BUDGET`.
32
+ */
33
+ contextFraction?: number;
27
34
  /** Generation headroom reserved below the budget. The final reminder fires at budget - reserve. */
28
35
  reserve: number;
29
36
  /** Fractions of the budget at which escalating reminders fire (0 < f < 1), ascending. */
30
37
  remindAtFractions: readonly number[];
31
38
  /** Whether to fire the final "budget - reserve" reminder. */
32
39
  remindAtReserve: boolean;
40
+ /**
41
+ * Share of the budget (0 <= f < 1) the context must grow past its size after an accepted
42
+ * edit before percentage reminders fire again; 0 turns the cooldown off. The
43
+ * budget − reserve reminder is exempt.
44
+ */
45
+ reminderCooldown: number;
33
46
  }
34
47
 
48
+ /** Budget when a percentage budget meets an unknown model window. */
49
+ export const FALLBACK_BUDGET = 32_000;
50
+
35
51
  export const DEFAULT_BUDGET_POLICY: BudgetPolicyConfig = {
36
- contextBudget: 32000,
52
+ contextFraction: 0.5,
37
53
  reserve: 2048,
38
54
  remindAtFractions: [0.25, 0.5, 0.75],
39
55
  remindAtReserve: true,
56
+ reminderCooldown: 0.1,
40
57
  };
41
58
 
42
59
  /**
@@ -70,23 +87,44 @@ export interface BudgetReading {
70
87
  * describes a context that no longer exists. The notice says so.
71
88
  */
72
89
  observedStale?: boolean;
73
- /** Where `budget` came from, for status text. */
74
- source: "config" | "model-window";
90
+ /**
91
+ * Where `budget` came from, for status text: `config` (a token count), `model-window`
92
+ * (the window, or a token count capped by it), `window-fraction` (`fraction` of the
93
+ * window), `fallback` (`FALLBACK_BUDGET`: a percentage with the window unknown).
94
+ */
95
+ source: BudgetSource;
96
+ /** The configured fraction, for `window-fraction` and `fallback`. */
97
+ fraction?: number;
75
98
  /** Set when `budget` was raised above the configured value to cover the fixed overhead (see `budgetFit`). */
76
99
  raisedFrom?: number;
77
100
  }
78
101
 
102
+ export type BudgetSource = "config" | "model-window" | "window-fraction" | "fallback";
103
+
79
104
  /** A reminder threshold in absolute tokens plus a stable label for re-arming. */
80
105
  export interface BudgetTier {
81
106
  label: string;
82
107
  tokens: number;
83
108
  }
84
109
 
110
+ /**
111
+ * Defaults plus `overrides`, validated. An own `contextBudget` key without an own
112
+ * `contextFraction` key replaces the default fraction (`{ contextBudget: undefined }` still
113
+ * means "the model window"); a defined `contextBudget` always clears `contextFraction`.
114
+ */
85
115
  export function resolveBudgetPolicy(overrides: Partial<BudgetPolicyConfig> | undefined): BudgetPolicyConfig {
86
116
  const merged: BudgetPolicyConfig = { ...DEFAULT_BUDGET_POLICY, ...(overrides ?? {}) };
117
+ const budgetGiven = overrides !== undefined && Object.hasOwn(overrides, "contextBudget");
118
+ const fractionGiven = overrides !== undefined && Object.hasOwn(overrides, "contextFraction");
119
+ if (merged.contextBudget !== undefined || (budgetGiven && !fractionGiven)) delete merged.contextFraction;
87
120
  if (merged.contextBudget !== undefined && (!Number.isFinite(merged.contextBudget) || merged.contextBudget <= 0)) {
88
121
  throw new Error(`contextBudget must be a positive number of tokens, got ${String(merged.contextBudget)}`);
89
122
  }
123
+ if (merged.contextFraction !== undefined && (!Number.isFinite(merged.contextFraction) || merged.contextFraction <= 0 || merged.contextFraction > 1)) {
124
+ throw new Error(`contextFraction must be in (0, 1], got ${String(merged.contextFraction)}`);
125
+ }
126
+ if (merged.contextFraction === undefined) delete merged.contextFraction;
127
+ if (merged.contextBudget === undefined) delete merged.contextBudget;
90
128
  if (!Number.isFinite(merged.reserve) || merged.reserve < 0) {
91
129
  throw new Error(`reserve must be a nonnegative number of tokens, got ${String(merged.reserve)}`);
92
130
  }
@@ -97,25 +135,35 @@ export function resolveBudgetPolicy(overrides: Partial<BudgetPolicyConfig> | und
97
135
  }
98
136
  }
99
137
  fractions.sort((left, right) => left - right);
138
+ if (!Number.isFinite(merged.reminderCooldown) || merged.reminderCooldown < 0 || merged.reminderCooldown >= 1) {
139
+ throw new Error(`reminderCooldown must be in [0, 1) (0 = off), got ${String(merged.reminderCooldown)}`);
140
+ }
100
141
  return { ...merged, remindAtFractions: fractions };
101
142
  }
102
143
 
103
144
  /**
104
- * Effective budget: the configured budget (default 32,000), capped by the model's context
105
- * window minus its output allowance when that is smaller. `undefined` contextBudget means
106
- * "use the window". An output limit that is not finite, <= 0, or >= the window is ignored.
145
+ * Effective budget against the model window minus its output allowance (the base): a token
146
+ * count, capped by the base when that is smaller; else `contextFraction` of the base
147
+ * (default 50%), or `FALLBACK_BUDGET` with the window unknown; else the base itself. An
148
+ * output limit that is not finite, <= 0, or >= the window is ignored. Undefined only for
149
+ * the `window` setting with the window unknown.
107
150
  */
108
151
  export function resolveBudget(
109
152
  policy: BudgetPolicyConfig,
110
153
  modelContextWindow: number | undefined,
111
154
  modelOutputLimit = 0,
112
- ): Pick<BudgetReading, "budget" | "reserve" | "source"> | undefined {
155
+ ): Pick<BudgetReading, "budget" | "reserve" | "source" | "fraction"> | undefined {
113
156
  const window = modelContextWindow !== undefined && Number.isFinite(modelContextWindow) && modelContextWindow > 0
114
157
  ? modelContextWindow - (Number.isFinite(modelOutputLimit) && modelOutputLimit > 0 && modelOutputLimit < modelContextWindow ? modelOutputLimit : 0)
115
158
  : undefined;
116
159
  if (policy.contextBudget !== undefined && (window === undefined || policy.contextBudget <= window)) {
117
160
  return { budget: policy.contextBudget, reserve: Math.min(policy.reserve, policy.contextBudget), source: "config" };
118
161
  }
162
+ const fraction = policy.contextBudget === undefined ? policy.contextFraction : undefined;
163
+ if (fraction !== undefined) {
164
+ const budget = window !== undefined ? Math.max(1, Math.floor(window * fraction)) : FALLBACK_BUDGET;
165
+ return { budget, reserve: Math.min(policy.reserve, budget), source: window !== undefined ? "window-fraction" : "fallback", fraction };
166
+ }
119
167
  if (window !== undefined) {
120
168
  return { budget: window, reserve: Math.min(policy.reserve, window), source: "model-window" };
121
169
  }
@@ -202,7 +250,7 @@ export function budgetFitLine(fit: BudgetFit): string {
202
250
  * The one-time model notice for a budget that cannot hold OpenCode's fixed overhead plus a
203
251
  * working margin. Text written for this package.
204
252
  */
205
- export function budgetTooSmallNoticeText(fit: BudgetFit): string {
253
+ export function budgetTooSmallNoticeText(fit: BudgetFit, options: NoticeOptions = {}): string {
206
254
  const head =
207
255
  `[CLM BUDGET] The configured budget of ${formatTokens(fit.configured)} tokens is too small for this session: ` +
208
256
  `OpenCode's system prompt and tool schemas take about ${formatTokens(fit.overhead ?? 0)} tokens of every request, ` +
@@ -212,7 +260,9 @@ export function budgetTooSmallNoticeText(fit: BudgetFit): string {
212
260
  const raise = fit.raised ? `CLM raised this session's budget to ${formatTokens(fit.effective)} tokens, the most the model window allows, ` : "The model window allows no larger budget, ";
213
261
  return (
214
262
  `${head} ${raise}which still leaves only ${left} tokens for the conversation. ` +
215
- "The overflow guard will hold back most tool results: keep the context mirror short and read files in small parts."
263
+ (options.noticesOnly
264
+ ? "The overflow guard will hold back most tool results: read files in small parts."
265
+ : "The overflow guard will hold back most tool results: keep the context mirror short and read files in small parts.")
216
266
  );
217
267
  }
218
268
  return (
@@ -252,35 +302,93 @@ export function governingTokens(reading: Pick<BudgetReading, "estimated">): numb
252
302
  return reading.estimated;
253
303
  }
254
304
 
305
+ /** The outcome of one `BudgetTracker.check`. */
306
+ export interface BudgetCheck {
307
+ /** The highest newly crossed tier to announce, if any. */
308
+ tier?: BudgetTier;
309
+ /** Tiers crossed during a reminder cooldown: consumed without a notice. */
310
+ suppressed: BudgetTier[];
311
+ /** Governing size at which the active cooldown ends; undefined without one. */
312
+ cooldownUntil?: number;
313
+ }
314
+
255
315
  /**
256
316
  * Escalating, re-arming tier tracker over absolute token thresholds. Re-derives its state
257
317
  * when the tier set changes (for example after the budget changes).
318
+ *
319
+ * Reminder cooldown: `editAccepted()` marks an accepted context edit. The next check records
320
+ * the governing size E; until the size reaches E + cooldown × budget, crossed percentage tiers
321
+ * are marked fired without a notice (consumed, not deferred). The budget-reserve tier is
322
+ * exempt. A later accepted edit restarts the cooldown; a reset, `clearCooldown` or a tier change ends it.
258
323
  */
259
324
  export class BudgetTracker {
260
325
  private fired = new Set<string>();
261
326
  private tierKey = "";
327
+ /** An edit was accepted and the next check has not yet recorded its size. */
328
+ private cooldownPending = false;
329
+ /** Governing size recorded after the last accepted edit, while the cooldown runs. */
330
+ private cooldownStart: number | undefined;
262
331
 
263
332
  reset(): void {
264
333
  this.fired.clear();
334
+ this.clearCooldown();
335
+ }
336
+
337
+ /** End the reminder cooldown; fired tiers stay fired. */
338
+ clearCooldown(): void {
339
+ this.cooldownPending = false;
340
+ this.cooldownStart = undefined;
341
+ }
342
+
343
+ /** Start (or restart) the reminder cooldown from the next reading. */
344
+ editAccepted(): void {
345
+ this.cooldownPending = true;
346
+ this.cooldownStart = undefined;
265
347
  }
266
348
 
267
349
  /** Returns the highest newly crossed tier, or undefined when nothing new fired. */
268
- observe(reading: BudgetReading, tiers: readonly BudgetTier[]): BudgetTier | undefined {
350
+ observe(reading: BudgetReading, tiers: readonly BudgetTier[], cooldown = 0): BudgetTier | undefined {
351
+ return this.check(reading, tiers, cooldown).tier;
352
+ }
353
+
354
+ /** `observe` with the tiers a cooldown suppressed. `cooldown`: share of the budget, 0 = off. */
355
+ check(reading: BudgetReading, tiers: readonly BudgetTier[], cooldown = 0): BudgetCheck {
269
356
  const key = tiers.map((tier) => `${tier.label}:${tier.tokens}`).join("|");
270
357
  if (key !== this.tierKey) {
271
358
  this.tierKey = key;
272
359
  this.fired.clear();
360
+ this.cooldownStart = undefined;
273
361
  }
274
362
  const tokens = governingTokens(reading);
275
363
  this.fired = new Set([...this.fired].filter((label) => {
276
364
  const tier = tiers.find((candidate) => candidate.label === label);
277
365
  return tier !== undefined && tokens >= tier.tokens;
278
366
  }));
279
- const crossed = tiers.filter((tier) => tokens >= tier.tokens && !this.fired.has(tier.label));
280
- if (crossed.length === 0) return undefined;
367
+ if (this.cooldownPending) {
368
+ this.cooldownPending = false;
369
+ this.cooldownStart = tokens;
370
+ }
371
+ let cooldownUntil: number | undefined;
372
+ if (this.cooldownStart !== undefined) {
373
+ const until = this.cooldownStart + cooldown * reading.budget;
374
+ // cooldown 0 (off, possibly set mid-cooldown) ends it outright.
375
+ if (cooldown > 0 && tokens < until) cooldownUntil = until;
376
+ else this.cooldownStart = undefined;
377
+ }
378
+ let crossed = tiers.filter((tier) => tokens >= tier.tokens && !this.fired.has(tier.label));
379
+ const suppressed: BudgetTier[] = [];
380
+ if (cooldownUntil !== undefined) {
381
+ for (const tier of crossed) {
382
+ if (tier.label === "budget-reserve") continue;
383
+ suppressed.push(tier);
384
+ this.fired.add(tier.label);
385
+ }
386
+ crossed = crossed.filter((tier) => tier.label === "budget-reserve");
387
+ }
388
+ if (crossed.length === 0) return { suppressed, cooldownUntil };
281
389
  const top = crossed[crossed.length - 1]!;
282
390
  for (const tier of tiers) if (tier.tokens <= top.tokens) this.fired.add(tier.label);
283
- return top;
391
+ return { tier: top, suppressed, cooldownUntil };
284
392
  }
285
393
  }
286
394
 
@@ -293,11 +401,27 @@ function calibrated(reading: Pick<BudgetReading, "calibration">): boolean {
293
401
  return reading.calibration !== undefined && reading.calibration > 1.005;
294
402
  }
295
403
 
404
+ /** `0.5` → `50%`, `0.125` → `12.5%`. */
405
+ export function formatPercent(fraction: number): string {
406
+ return `${Number((fraction * 100).toFixed(2))}%`;
407
+ }
408
+
409
+ /** Short origin label of a budget: `configured`, `model window`, `50% of model window`, … */
410
+ export function budgetOrigin(reading: Pick<BudgetReading, "source" | "fraction">): string {
411
+ const percent = reading.fraction !== undefined ? formatPercent(reading.fraction) : "";
412
+ switch (reading.source) {
413
+ case "config": return "configured";
414
+ case "model-window": return "model window";
415
+ case "window-fraction": return `${percent} of model window`;
416
+ case "fallback": return `fallback, ${percent} of an unknown model window`;
417
+ }
418
+ }
419
+
296
420
  /** One line for status text: budget, estimate, and observation, each labelled. */
297
421
  export function budgetSummaryLine(reading: BudgetReading): string {
298
422
  const origin = reading.raisedFrom !== undefined
299
423
  ? `raised from ${formatTokens(reading.raisedFrom)} to cover the fixed overhead`
300
- : reading.source === "config" ? "configured" : "model window";
424
+ : budgetOrigin(reading);
301
425
  const parts = [
302
426
  `budget ${formatTokens(reading.budget)} tok (${origin}, reserve ${formatTokens(reading.reserve)})`,
303
427
  `estimated next request ${formatTokens(reading.estimated)}${calibrated(reading) ? ` (×${reading.calibration!.toFixed(2)} calibrated)` : ""}`,
@@ -310,17 +434,24 @@ export function budgetSummaryLine(reading: BudgetReading): string {
310
434
  return parts.join(" · ");
311
435
  }
312
436
 
437
+ /** Model-facing notice variants. `noticesOnly`: the session runs `mode notices-only`, so no text may point the model at a mirror. */
438
+ export interface NoticeOptions {
439
+ noticesOnly?: boolean;
440
+ }
441
+
313
442
  /**
314
443
  * The model-facing reminder. States the estimate and the provider count separately, the
315
444
  * budget and the tokens left, the mirror path, and, from the third tier up, what the
316
445
  * overflow guard does above `overflowLimit` (default budget - reserve). Pass `null` when the
317
- * guard is off: the notice then leaves the guard out.
446
+ * guard is off: the notice then leaves the guard out. `noticesOnly` keeps the measurement,
447
+ * the budget, the tokens left and the guard sentence, and drops every invitation to edit.
318
448
  */
319
449
  export function budgetNoticeText(
320
450
  reading: BudgetReading,
321
451
  tier: BudgetTier,
322
452
  mirrorPath: string | undefined,
323
453
  overflowLimit: number | null = reading.budget - reading.reserve,
454
+ options: NoticeOptions = {},
324
455
  ): string {
325
456
  const governing = governingTokens(reading);
326
457
  const remaining = Math.max(0, reading.budget - governing);
@@ -334,6 +465,19 @@ export function budgetNoticeText(
334
465
  const overflow = overflowLimit === null
335
466
  ? ""
336
467
  : ` Above ${formatTokens(overflowLimit)} tokens the overflow guard holds back the oldest new tool results and saves them to files.`;
468
+ if (options.noticesOnly) {
469
+ const fraction = tier.tokens / reading.budget;
470
+ if (tier.label === "budget-reserve") {
471
+ return (
472
+ `[CLM BUDGET] Context is at ${formatTokens(governing)} of a ${formatTokens(reading.budget)}-token budget ` +
473
+ `(${measurement}). Only ${formatTokens(remaining)} tokens remain, which is inside the ${formatTokens(reading.reserve)}-token generation reserve.${overflow}`
474
+ );
475
+ }
476
+ const base =
477
+ `[CLM BUDGET] Context crossed ${tier.label} of a ${formatTokens(reading.budget)}-token budget: ${measurement}. ` +
478
+ `${formatTokens(remaining)} tokens remain; the final reminder comes at ${formatTokens(reading.budget - reading.reserve)} tokens.`;
479
+ return fraction <= 0.5 ? base : `${base}${overflow}`;
480
+ }
337
481
  if (tier.label === "budget-reserve") {
338
482
  return (
339
483
  `[CLM BUDGET] Context is at ${formatTokens(governing)} of a ${formatTokens(reading.budget)}-token budget ` +
package/src/channel.ts ADDED
@@ -0,0 +1,187 @@
1
+ // The turn-free channel between the TUI plugin and the server plugin. Both sides speak
2
+ // through OpenCode's `tui.command.execute` bus event: `POST /tui/publish` accepts any
3
+ // command string, the server publishes it on the instance bus, and every listener sees it:
4
+ // the server plugin's `event` hook, each TUI's own `api.event` and the TUI app, which
5
+ // dispatches the string as a keymap command (an unknown name is a no-op). Requests and
6
+ // replies are prefixed, base64url-encoded JSON, so they carry no spaces and never collide
7
+ // with a real keymap command. Written for this package. Verified against opencode 1.18.34:
8
+ // publish handler server/routes/instance/httpapi/handlers/tui.ts:84-93; command schema
9
+ // packages/schema/src/tui-event.ts:13-37; plugin `event` hook plugin/index.ts:255-260 (events
10
+ // of the plugin's own directory only); TUI app dispatch packages/tui/src/app.tsx:987-990.
11
+
12
+ /** `tui.command.execute` event type (opencode packages/schema/src/tui-event.ts). */
13
+ export const COMMAND_EVENT = "tui.command.execute";
14
+ export const REQUEST_PREFIX = "opencode-clm.request:";
15
+ export const REPLY_PREFIX = "opencode-clm.reply:";
16
+ export const CHANNEL_VERSION = 1;
17
+
18
+ export type ChannelOperation =
19
+ /** Where the server keeps the session's files. Answer: `directory` and `root`. */
20
+ | { op: "locate" }
21
+ /** `/clm config <setting> <value>`, `/clm on|off` and the panel's apply. */
22
+ | { op: "set"; setting: string; value: string }
23
+ /** `/clm config reset`. */
24
+ | { op: "settings-reset" }
25
+ /** `/clm reset`: drop the accepted revision. */
26
+ | { op: "reset" }
27
+ /** A file of the session directory (`path` relative to it). Answer: `content`, absent when missing. */
28
+ | { op: "read"; path: string }
29
+ /** A directory of the session directory (`""` = itself). Answer: `names`, absent when missing. */
30
+ | { op: "list"; path: string };
31
+
32
+ /** Largest file a `read` reply carries; replies travel on the instance bus to every client. */
33
+ export const MAX_READ_BYTES = 4 * 1024 * 1024;
34
+
35
+ export type ChannelRequest = ChannelOperation & { v: typeof CHANNEL_VERSION; id: string; session: string };
36
+
37
+ export interface ChannelReply {
38
+ v: typeof CHANNEL_VERSION;
39
+ id: string;
40
+ ok: boolean;
41
+ /** User-facing result or error. */
42
+ text: string;
43
+ /** `locate`: the session directory on the server. */
44
+ directory?: string;
45
+ /** `locate`: the server's instance directory, the root `file.read` paths are relative to. */
46
+ root?: string;
47
+ /** `read`: the file text. */
48
+ content?: string;
49
+ /** `list`: the entry names. */
50
+ names?: string[];
51
+ }
52
+
53
+ function encode(prefix: string, value: unknown): string {
54
+ return prefix + Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
55
+ }
56
+
57
+ function decode(prefix: string, command: unknown): Record<string, unknown> | undefined {
58
+ if (typeof command !== "string" || !command.startsWith(prefix)) return undefined;
59
+ try {
60
+ const value: unknown = JSON.parse(Buffer.from(command.slice(prefix.length), "base64url").toString("utf8"));
61
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
62
+ const record = value as Record<string, unknown>;
63
+ if (record.v !== CHANNEL_VERSION || typeof record.id !== "string" || record.id === "") return undefined;
64
+ return record;
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ export function encodeRequest(request: ChannelRequest): string {
71
+ return encode(REQUEST_PREFIX, request);
72
+ }
73
+
74
+ /** The request in a command string; undefined for anything else (foreign or malformed). */
75
+ export function decodeRequest(command: unknown): ChannelRequest | undefined {
76
+ const record = decode(REQUEST_PREFIX, command);
77
+ if (!record || typeof record.session !== "string" || record.session === "") return undefined;
78
+ switch (record.op) {
79
+ case "locate":
80
+ case "settings-reset":
81
+ case "reset":
82
+ return { v: CHANNEL_VERSION, id: record.id as string, session: record.session, op: record.op };
83
+ case "read":
84
+ case "list":
85
+ if (typeof record.path !== "string") return undefined;
86
+ return { v: CHANNEL_VERSION, id: record.id as string, session: record.session, op: record.op, path: record.path };
87
+ case "set":
88
+ if (typeof record.setting !== "string" || typeof record.value !== "string") return undefined;
89
+ return { v: CHANNEL_VERSION, id: record.id as string, session: record.session, op: "set", setting: record.setting, value: record.value };
90
+ default:
91
+ return undefined;
92
+ }
93
+ }
94
+
95
+ export function encodeReply(reply: ChannelReply): string {
96
+ return encode(REPLY_PREFIX, reply);
97
+ }
98
+
99
+ export function decodeReply(command: unknown): ChannelReply | undefined {
100
+ const record = decode(REPLY_PREFIX, command);
101
+ if (!record || typeof record.ok !== "boolean" || typeof record.text !== "string") return undefined;
102
+ return {
103
+ v: CHANNEL_VERSION,
104
+ id: record.id as string,
105
+ ok: record.ok,
106
+ text: record.text,
107
+ ...(typeof record.directory === "string" ? { directory: record.directory } : {}),
108
+ ...(typeof record.root === "string" ? { root: record.root } : {}),
109
+ ...(typeof record.content === "string" ? { content: record.content } : {}),
110
+ ...(Array.isArray(record.names) && record.names.every((name) => typeof name === "string") ? { names: record.names as string[] } : {}),
111
+ };
112
+ }
113
+
114
+ /** The command string of a `tui.command.execute` event, if the value is one. */
115
+ export function commandOf(event: unknown): string | undefined {
116
+ if (!event || typeof event !== "object") return undefined;
117
+ const { type, properties } = event as { type?: unknown; properties?: { command?: unknown } };
118
+ if (type !== COMMAND_EVENT) return undefined;
119
+ return typeof properties?.command === "string" ? properties.command : undefined;
120
+ }
121
+
122
+ /** The TUI side: send a request, wait for the reply with the same id. */
123
+ export interface ChannelClientDeps {
124
+ /** Publishes a `tui.command.execute` event with this command string. */
125
+ publish(command: string): Promise<void>;
126
+ /** Subscribes to `tui.command.execute` command strings; returns the unsubscribe. */
127
+ subscribe(handler: (command: string) => void): () => void;
128
+ timeoutMs?: number;
129
+ /** Request ids; random by default. */
130
+ newId?: () => string;
131
+ }
132
+
133
+ export interface ChannelClient {
134
+ /** `timeoutMs` overrides the client's default for this request. */
135
+ request(session: string, operation: ChannelOperation, timeoutMs?: number): Promise<ChannelReply>;
136
+ dispose(): void;
137
+ }
138
+
139
+ export class ChannelTimeout extends Error {
140
+ constructor(readonly timeoutMs: number) {
141
+ super(
142
+ `the opencode-clm server plugin did not answer within ${Math.round(timeoutMs / 100) / 10} s: the server does not load ` +
143
+ "opencode-clm, loads an older version without the TUI channel, or has CLM disabled (enabled: false)",
144
+ );
145
+ }
146
+ }
147
+
148
+ export const CHANNEL_TIMEOUT_MS = 5_000;
149
+ /** `locate` is asked on panel loads; a short wait keeps a missing server plugin from stalling them. */
150
+ export const LOCATE_TIMEOUT_MS = 1_000;
151
+
152
+ export function createChannelClient(deps: ChannelClientDeps): ChannelClient {
153
+ const pending = new Map<string, (reply: ChannelReply) => void>();
154
+ const unsubscribe = deps.subscribe((command) => {
155
+ const reply = decodeReply(command);
156
+ if (!reply) return;
157
+ pending.get(reply.id)?.(reply);
158
+ });
159
+ const newId = deps.newId ?? (() => crypto.randomUUID());
160
+ const defaultTimeoutMs = deps.timeoutMs ?? CHANNEL_TIMEOUT_MS;
161
+ return {
162
+ request(session, operation, timeoutMs = defaultTimeoutMs) {
163
+ const id = newId();
164
+ const request = { ...operation, v: CHANNEL_VERSION, id, session } as ChannelRequest;
165
+ return new Promise<ChannelReply>((resolve, reject) => {
166
+ const timer = setTimeout(() => {
167
+ pending.delete(id);
168
+ reject(new ChannelTimeout(timeoutMs));
169
+ }, timeoutMs);
170
+ pending.set(id, (reply) => {
171
+ clearTimeout(timer);
172
+ pending.delete(id);
173
+ resolve(reply);
174
+ });
175
+ deps.publish(encodeRequest(request)).catch((error: unknown) => {
176
+ clearTimeout(timer);
177
+ pending.delete(id);
178
+ reject(error instanceof Error ? error : new Error(String(error)));
179
+ });
180
+ });
181
+ },
182
+ dispose() {
183
+ unsubscribe();
184
+ pending.clear();
185
+ },
186
+ };
187
+ }