opencode-clm 0.2.0 → 0.3.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,24 +114,25 @@ 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.
@@ -75,20 +143,43 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
75
143
  format: { ...(limits.context ? { modelWindow: limits.context } : {}) },
76
144
  ...(current.warning ? { warning: current.warning } : {}),
77
145
  });
146
+ model.settings.rows.push(resetRow(model.settings.changed.length));
78
147
  return model;
79
148
  };
80
149
 
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);
150
+ /** Drops the session's settings changes, locally or through the server. */
151
+ const resetSessionSettings = async (sessionID: string): Promise<{ viaServer: boolean }> => {
152
+ const from = await source(sessionID, { write: true });
153
+ if (from.kind === "local") {
154
+ await resetSettings(from.directory);
155
+ return { viaServer: false };
156
+ }
157
+ await ask(sessionID, { op: "settings-reset" });
158
+ channelWrites.set(sessionID, Date.now());
159
+ return { viaServer: true };
160
+ };
161
+
162
+ /**
163
+ * Validates and saves one setting; returns `Label: value` and whether the server
164
+ * confirmed it with its own toast. Local files: written here (overrides.json, which the
165
+ * server reads before each request). Otherwise the server applies it over the channel.
166
+ */
167
+ const applySetting = async (sessionID: string, name: string, value: string): Promise<{ text: string; viaServer: boolean }> => {
168
+ const from = await source(sessionID, { write: true });
169
+ if (from.kind !== "local") {
170
+ const reply = await ask(sessionID, { op: "set", setting: name, value });
171
+ channelWrites.set(sessionID, Date.now());
172
+ return { text: reply.text, viaServer: true };
173
+ }
174
+ const files = await readFiles(sessionID, from);
175
+ const current = await sessionSettings(sessionID, from, files);
85
176
  const result = await changeSetting({
86
177
  sessionDirectory: current.directory,
87
178
  base: current.base,
88
179
  baseEditing: current.baseEditing,
89
180
  projectDirectory: api.state.path.directory,
90
181
  }, name, value);
91
- return result.text;
182
+ return { text: result.text, viaServer: false };
92
183
  };
93
184
 
94
185
  /** False when `/clm` belongs to someone else: `commands: false`, or a user-defined command. */
@@ -125,10 +216,26 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
125
216
  active?.controller.dispose();
126
217
  const controller = createPanel(api, {
127
218
  page,
128
- load: () => loadModel(sessionID).catch((error: unknown) => errorModel(sessionID, error)),
219
+ // The footer of this session takes the panel's model instead of reading the files again.
220
+ load: () => loadModel(sessionID).then(
221
+ (model) => {
222
+ showFooter(sessionID, model);
223
+ return model;
224
+ },
225
+ (error: unknown) => {
226
+ showFooter(sessionID, undefined);
227
+ return errorModel(sessionID, error);
228
+ },
229
+ ),
129
230
  apply: async (setting, value) => {
130
231
  try {
232
+ if (setting === RESET_ROW) {
233
+ if (value === RESET_NOW) await resetSessionSettings(sessionID);
234
+ refreshFooter(sessionID);
235
+ return undefined;
236
+ }
131
237
  await applySetting(sessionID, setting, value);
238
+ refreshFooter(sessionID);
132
239
  return undefined;
133
240
  } catch (error) {
134
241
  return describe(error);
@@ -168,10 +275,11 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
168
275
  /** After a change: refresh an open panel so it shows the new value. */
169
276
  const refreshPanel = (sessionID: string) => {
170
277
  if (active?.sessionID === sessionID) void active.controller.reload();
278
+ refreshFooter(sessionID);
171
279
  };
172
280
 
173
- /** Every `/clm …` line the intercept consumes (`/clm reset` goes to the server). */
174
- const handle = async (command: Exclude<ClmCommand, { kind: "server" }>): Promise<void> => {
281
+ /** Every `/clm …` line the intercept consumes (`/clm reset` goes to the server over the channel). */
282
+ const handle = async (command: ClmCommand): Promise<void> => {
175
283
  if (command.kind === "open") {
176
284
  open(command.page);
177
285
  return;
@@ -186,64 +294,142 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
186
294
  return;
187
295
  }
188
296
  switch (command.kind) {
189
- case "path":
190
- toast(sessionDirectory(settings().mirrorDir, sessionID));
297
+ case "path": {
298
+ const from = await source(sessionID);
299
+ toast(`${from.directory} (${SOURCE_LABEL[from.kind]})`);
191
300
  return;
301
+ }
192
302
  case "status":
193
303
  toast(statusSummary(await loadModel(sessionID)));
194
304
  return;
195
305
  case "config-show": {
196
- const files = await readSessionFiles(settings().mirrorDir, sessionID);
197
- const current = await sessionSettings(sessionID, files);
306
+ const from = await source(sessionID);
307
+ const current = await sessionSettings(sessionID, from, await readFiles(sessionID, from));
198
308
  toast(showSetting(command.setting, current.values.effective));
199
309
  return;
200
310
  }
201
311
  case "config-set":
202
312
  try {
203
- toast(`CLM ${await applySetting(sessionID, command.setting, command.value)}.`);
313
+ const result = await applySetting(sessionID, command.setting, command.value);
314
+ if (!result.viaServer) toast(`CLM ${result.text}.`);
204
315
  } catch (error) {
205
316
  toast(describe(error), "warning");
206
317
  }
207
318
  refreshPanel(sessionID);
208
319
  return;
209
- case "config-reset":
210
- await resetSettings(sessionDirectory(settings().mirrorDir, sessionID));
211
- toast("CLM settings reset to the defaults.");
320
+ case "config-reset": {
321
+ if (!(await resetSessionSettings(sessionID)).viaServer) toast("CLM settings reset to the defaults.");
212
322
  refreshPanel(sessionID);
213
323
  return;
214
- case "enable":
324
+ }
325
+ case "enable": {
215
326
  // 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`);
327
+ const result = await applySetting(sessionID, "editing", command.enabled ? "on" : "off");
328
+ if (!result.viaServer) toast(`CLM ${command.enabled ? "on" : "off"} for this session`);
329
+ refreshPanel(sessionID);
330
+ return;
331
+ }
332
+ case "server":
333
+ // `/clm reset`: the server owns state.json; it drops the revision and toasts.
334
+ await resetSession({
335
+ sessionID,
336
+ ask,
337
+ command: (parameters) => api.client.session.command(parameters),
338
+ toast,
339
+ });
218
340
  refreshPanel(sessionID);
219
341
  return;
220
342
  }
221
343
  };
222
344
 
223
345
  const disposeIntercept = api.keymap.intercept("key", (context) => {
346
+ // Tab on a `/clm …` line completes it inline (OpenCode has no argument completion).
347
+ if (interceptTab(context as never, {
348
+ focused: () => api.renderer.currentFocusedRenderable as never,
349
+ owned,
350
+ show: (items) => toast(items.map((item) => `/clm ${item}`).join(" · ")),
351
+ })) return;
224
352
  interceptEnter(context as never, {
225
353
  focused: () => api.renderer.currentFocusedRenderable as never,
226
354
  owned,
227
355
  handle,
356
+ serverChannel: () => true,
228
357
  report: (message) => toast(message, "warning"),
229
358
  });
230
359
  }, { priority: 100 });
231
360
 
361
+ // ---- footer: `clm 12k / 32k · r2` right of the session prompt (pi's status line) -----
362
+ /**
363
+ * One footer per session, reused while it is alive: the host's Solid slot calls the
364
+ * renderer again whenever its props change, and returning the same renderable keeps it
365
+ * from piling up new ones. Leaving the session view destroys it; the next render makes a
366
+ * new one.
367
+ */
368
+ const footers = new Map<string, ReturnType<typeof createFooter>>();
369
+ const footerTimers = new Map<string, ReturnType<typeof setTimeout>>();
370
+ /** The session's footer while it can still be drawn; the host destroys one it unmounts. */
371
+ const liveFooter = (sessionID: string) => {
372
+ const footer = footers.get(sessionID);
373
+ if (footer?.renderable.isDestroyed) footers.delete(sessionID);
374
+ return footer?.renderable.isDestroyed ? undefined : footer;
375
+ };
376
+ const showFooter = (sessionID: string, model: PanelModel | undefined) =>
377
+ liveFooter(sessionID)?.set(model ? footerText(model) : undefined);
378
+ const refreshFooter = (sessionID: string) => {
379
+ if (!liveFooter(sessionID)) return;
380
+ // An open panel for this session reloads its model and feeds the footer (above).
381
+ if (active?.sessionID === sessionID) return;
382
+ clearTimeout(footerTimers.get(sessionID));
383
+ footerTimers.set(sessionID, setTimeout(() => {
384
+ footerTimers.delete(sessionID);
385
+ void loadModel(sessionID).then(
386
+ (model) => showFooter(sessionID, model),
387
+ () => showFooter(sessionID, undefined),
388
+ );
389
+ }, RELOAD_DEBOUNCE_MS));
390
+ };
391
+ api.slots.register({
392
+ order: 50,
393
+ slots: {
394
+ session_prompt_right(_context, props) {
395
+ const sessionID = (props as { session_id?: unknown }).session_id;
396
+ if (typeof sessionID !== "string" || !settings().enabled) return null as never;
397
+ let footer = liveFooter(sessionID);
398
+ if (!footer) {
399
+ footer = createFooter(api, `opencode-clm-footer-${sessionID}`);
400
+ footers.set(sessionID, footer);
401
+ }
402
+ refreshFooter(sessionID);
403
+ // A raw renderable, as the panel route returns (accepted by the host's Solid insert).
404
+ return footer.renderable as never;
405
+ },
406
+ },
407
+ });
408
+
232
409
  const scheduleReload = (sessionID: unknown) => {
410
+ if (typeof sessionID === "string") refreshFooter(sessionID);
233
411
  if (!active || (typeof sessionID === "string" && sessionID !== active.sessionID)) return;
234
412
  clearTimeout(reloadTimer);
235
413
  reloadTimer = setTimeout(() => void active?.controller.reload(), RELOAD_DEBOUNCE_MS);
236
414
  };
237
415
  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));
416
+ // Sizes change when a reply completes; streaming updates in between are skipped.
417
+ const offMessage = api.event.on("message.updated", (event) => {
418
+ const info = (event.properties as { info?: { sessionID?: unknown; role?: unknown; time?: { completed?: unknown } } }).info;
419
+ if (info?.role === "assistant" && info.time?.completed === undefined) return;
420
+ scheduleReload(info?.sessionID);
421
+ });
240
422
 
241
423
  api.lifecycle.onDispose(() => {
242
424
  clearTimeout(reloadTimer);
425
+ for (const timer of footerTimers.values()) clearTimeout(timer);
426
+ for (const footer of footers.values()) if (!footer.renderable.isDestroyed) footer.renderable.destroy();
427
+ footers.clear();
243
428
  active?.controller.dispose();
244
429
  active = undefined;
245
430
  offIdle();
246
431
  offMessage();
432
+ channel.dispose();
247
433
  disposeIntercept();
248
434
  disposeLayer();
249
435
  });
@@ -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
+ }