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/src/tui/plugin.ts CHANGED
@@ -6,16 +6,20 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui";
6
6
 
7
7
  import { userOwnsStatusCommand } from "../commands.ts";
8
8
  import { type ClmCommand, type Page } from "../panel/command.ts";
9
- import { changeSetting, resetSettings, sessionValues, showSetting } from "../overrides.ts";
9
+ import { changeSetting, overridesValues, resetSettings, sessionValues, showSetting } from "../overrides.ts";
10
+ import { COMMAND_EVENT, commandOf, createChannelClient, type ChannelOperation, type ChannelReply } from "../channel.ts";
11
+ import { createLocator, resetSession } from "./locator.ts";
12
+ import { outsideMessage, resolveSource, sourceOverrides, type FileApi, type SessionSource } from "./remote.ts";
10
13
  import type { SettingsValues } from "../settings-table.ts";
14
+ import { interceptTab } from "./complete.ts";
11
15
  import { interceptEnter } from "./intercept.ts";
12
16
  import { buildPanelModel, type PanelModel } from "../panel/model.ts";
13
17
  import { formatTokenCount } from "../panel/timeline.ts";
14
- import { readSessionFiles, sessionDirectory } from "../session-files.ts";
18
+ import { readSessionDirectory, sessionDirectory, type SessionReader } from "../session-files.ts";
15
19
  import { resolveSettings, type ClmSettings } from "../settings.ts";
16
- import { fallbackBudget, latestUsage, modelLimits, overridesNewer, serverBase, serverPluginOptions, settingsView } from "./data.ts";
20
+ import { fallbackBudget, footerText, latestUsage, modelLimits, overridesNewer, RESET_NOW, RESET_ROW, resetRow, serverBase, serverPluginOptions, settingsView } from "./data.ts";
17
21
  import type { SessionFiles } from "../panel/files.ts";
18
- import { createPanel, type PanelController } from "./panel.ts";
22
+ import { createFooter, createPanel, type PanelController } from "./panel.ts";
19
23
 
20
24
  export const PANEL_ROUTE = "opencode-clm.panel";
21
25
  const OPEN_COMMAND = "opencode-clm.panel.open";
@@ -24,6 +28,15 @@ const RELOAD_DEBOUNCE_MS = 250;
24
28
  const PACKAGE_INDEX_URL = new URL("../../index.ts", import.meta.url).href;
25
29
 
26
30
  const PAGES = new Set<Page>(["overview", "input", "edits", "settings"]);
31
+ /** `/clm path` names how this TUI reads the files. */
32
+ const SOURCE_LABEL: Record<SessionSource["kind"], string> = {
33
+ local: "local disk",
34
+ remote: "server file API",
35
+ channel: "server plugin channel",
36
+ outside: "unreadable from this TUI",
37
+ };
38
+ /** Reads nothing: a session directory the attached TUI cannot reach. */
39
+ const NO_READER: SessionReader = { name: "none", isDirectory: async () => false, readText: async () => undefined, list: async () => [], stamp: async () => undefined };
27
40
 
28
41
  function describe(error: unknown): string {
29
42
  return error instanceof Error ? error.message : String(error);
@@ -40,6 +53,60 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
40
53
  return resolveSettings(options, process.env, api.state.path.directory);
41
54
  };
42
55
 
56
+ // ---- the channel to the server plugin (src/channel.ts) --------------------------------
57
+ const channel = createChannelClient({
58
+ publish: async (command) => {
59
+ const result = await api.client.tui.publish({ body: { type: COMMAND_EVENT, properties: { command } } });
60
+ if (result.error !== undefined) throw new Error(`publishing to the server failed: ${JSON.stringify(result.error)}`);
61
+ },
62
+ subscribe: (handler) => api.event.on(COMMAND_EVENT, (event) => {
63
+ const command = commandOf(event);
64
+ if (command !== undefined) handler(command);
65
+ }),
66
+ });
67
+
68
+ /** Sends one operation; throws the server's error text, or a timeout. */
69
+ const ask = async (sessionID: string, operation: ChannelOperation, timeoutMs?: number): Promise<ChannelReply> => {
70
+ const reply = await channel.request(sessionID, operation, timeoutMs);
71
+ if (!reply.ok) throw new Error(reply.text);
72
+ return reply;
73
+ };
74
+
75
+ /** Where the server keeps each session's files (`locate`), cached; see locator.ts. */
76
+ const locator = createLocator({ ask });
77
+
78
+ /** When this TUI last changed a session's overrides through the channel (no mtime remotely). */
79
+ const channelWrites = new Map<string, number>();
80
+
81
+ /** `write`: the source a setting change goes to (never the read-only same-disk guess). */
82
+ const source = (sessionID: string, options: { write?: boolean } = {}): Promise<SessionSource> => resolveSource({
83
+ ownDirectory: sessionDirectory(settings().mirrorDir, sessionID),
84
+ serverRoot: api.state.path.directory,
85
+ file: api.client.file as unknown as FileApi,
86
+ locate: () => locator.locate(sessionID),
87
+ located: () => locator.fresh(sessionID),
88
+ relocate: () => {
89
+ const own = sessionDirectory(settings().mirrorDir, sessionID);
90
+ // The server keeps this session's files elsewhere: show them.
91
+ locator.relocate(sessionID, (value) => {
92
+ if (value.directory !== own) refreshPanel(sessionID);
93
+ });
94
+ },
95
+ ask: (operation) => ask(sessionID, operation),
96
+ }, options);
97
+
98
+ const readFiles = async (sessionID: string, from: SessionSource): Promise<SessionFiles> => {
99
+ if (from.kind === "outside") {
100
+ const files = await readSessionDirectory(from.directory, sessionID, NO_READER);
101
+ files.warnings.push(outsideMessage(from));
102
+ return files;
103
+ }
104
+ const files = await readSessionDirectory(from.directory, sessionID, from.reader);
105
+ // A read through the server failed: ask where the files are again next time.
106
+ if (from.kind !== "local" && files.warnings.some((warning) => warning.startsWith("Could not"))) locator.forget(sessionID);
107
+ return files;
108
+ };
109
+
43
110
  /**
44
111
  * Base and effective settings of a session. The server is the authority on the base:
45
112
  * once it wrote snapshot.json, the base is the server's (`serverBase`), so a TUI whose
@@ -47,48 +114,73 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
47
114
  * Before the first request, the TUI's own resolution of the server's options stands in.
48
115
  * Editing base = state.json's `enabled`. Effective = base + the session's overrides.json.
49
116
  */
50
- const sessionSettings = async (sessionID: string, files: Pick<SessionFiles, "state" | "snapshot">) => {
117
+ const sessionSettings = async (sessionID: string, from: SessionSource, files: Pick<SessionFiles, "state" | "snapshot">) => {
51
118
  const own = settings();
52
- const { base, source } = serverBase(own, files.snapshot);
53
- const directory = sessionDirectory(own.mirrorDir, sessionID);
119
+ const { base, source: baseSource } = serverBase(own, files.snapshot);
54
120
  const stored = files.state && typeof files.state === "object" ? (files.state as { enabled?: unknown }).enabled : undefined;
55
121
  const baseEditing = typeof stored === "boolean" ? stored : true;
56
- const read = await sessionValues(directory, base, baseEditing);
122
+ const read = from.kind === "local"
123
+ ? await sessionValues(from.directory, base, baseEditing)
124
+ : overridesValues(await sourceOverrides(from), base, baseEditing, channelWrites.get(sessionID));
57
125
  const values: { base: SettingsValues; effective: SettingsValues } = { base: { editing: baseEditing, settings: base }, effective: read.values };
58
- return { base, baseSource: source, baseEditing, directory, values, ...(read.warning ? { warning: read.warning } : {}), ...(read.mtimeMs !== undefined ? { overridesAt: read.mtimeMs } : {}) };
126
+ return { base, baseSource, baseEditing, directory: from.directory, values, ...(read.warning ? { warning: read.warning } : {}), ...(read.mtimeMs !== undefined ? { overridesAt: read.mtimeMs } : {}) };
59
127
  };
60
128
 
61
129
  const loadModel = async (sessionID: string): Promise<PanelModel> => {
62
- const resolved = settings();
63
- const files = await readSessionFiles(resolved.mirrorDir, sessionID);
130
+ const from = await source(sessionID);
131
+ const files = await readFiles(sessionID, from);
64
132
  const messages = api.state.session.messages(sessionID);
65
133
  const latest = latestUsage(messages);
66
134
  const limits = modelLimits(messages, api.state.provider);
67
- const current = await sessionSettings(sessionID, files);
135
+ const current = await sessionSettings(sessionID, from, files);
68
136
  const budget = fallbackBudget(current.values.effective.settings, limits);
69
137
  // overrides.json written after the last request: its budget is in force from the next
70
138
  // request, so the panel shows it rather than the snapshot's.
71
139
  const preferSettings = overridesNewer(current.overridesAt, files.snapshot);
72
140
  const model = buildPanelModel(files, { ...(latest ? { latest } : {}), ...(budget ? { budget } : {}), ...(preferSettings ? { preferSettings } : {}) });
73
141
  model.enabled = current.values.effective.editing;
142
+ if (current.values.effective.settings.mode === "notices-only") model.noticesOnly = true;
74
143
  model.settings = settingsView(current.values, model, {
75
- format: { ...(limits.context ? { modelWindow: limits.context } : {}) },
144
+ format: { ...(limits.context ? { modelWindow: limits.context } : {}), ...(limits.output ? { modelOutput: limits.output } : {}) },
76
145
  ...(current.warning ? { warning: current.warning } : {}),
77
146
  });
147
+ model.settings.rows.push(resetRow(model.settings.changed.length));
78
148
  return model;
79
149
  };
80
150
 
81
- /** Validates and writes one setting to the session's overrides.json; returns `Label: value`. */
82
- const applySetting = async (sessionID: string, name: string, value: string): Promise<string> => {
83
- const files = await readSessionFiles(settings().mirrorDir, sessionID);
84
- const current = await sessionSettings(sessionID, files);
151
+ /** Drops the session's settings changes, locally or through the server. */
152
+ const resetSessionSettings = async (sessionID: string): Promise<{ viaServer: boolean }> => {
153
+ const from = await source(sessionID, { write: true });
154
+ if (from.kind === "local") {
155
+ await resetSettings(from.directory);
156
+ return { viaServer: false };
157
+ }
158
+ await ask(sessionID, { op: "settings-reset" });
159
+ channelWrites.set(sessionID, Date.now());
160
+ return { viaServer: true };
161
+ };
162
+
163
+ /**
164
+ * Validates and saves one setting; returns `Label: value` and whether the server
165
+ * confirmed it with its own toast. Local files: written here (overrides.json, which the
166
+ * server reads before each request). Otherwise the server applies it over the channel.
167
+ */
168
+ const applySetting = async (sessionID: string, name: string, value: string): Promise<{ text: string; viaServer: boolean }> => {
169
+ const from = await source(sessionID, { write: true });
170
+ if (from.kind !== "local") {
171
+ const reply = await ask(sessionID, { op: "set", setting: name, value });
172
+ channelWrites.set(sessionID, Date.now());
173
+ return { text: reply.text, viaServer: true };
174
+ }
175
+ const files = await readFiles(sessionID, from);
176
+ const current = await sessionSettings(sessionID, from, files);
85
177
  const result = await changeSetting({
86
178
  sessionDirectory: current.directory,
87
179
  base: current.base,
88
180
  baseEditing: current.baseEditing,
89
181
  projectDirectory: api.state.path.directory,
90
182
  }, name, value);
91
- return result.text;
183
+ return { text: result.text, viaServer: false };
92
184
  };
93
185
 
94
186
  /** False when `/clm` belongs to someone else: `commands: false`, or a user-defined command. */
@@ -125,10 +217,26 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
125
217
  active?.controller.dispose();
126
218
  const controller = createPanel(api, {
127
219
  page,
128
- load: () => loadModel(sessionID).catch((error: unknown) => errorModel(sessionID, error)),
220
+ // The footer of this session takes the panel's model instead of reading the files again.
221
+ load: () => loadModel(sessionID).then(
222
+ (model) => {
223
+ showFooter(sessionID, model);
224
+ return model;
225
+ },
226
+ (error: unknown) => {
227
+ showFooter(sessionID, undefined);
228
+ return errorModel(sessionID, error);
229
+ },
230
+ ),
129
231
  apply: async (setting, value) => {
130
232
  try {
233
+ if (setting === RESET_ROW) {
234
+ if (value === RESET_NOW) await resetSessionSettings(sessionID);
235
+ refreshFooter(sessionID);
236
+ return undefined;
237
+ }
131
238
  await applySetting(sessionID, setting, value);
239
+ refreshFooter(sessionID);
132
240
  return undefined;
133
241
  } catch (error) {
134
242
  return describe(error);
@@ -168,10 +276,11 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
168
276
  /** After a change: refresh an open panel so it shows the new value. */
169
277
  const refreshPanel = (sessionID: string) => {
170
278
  if (active?.sessionID === sessionID) void active.controller.reload();
279
+ refreshFooter(sessionID);
171
280
  };
172
281
 
173
- /** Every `/clm …` line the intercept consumes (`/clm reset` goes to the server). */
174
- const handle = async (command: Exclude<ClmCommand, { kind: "server" }>): Promise<void> => {
282
+ /** Every `/clm …` line the intercept consumes (`/clm reset` goes to the server over the channel). */
283
+ const handle = async (command: ClmCommand): Promise<void> => {
175
284
  if (command.kind === "open") {
176
285
  open(command.page);
177
286
  return;
@@ -186,64 +295,142 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
186
295
  return;
187
296
  }
188
297
  switch (command.kind) {
189
- case "path":
190
- toast(sessionDirectory(settings().mirrorDir, sessionID));
298
+ case "path": {
299
+ const from = await source(sessionID);
300
+ toast(`${from.directory} (${SOURCE_LABEL[from.kind]})`);
191
301
  return;
302
+ }
192
303
  case "status":
193
304
  toast(statusSummary(await loadModel(sessionID)));
194
305
  return;
195
306
  case "config-show": {
196
- const files = await readSessionFiles(settings().mirrorDir, sessionID);
197
- const current = await sessionSettings(sessionID, files);
307
+ const from = await source(sessionID);
308
+ const current = await sessionSettings(sessionID, from, await readFiles(sessionID, from));
198
309
  toast(showSetting(command.setting, current.values.effective));
199
310
  return;
200
311
  }
201
312
  case "config-set":
202
313
  try {
203
- toast(`CLM ${await applySetting(sessionID, command.setting, command.value)}.`);
314
+ const result = await applySetting(sessionID, command.setting, command.value);
315
+ if (!result.viaServer) toast(`CLM ${result.text}.`);
204
316
  } catch (error) {
205
317
  toast(describe(error), "warning");
206
318
  }
207
319
  refreshPanel(sessionID);
208
320
  return;
209
- case "config-reset":
210
- await resetSettings(sessionDirectory(settings().mirrorDir, sessionID));
211
- toast("CLM settings reset to the defaults.");
321
+ case "config-reset": {
322
+ if (!(await resetSessionSettings(sessionID)).viaServer) toast("CLM settings reset to the defaults.");
212
323
  refreshPanel(sessionID);
213
324
  return;
214
- case "enable":
325
+ }
326
+ case "enable": {
215
327
  // The same override as the server's `/clm on|off` and `/clm config editing`.
216
- await applySetting(sessionID, "editing", command.enabled ? "on" : "off");
217
- toast(`CLM ${command.enabled ? "on" : "off"} for this session`);
328
+ const result = await applySetting(sessionID, "editing", command.enabled ? "on" : "off");
329
+ if (!result.viaServer) toast(`CLM ${command.enabled ? "on" : "off"} for this session`);
330
+ refreshPanel(sessionID);
331
+ return;
332
+ }
333
+ case "server":
334
+ // `/clm reset`: the server owns state.json; it drops the revision and toasts.
335
+ await resetSession({
336
+ sessionID,
337
+ ask,
338
+ command: (parameters) => api.client.session.command(parameters),
339
+ toast,
340
+ });
218
341
  refreshPanel(sessionID);
219
342
  return;
220
343
  }
221
344
  };
222
345
 
223
346
  const disposeIntercept = api.keymap.intercept("key", (context) => {
347
+ // Tab on a `/clm …` line completes it inline (OpenCode has no argument completion).
348
+ if (interceptTab(context as never, {
349
+ focused: () => api.renderer.currentFocusedRenderable as never,
350
+ owned,
351
+ show: (items) => toast(items.map((item) => `/clm ${item}`).join(" · ")),
352
+ })) return;
224
353
  interceptEnter(context as never, {
225
354
  focused: () => api.renderer.currentFocusedRenderable as never,
226
355
  owned,
227
356
  handle,
357
+ serverChannel: () => true,
228
358
  report: (message) => toast(message, "warning"),
229
359
  });
230
360
  }, { priority: 100 });
231
361
 
362
+ // ---- footer: `clm 12k / 32k · r2` right of the session prompt (pi's status line) -----
363
+ /**
364
+ * One footer per session, reused while it is alive: the host's Solid slot calls the
365
+ * renderer again whenever its props change, and returning the same renderable keeps it
366
+ * from piling up new ones. Leaving the session view destroys it; the next render makes a
367
+ * new one.
368
+ */
369
+ const footers = new Map<string, ReturnType<typeof createFooter>>();
370
+ const footerTimers = new Map<string, ReturnType<typeof setTimeout>>();
371
+ /** The session's footer while it can still be drawn; the host destroys one it unmounts. */
372
+ const liveFooter = (sessionID: string) => {
373
+ const footer = footers.get(sessionID);
374
+ if (footer?.renderable.isDestroyed) footers.delete(sessionID);
375
+ return footer?.renderable.isDestroyed ? undefined : footer;
376
+ };
377
+ const showFooter = (sessionID: string, model: PanelModel | undefined) =>
378
+ liveFooter(sessionID)?.set(model ? footerText(model) : undefined);
379
+ const refreshFooter = (sessionID: string) => {
380
+ if (!liveFooter(sessionID)) return;
381
+ // An open panel for this session reloads its model and feeds the footer (above).
382
+ if (active?.sessionID === sessionID) return;
383
+ clearTimeout(footerTimers.get(sessionID));
384
+ footerTimers.set(sessionID, setTimeout(() => {
385
+ footerTimers.delete(sessionID);
386
+ void loadModel(sessionID).then(
387
+ (model) => showFooter(sessionID, model),
388
+ () => showFooter(sessionID, undefined),
389
+ );
390
+ }, RELOAD_DEBOUNCE_MS));
391
+ };
392
+ api.slots.register({
393
+ order: 50,
394
+ slots: {
395
+ session_prompt_right(_context, props) {
396
+ const sessionID = (props as { session_id?: unknown }).session_id;
397
+ if (typeof sessionID !== "string" || !settings().enabled) return null as never;
398
+ let footer = liveFooter(sessionID);
399
+ if (!footer) {
400
+ footer = createFooter(api, `opencode-clm-footer-${sessionID}`);
401
+ footers.set(sessionID, footer);
402
+ }
403
+ refreshFooter(sessionID);
404
+ // A raw renderable, as the panel route returns (accepted by the host's Solid insert).
405
+ return footer.renderable as never;
406
+ },
407
+ },
408
+ });
409
+
232
410
  const scheduleReload = (sessionID: unknown) => {
411
+ if (typeof sessionID === "string") refreshFooter(sessionID);
233
412
  if (!active || (typeof sessionID === "string" && sessionID !== active.sessionID)) return;
234
413
  clearTimeout(reloadTimer);
235
414
  reloadTimer = setTimeout(() => void active?.controller.reload(), RELOAD_DEBOUNCE_MS);
236
415
  };
237
416
  const offIdle = api.event.on("session.idle", (event) => scheduleReload((event.properties as { sessionID?: unknown }).sessionID));
238
- const offMessage = api.event.on("message.updated", (event) =>
239
- scheduleReload((event.properties as { info?: { sessionID?: unknown } }).info?.sessionID));
417
+ // Sizes change when a reply completes; streaming updates in between are skipped.
418
+ const offMessage = api.event.on("message.updated", (event) => {
419
+ const info = (event.properties as { info?: { sessionID?: unknown; role?: unknown; time?: { completed?: unknown } } }).info;
420
+ if (info?.role === "assistant" && info.time?.completed === undefined) return;
421
+ scheduleReload(info?.sessionID);
422
+ });
240
423
 
241
424
  api.lifecycle.onDispose(() => {
242
425
  clearTimeout(reloadTimer);
426
+ for (const timer of footerTimers.values()) clearTimeout(timer);
427
+ for (const footer of footers.values()) if (!footer.renderable.isDestroyed) footer.renderable.destroy();
428
+ footers.clear();
243
429
  active?.controller.dispose();
244
430
  active = undefined;
245
431
  offIdle();
246
432
  offMessage();
433
+ channel.dispose();
247
434
  disposeIntercept();
248
435
  disposeLayer();
249
436
  });
@@ -270,7 +457,7 @@ export function statusSummary(model: PanelModel): string {
270
457
  if (!model.found) return `No CLM data for this session (${model.directory}).`;
271
458
  const latest = model.timeline.points.at(-1);
272
459
  const parts = [
273
- `CLM ${model.enabled ? "on" : "off"} · revision ${model.revision}`,
460
+ `CLM ${model.enabled ? "on" : "off"}${model.enabled && model.noticesOnly ? " · mode notices-only" : ""} · revision ${model.revision}`,
274
461
  latest ? `last request ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}` : "no requests yet",
275
462
  model.budget !== undefined ? `budget ${formatTokenCount(model.budget)}` : undefined,
276
463
  model.budgetInfo?.overhead !== undefined ? `fixed overhead ~${formatTokenCount(model.budgetInfo.overhead)}` : undefined,
@@ -0,0 +1,199 @@
1
+ // Where the TUI reads a session's CLM files from. Local disk first (the TUI and the server
2
+ // share a file system); otherwise the server's own copy through OpenCode's file API
3
+ // (`GET /file`, `GET /file/content`), which serves paths inside the server's instance
4
+ // directory only. A session directory outside it cannot be read from an attached TUI.
5
+ // Written for this package. Verified against opencode 1.18.34: `GET /file/content`
6
+ // server/routes/instance/httpapi/handlers/file.ts:96-124 (missing file → empty text, text
7
+ // trimmed, path must stay inside the instance directory); `GET /file` file.ts:66-92 and
8
+ // packages/core/src/filesystem.ts:66-99 (a missing directory fails).
9
+
10
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
11
+
12
+ import { OVERRIDES_FILE, parseOverridesText, readOverrides, type OverridesRead } from "../overrides.ts";
13
+ import { localReader, type SessionReader } from "../session-files.ts";
14
+
15
+ /** The part of the TUI api's SDK client (`api.client.file`) this uses. */
16
+ export interface FileApi {
17
+ read(parameters: { path: string }): Promise<{ data?: unknown; error?: unknown }>;
18
+ list(parameters: { path: string }): Promise<{ data?: unknown; error?: unknown }>;
19
+ }
20
+
21
+ export type SessionSource =
22
+ | { kind: "local"; directory: string; reader: SessionReader }
23
+ /** Read through the server; `root` is the server's instance directory. */
24
+ | { kind: "remote"; directory: string; root: string; reader: SessionReader }
25
+ /** On the server, outside its instance directory: the server plugin reads it and answers over the channel. */
26
+ | { kind: "channel"; directory: string; root: string; reader: SessionReader }
27
+ /** Outside the server directory and no server plugin answering: unreadable from here. */
28
+ | { kind: "outside"; directory: string; root: string };
29
+
30
+ /** `path` relative to `root`, or undefined when it lies outside. */
31
+ export function insideRoot(root: string, path: string): string | undefined {
32
+ const rel = relative(root, path);
33
+ if (rel === "") return ".";
34
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) return undefined;
35
+ return rel;
36
+ }
37
+
38
+ function failure(error: unknown, path: string): Error {
39
+ if (error instanceof Error) return error;
40
+ const message = error && typeof error === "object" && "data" in error
41
+ ? JSON.stringify((error as { data?: unknown }).data)
42
+ : JSON.stringify(error);
43
+ return new Error(`server file API refused ${path}: ${message}`);
44
+ }
45
+
46
+ /**
47
+ * A reader over the server's file API. `GET /file/content` answers a missing file with
48
+ * empty text and trims what it returns (whitespace only matters at the ends of the JSON and
49
+ * JSONL files read here); `GET /file` fails for a missing directory.
50
+ */
51
+ export function remoteReader(file: FileApi, root: string): SessionReader {
52
+ const rel = (path: string): string => {
53
+ const inside = insideRoot(root, path);
54
+ if (inside === undefined) throw new Error(`${path} lies outside the server directory ${root}`);
55
+ return inside;
56
+ };
57
+ const list = async (path: string): Promise<string[] | undefined> => {
58
+ const result = await file.list({ path: rel(path) });
59
+ if (result.error !== undefined || !Array.isArray(result.data)) return undefined;
60
+ return result.data.flatMap((item) => (item && typeof (item as { name?: unknown }).name === "string" ? [(item as { name: string }).name] : []));
61
+ };
62
+ return {
63
+ name: `remote:${root}`,
64
+ async isDirectory(path) {
65
+ return (await list(path)) !== undefined;
66
+ },
67
+ async readText(path) {
68
+ const result = await file.read({ path: rel(path) });
69
+ if (result.error !== undefined) throw failure(result.error, path);
70
+ const data = result.data as { type?: unknown; content?: unknown } | undefined;
71
+ if (data?.type !== "text" || typeof data.content !== "string" || data.content === "") return undefined;
72
+ return data.content;
73
+ },
74
+ async list(path) {
75
+ return (await list(path)) ?? [];
76
+ },
77
+ // No mtimes over the API; revisions/rN files are written once, so a fixed stamp is safe.
78
+ async stamp() {
79
+ return "remote";
80
+ },
81
+ };
82
+ }
83
+
84
+ /** Asks the server plugin (src/channel.ts) for one operation on this session; throws on refusal. */
85
+ export type ChannelAsk = (operation: { op: "read"; path: string } | { op: "list"; path: string }) => Promise<{ content?: string; names?: string[] }>;
86
+
87
+ /**
88
+ * A reader that asks the server plugin over the channel: for a session directory the file
89
+ * API cannot reach (outside the server's instance directory). Paths go relative to the
90
+ * session directory; the server refuses anything outside it.
91
+ */
92
+ export function channelReader(ask: ChannelAsk, directory: string): SessionReader {
93
+ const rel = (path: string): string => {
94
+ const inside = insideRoot(directory, path);
95
+ if (inside === undefined) throw new Error(`${path} lies outside the session directory ${directory}`);
96
+ return inside === "." ? "" : inside;
97
+ };
98
+ const list = async (path: string) => (await ask({ op: "list", path: rel(path) })).names;
99
+ return {
100
+ name: `channel:${directory}`,
101
+ async isDirectory(path) {
102
+ return (await list(path)) !== undefined;
103
+ },
104
+ async readText(path) {
105
+ return (await ask({ op: "read", path: rel(path) })).content;
106
+ },
107
+ async list(path) {
108
+ return (await list(path)) ?? [];
109
+ },
110
+ async stamp() {
111
+ return "channel";
112
+ },
113
+ };
114
+ }
115
+
116
+ export interface SourceDeps {
117
+ /** The session directory as this TUI resolves it (its own settings and environment). */
118
+ ownDirectory: string;
119
+ /** The server's instance directory (`api.state.path.directory`). */
120
+ serverRoot: string;
121
+ file: FileApi;
122
+ /** The server's answer to `locate`, if it gave one. */
123
+ locate(): Promise<{ directory: string; root: string } | undefined>;
124
+ /**
125
+ * A fresh cached `locate` answer, without asking. With `relocate`, a TUI that shares the
126
+ * server's disk skips the wait: see `resolveSource`.
127
+ */
128
+ located?(): { directory: string; root: string } | undefined;
129
+ /** Starts a `locate` in the background (its answer serves the next resolve). */
130
+ relocate?(): void;
131
+ /** The server plugin, for a located directory outside the server root. */
132
+ ask?: ChannelAsk;
133
+ /** Local check; defaults to the disk. */
134
+ isLocalDirectory?(path: string): Promise<boolean>;
135
+ }
136
+
137
+ /**
138
+ * Local when this TUI's own session directory exists on its disk. Otherwise the server says
139
+ * where the directory is (`locate`), falling back to this TUI's resolution. Inside the
140
+ * server directory it is read through the file API; outside it, through the server plugin
141
+ * over the channel (only when `locate` answered, i.e. the plugin is there).
142
+ */
143
+ export async function resolveSource(deps: SourceDeps, options: { write?: boolean } = {}): Promise<SessionSource> {
144
+ const isLocal = deps.isLocalDirectory ?? ((path: string) => localReader.isDirectory(path).catch(() => false));
145
+ if (await isLocal(deps.ownDirectory)) return { kind: "local", directory: deps.ownDirectory, reader: localReader };
146
+ const sameDisk = async () => (await isLocal(dirname(deps.ownDirectory))) && (await isLocal(deps.serverRoot));
147
+ // A write creates the directory it writes to, and the server must read it: no guessing.
148
+ // Locally only when the server names this TUI's own directory, or does not answer (CLM
149
+ // disabled there) on the server's own disk; otherwise wherever the server keeps the files.
150
+ if (options.write) {
151
+ const answer = await deps.locate().catch(() => undefined);
152
+ if ((answer === undefined || answer.directory === deps.ownDirectory) && (await sameDisk())) {
153
+ return { kind: "local", directory: deps.ownDirectory, reader: localReader };
154
+ }
155
+ return placed(deps, answer);
156
+ }
157
+ // Same disk as the server (its directory and this TUI's mirror parent both exist here), and
158
+ // no answer cached yet: most likely a session before its first request, or a server with
159
+ // CLM disabled. Read the (empty) local directory now instead of waiting for `locate`,
160
+ // which runs in the background and corrects the source on the next load if the server
161
+ // keeps the files elsewhere.
162
+ if (deps.relocate && !deps.located?.() && (await sameDisk())) {
163
+ deps.relocate();
164
+ return { kind: "local", directory: deps.ownDirectory, reader: localReader };
165
+ }
166
+ return placed(deps, await deps.locate().catch(() => undefined));
167
+ }
168
+
169
+ /** The source for the server's answer to `locate` (or this TUI's own path without one). */
170
+ function placed(deps: SourceDeps, located: { directory: string; root: string } | undefined): SessionSource {
171
+ const directory = located?.directory ?? deps.ownDirectory;
172
+ const root = located?.root ?? deps.serverRoot;
173
+ if (insideRoot(root, directory) === undefined) {
174
+ if (located && deps.ask) return { kind: "channel", directory, root, reader: channelReader(deps.ask, directory) };
175
+ return { kind: "outside", directory, root };
176
+ }
177
+ return { kind: "remote", directory, root, reader: remoteReader(deps.file, root) };
178
+ }
179
+
180
+ /** overrides.json of a source. */
181
+ export async function sourceOverrides(source: SessionSource): Promise<OverridesRead> {
182
+ if (source.kind === "local") return readOverrides(source.directory);
183
+ if (source.kind === "outside") return { overrides: {} };
184
+ const path = join(source.directory, OVERRIDES_FILE);
185
+ let text: string | undefined;
186
+ try {
187
+ text = await source.reader.readText(path);
188
+ } catch (error) {
189
+ return { overrides: {}, warning: `could not read ${path}: ${error instanceof Error ? error.message : String(error)}` };
190
+ }
191
+ return text === undefined ? { overrides: {} } : parseOverridesText(text, path);
192
+ }
193
+
194
+ /** Panel text for a session directory the attached TUI cannot read. */
195
+ export function outsideMessage(source: { directory: string; root: string }): string {
196
+ return `The CLM files of this session would be at ${source.directory} on the server, outside the server's directory ${source.root}. ` +
197
+ "OpenCode's file API reads only inside that directory, and the opencode-clm server plugin did not answer to read them instead. " +
198
+ "Check that the server loads opencode-clm at this version, or set mirrorDir inside the project (the default .opencode/clm is).";
199
+ }