opencode-clm 0.4.0 → 0.5.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.4.0",
3
+ "version": "0.5.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",
@@ -31,7 +31,7 @@
31
31
  "index.ts",
32
32
  "tui.ts",
33
33
  "src/**/*.ts",
34
- "skills/**",
34
+ ".agents/skills/clm-context/**",
35
35
  "steering/**",
36
36
  "LICENSE",
37
37
  "NOTICE",
@@ -52,7 +52,7 @@
52
52
  "test:e2e": "OPENCODE_CLM_E2E=1 bun test test/e2e"
53
53
  },
54
54
  "dependencies": {
55
- "@opencode-ai/plugin": "^1.18.34"
55
+ "@opencode-ai/plugin": "^1.19.3"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@opentui/core": ">=0.4.5"
@@ -63,10 +63,10 @@
63
63
  }
64
64
  },
65
65
  "devDependencies": {
66
- "@opentui/core": "0.5.14",
67
- "@opentui/keymap": "0.5.14",
68
- "@opentui/solid": "0.5.14",
69
- "@types/bun": "1.4.2",
66
+ "@opentui/core": "0.6.6",
67
+ "@opentui/keymap": "0.6.6",
68
+ "@opentui/solid": "0.6.6",
69
+ "@types/bun": "1.4.3",
70
70
  "@xterm/headless": "6.0.0",
71
71
  "typescript": "7.0.2"
72
72
  }
package/src/clm.ts CHANGED
@@ -38,7 +38,6 @@
38
38
  * calibrator learns only from requests whose full scope (system prompt and tool schemas
39
39
  * included) was estimated; without those sizes it stays at 1.
40
40
  */
41
- import { randomUUID } from "node:crypto";
42
41
  import { appendFile, mkdir, stat, writeFile } from "node:fs/promises";
43
42
  import { join } from "node:path";
44
43
 
@@ -176,11 +175,16 @@ export function lastProviderReported(raw: readonly OcMessage[]): ObservedRequest
176
175
  return undefined;
177
176
  }
178
177
 
179
- /** Id of the newest compaction summary (an assistant message with `summary` set). */
178
+ /**
179
+ * Id of the newest compaction summary: an OpenCode 1 assistant message with `summary` set,
180
+ * or the OpenCode 2 checkpoint user message src/opencode-v2.ts marks the same way.
181
+ */
180
182
  export function compactionSummaryId(raw: readonly OcMessage[]): string | undefined {
181
183
  for (let index = raw.length - 1; index >= 0; index--) {
182
184
  const info = raw[index]!.info;
183
- if (info.role === "assistant" && info.summary && !info.error) return info.id;
185
+ // OpenCode 1 marks an assistant message; OpenCode 2 marks its checkpoint user message,
186
+ // whose `summary` on an OpenCode 1 user message is the session diff, not a summary.
187
+ if ((info.role === "assistant" ? info.summary : info.summary === true) && !info.error) return info.id;
184
188
  }
185
189
  return undefined;
186
190
  }
@@ -220,6 +224,16 @@ export class ClmSession {
220
224
  * `experimental.compaction.autocontinue` hook); the next transform rebases onto the summary.
221
225
  */
222
226
  compacted = false;
227
+ /** OpenCode's newest compaction of this session in this process (`/clm status`). */
228
+ lastCompaction?: { reason: "auto" | "manual"; at: number };
229
+ /**
230
+ * Records a compaction OpenCode made (automatic: its threshold or a provider overflow;
231
+ * manual: `/compact`) for `/clm status` and events.jsonl.
232
+ */
233
+ async noteCompaction(reason: "auto" | "manual", at = Date.now()): Promise<void> {
234
+ this.lastCompaction = { reason, at };
235
+ await this.log({ event: "opencode-compaction", reason });
236
+ }
223
237
  /**
224
238
  * Set by the plugin when OpenCode's session metadata names another session as the origin
225
239
  * (a fork, `Session.fork` copies metadata). The first transform restores the newest of the
@@ -261,11 +275,6 @@ export class ClmSession {
261
275
  private pendingAlerts: string[] = [];
262
276
  /** The settings warning last shown to the user, so a new or changed one toasts once. */
263
277
  private shownSettingsWarning?: string;
264
- /**
265
- * Per-process seed of the stable document id. Fresh on every open, so a document id never
266
- * repeats even when a corrupt state file resets the revision counter to 0.
267
- */
268
- private readonly nonce = randomUUID();
269
278
  private readonly tokenCache = new WeakMap<LiveContextMessage, number>();
270
279
  private saving: Promise<void> = Promise.resolve();
271
280
  private running: Promise<unknown> = Promise.resolve();
@@ -1264,6 +1273,24 @@ export class ClmSession {
1264
1273
  } else if (this.state.checkpoint) {
1265
1274
  this.invalidationStreak = 0;
1266
1275
  }
1276
+ if (projection.valid && projection.reanchor) {
1277
+ // OpenCode rewrote messages of the current turn after the checkpoint was captured
1278
+ // (an assistant message still being finalized). Trim the anchor to the stable head
1279
+ // instead of dropping the revision: the trimmed tail becomes ordinary suffix,
1280
+ // exactly as if the edit had been committed a moment later. Silent by design;
1281
+ // `reanchored` in events.jsonl records it for the panel and for debugging.
1282
+ this.invalidationStreak = 0;
1283
+ const reanchor = projection.reanchor;
1284
+ const revision = this.state.revision;
1285
+ await this.updateState(
1286
+ (state) =>
1287
+ state.checkpoint && state.revision === revision
1288
+ ? { ...state, checkpoint: { ...state.checkpoint, ...reanchor } }
1289
+ : state,
1290
+ true,
1291
+ ).catch(() => undefined);
1292
+ await this.log({ event: "reanchored", revision, sourceMessageCount: reanchor.sourceMessageCount });
1293
+ }
1267
1294
  const origin = this.forkOrigin;
1268
1295
  this.forkOrigin = undefined;
1269
1296
  if (origin && !noticesOnly && !this.state.checkpoint && this.state.revision === 0 && (await this.restoreFromFork(origin, source))) {
@@ -1331,9 +1358,10 @@ export class ClmSession {
1331
1358
  const snapshot = noticesOnly ? undefined : renderContextDocument(effective, {
1332
1359
  revision: this.state.revision,
1333
1360
  protectedIndexes: new Set<number>(),
1334
- // Constant until the next accepted edit or reset, so header ids read on one call
1335
- // remain valid on the next; bodies are escaped accordingly.
1336
- documentSeed: `${this.sessionID}:${this.nonce}:${checkpoint?.sourceDigest ?? "raw"}`,
1361
+ // Constant until the next accepted edit or reset — and across process restarts,
1362
+ // as pi-clm's is — so header ids read on one call remain valid on the next;
1363
+ // bodies are escaped accordingly.
1364
+ documentSeed: `${this.sessionID}:${checkpoint?.sourceDigest ?? "raw"}`,
1337
1365
  });
1338
1366
  if (snapshot) {
1339
1367
  try {
@@ -1494,6 +1522,7 @@ export class ClmSession {
1494
1522
  }
1495
1523
  : undefined,
1496
1524
  lastRequest: this.lastRequest,
1525
+ ...(this.lastCompaction ? { lastCompaction: this.lastCompaction } : {}),
1497
1526
  steering,
1498
1527
  };
1499
1528
  }
@@ -54,7 +54,10 @@ function normalizeJson(value: unknown): unknown {
54
54
  }
55
55
 
56
56
  export function canonicalMessage(message: LiveContextMessage): string {
57
- return JSON.stringify(normalizeJson(message));
57
+ // `updatedAt` is OpenCode write metadata, not content: OpenCode rewrites it in place while
58
+ // a message is still being finalized, so it must not reach any digest or block id.
59
+ const { updatedAt: _updatedAt, ...rest } = message;
60
+ return JSON.stringify(normalizeJson(rest));
58
61
  }
59
62
 
60
63
  export function digestMessages(messages: LiveContextMessage[]): string {
package/src/host.ts ADDED
@@ -0,0 +1,512 @@
1
+ /**
2
+ * What both plugin entry points share: the session registry with its mirror fallbacks, fork
3
+ * stamps and persisted annotations in OpenCode's session metadata, and the `/clm` and
4
+ * `/clm-compact` command texts. index.ts wires it to OpenCode 1's hooks (`server`) and
5
+ * src/v2.ts to OpenCode 2's (`setup`); each passes the host calls it has (toasts, logs,
6
+ * session records), so nothing here depends on either plugin API.
7
+ */
8
+ import { rmSync } from "node:fs";
9
+ import { mkdir } from "node:fs/promises";
10
+
11
+ import { formatTokens } from "./budget.ts";
12
+ import { ClmSession } from "./clm.ts";
13
+ import { buildCompactPrompt, loadCompactPrompt } from "./compact.ts";
14
+ import { ANNOTATIONS_FILE, persistAnnotations, restorePersisted } from "./continuity.ts";
15
+ import { copyPrivateFile, MirrorDirectoryError, privateTemporaryParent } from "./mirror-store.ts";
16
+ import { changeSetting, OVERRIDES_FILE, resetSettings, showSetting } from "./overrides.ts";
17
+ import { parseClmCommand, type Page } from "./panel/command.ts";
18
+ import { buildPanelModel } from "./panel/model.ts";
19
+ import { panelPageText } from "./panel/text.ts";
20
+ import { statusText } from "./presentation.ts";
21
+ import { readSessionDirectory, sessionDirectory } from "./session-files.ts";
22
+ import { defaultMirrorDir, type ClmSettings } from "./settings.ts";
23
+ import { settingsText } from "./settings-table.ts";
24
+ import { STATE_FILE } from "./state.ts";
25
+ import type { SteeringDocument } from "./steering.ts";
26
+ import { fallbackBudget, settingsView } from "./tui/data.ts";
27
+
28
+ export type ToastVariant = "info" | "success" | "warning" | "error";
29
+
30
+ export const STATUS_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | budget [value] | config [setting [value] | reset]]";
31
+ /** A pending `/clm-compact` older than this no longer refuses a new one (its prompt may have failed to send). */
32
+ export const COMPACT_PENDING_MS = 5 * 60_000;
33
+
34
+ export function describe(error: unknown): string {
35
+ return error instanceof Error ? error.message : String(error);
36
+ }
37
+
38
+ /**
39
+ * Session ids name a directory (`<mirrorDir>/clm-<id>`). A separator or `..` would reach
40
+ * outside it, so such ids are refused before anything touches the file system.
41
+ */
42
+ export function checkSessionID(sessionID: unknown): string {
43
+ if (typeof sessionID !== "string" || sessionID === "" || /[/\\]/.test(sessionID) || sessionID.includes("..")) {
44
+ throw new Error(`CLM refuses session id ${JSON.stringify(sessionID)}: it must not contain "/", "\\" or "..".`);
45
+ }
46
+ return sessionID;
47
+ }
48
+
49
+ /** The text the model receives in place of the `/clm` template. */
50
+ export function relay(text: string): string {
51
+ return `${text}\n\nShow the CLM text above to the user exactly as written. Do not edit the mirror or run tools.`;
52
+ }
53
+
54
+ /** OpenCode's record of a session, as far as CLM reads it. */
55
+ export interface SessionRecord {
56
+ /** `metadata`, a free record (empty object when absent). */
57
+ metadata: Record<string, unknown>;
58
+ /** A child (subagent) session. */
59
+ child: boolean;
60
+ created?: number;
61
+ /** The session this one was forked from, when the host records it (OpenCode 2 `fork.sessionID`). */
62
+ forkOf?: string;
63
+ }
64
+
65
+ export interface HostDeps {
66
+ settings: ClmSettings;
67
+ steering?: SteeringDocument;
68
+ /** The project directory (default mirror parent, `/clm config` paths). */
69
+ directory: string;
70
+ toast(message: string, variant?: ToastVariant): void;
71
+ log(level: "info" | "warn" | "error", message: string): void;
72
+ /** The session's record; undefined when the host cannot read it. A throw is logged as `stamp-error`. */
73
+ record?(sessionID: string): Promise<SessionRecord | undefined>;
74
+ /** Replaces the session's metadata. A throw is logged. */
75
+ updateMetadata?(sessionID: string, metadata: Record<string, unknown>): Promise<void>;
76
+ }
77
+
78
+ /** What `/clm-compact` measures before it asks the model to compact. */
79
+ export interface CompactMeasure {
80
+ /** Context size now, in tokens; undefined when the context is empty. */
81
+ current: number | undefined;
82
+ /** The session is running (the size misses the rest of the current turn). */
83
+ busy: boolean;
84
+ }
85
+
86
+ export type ClmHost = ReturnType<typeof createHost>;
87
+
88
+ export function createHost(deps: HostDeps) {
89
+ const { settings, steering, toast, log } = deps;
90
+ const sessions = new Map<string, Promise<ClmSession>>();
91
+
92
+ /** Read-modify-write of a session's metadata runs one at a time per session. */
93
+ const metadataChains = new Map<string, Promise<unknown>>();
94
+ const inMetadataChain = <T>(sessionID: string, task: () => Promise<T>): Promise<T> => {
95
+ const run = (metadataChains.get(sessionID) ?? Promise.resolve()).then(task, task);
96
+ const tail = run.catch(() => undefined);
97
+ metadataChains.set(sessionID, tail);
98
+ void tail.then(() => {
99
+ if (metadataChains.get(sessionID) === tail) metadataChains.delete(sessionID);
100
+ });
101
+ return run;
102
+ };
103
+
104
+ const sessionRecord = async (clm: ClmSession): Promise<SessionRecord | undefined> => {
105
+ if (!deps.record) return undefined;
106
+ try {
107
+ return await deps.record(clm.sessionID);
108
+ } catch (error) {
109
+ await clm.log({ event: "stamp-error", stage: "get", error: describe(error) });
110
+ return undefined;
111
+ }
112
+ };
113
+
114
+ /** `metadata.clm` of a session record, or an empty object. */
115
+ const clmStamp = (metadata: Record<string, unknown>): Record<string, unknown> =>
116
+ metadata.clm && typeof metadata.clm === "object" && !Array.isArray(metadata.clm) ? (metadata.clm as Record<string, unknown>) : {};
117
+
118
+ /** Sessions whose stamp was handled in this process (`linkFork`). */
119
+ const linked = new Set<string>();
120
+
121
+ /**
122
+ * Fork detection. The plugin stamps `metadata.clm.origin = <own id>` on every session it
123
+ * opens; a fork copies the metadata, so a stamp naming another session marks a fork of
124
+ * it. A host that records forks itself (`forkOf`) names the origin directly. A fresh fork
125
+ * restores the origin's newest matching revision on its first request (clm.ts
126
+ * `restoreFromFork`); the stamp is then rewritten to the fork's id. The metadata update
127
+ * replaces the whole record, so the other keys are copied.
128
+ */
129
+ const linkFork = (clm: ClmSession): Promise<void> => inMetadataChain(clm.sessionID, async () => {
130
+ const record = await sessionRecord(clm);
131
+ // Child (subagent) sessions are never forked: no stamp, no update bumping their time.
132
+ if (!record || record.child) return;
133
+ const metadata = record.metadata;
134
+ const stamp = clmStamp(metadata);
135
+ const origin = record.forkOf ?? stamp.origin;
136
+ // `annotations` is the store of a session without a mirror (M1). A session with a mirror
137
+ // drops it, so a later run without one does not restore a stale set; a fork drops the
138
+ // origin's copy and persists its own after its first request.
139
+ const own = origin === clm.sessionID;
140
+ const fork = typeof origin === "string" && !own;
141
+ const dropAnnotations = "annotations" in stamp && (fork || clm.mirrorUnavailable === undefined);
142
+ if (own && !dropAnnotations) return;
143
+ if (!own && typeof origin === "string" && !clm.state.checkpoint && clm.state.revision === 0) {
144
+ try {
145
+ clm.forkOrigin = {
146
+ sessionID: origin,
147
+ directory: sessionDirectory(settings.mirrorDir, checkSessionID(origin)),
148
+ ...(record.created !== undefined ? { created: record.created } : {}),
149
+ };
150
+ } catch {
151
+ // a foreign stamp that is not a usable session id: ignore it
152
+ }
153
+ }
154
+ if (!deps.updateMetadata) return;
155
+ try {
156
+ const { annotations: _dropped, ...kept } = stamp;
157
+ const next = { ...(dropAnnotations ? kept : stamp), origin: clm.sessionID };
158
+ await deps.updateMetadata(clm.sessionID, { ...metadata, clm: next });
159
+ } catch (error) {
160
+ await clm.log({ event: "stamp-error", stage: "update", error: describe(error) });
161
+ }
162
+ });
163
+
164
+ /** The project's default mirror parent, inside the project (no external-directory prompts). */
165
+ const projectMirrorDir = defaultMirrorDir(deps.directory);
166
+
167
+ /** Private parent for sessions without a usable mirror directory, made on first use (`openWithoutMirror`). */
168
+ let unmirroredParent: Promise<string> | undefined;
169
+
170
+ /**
171
+ * Opens a session in `mirrorDir`. Only a failure to create or use the session directory
172
+ * (`MirrorDirectoryError`) is handled here; any other error propagates as before.
173
+ * - An explicitly configured `mirrorDir` that fails: the session uses the project default
174
+ * `.opencode/clm` instead, and a toast names both. Inside the project the model's tools
175
+ * reach it without an `external_directory` permission prompt.
176
+ * - The default fails too: the session runs without a mirror (`openWithoutMirror`).
177
+ */
178
+ const openSession = async (id: string): Promise<ClmSession> => {
179
+ const failures: MirrorDirectoryError[] = [];
180
+ try {
181
+ return await ClmSession.open(id, settings, { steering });
182
+ } catch (error) {
183
+ if (!(error instanceof MirrorDirectoryError)) throw error;
184
+ failures.push(error);
185
+ }
186
+ if (settings.mirrorDir !== projectMirrorDir) {
187
+ try {
188
+ const clm = await ClmSession.open(id, { ...settings, mirrorDir: projectMirrorDir }, { steering });
189
+ const error = failures[0]!;
190
+ const message = `could not use the configured mirrorDir ${settings.mirrorDir} (${describe(error.cause)}); the files of session ${id} are in ${clm.store.directory} instead.`;
191
+ log("warn", message);
192
+ toast(message, "warning");
193
+ await clm.log({ event: "mirror-dir-fallback", configured: settings.mirrorDir, used: projectMirrorDir, reason: describe(error.cause) });
194
+ return clm;
195
+ } catch (error) {
196
+ if (!(error instanceof MirrorDirectoryError)) throw error;
197
+ failures.push(error);
198
+ }
199
+ }
200
+ return await openWithoutMirror(id, failures);
201
+ };
202
+
203
+ /**
204
+ * pi-clm's degraded mode (src/index.ts `replaceStore` and the `context` handler's `!store`
205
+ * branch): no mirror, no protocol prompt, no edits; requests carry the raw history plus the
206
+ * continuity annotations and their size notice. The session's files go to a private
207
+ * directory under the OS temp directory, made once per process and removed at exit. The
208
+ * model is never pointed there, so no `external_directory` prompt arises. state.json,
209
+ * overrides.json and annotations.jsonl still readable in a failed session directory (ours,
210
+ * not a symlink) are copied in before the session loads them; changes stay in the copy.
211
+ * When even that fails, the open fails: raw history, one "not active" toast naming every
212
+ * reason.
213
+ */
214
+ const openWithoutMirror = async (id: string, failures: MirrorDirectoryError[]): Promise<ClmSession> => {
215
+ const reason = failures.map((failure) => `${failure.parent}: ${describe(failure.cause)}`).join("; ");
216
+ const copied: string[] = [];
217
+ let copiedFrom: string | undefined;
218
+ let clm: ClmSession;
219
+ try {
220
+ if (!unmirroredParent) {
221
+ unmirroredParent = privateTemporaryParent().then((parent) => {
222
+ process.once("exit", () => rmSync(parent, { recursive: true, force: true }));
223
+ return parent;
224
+ });
225
+ unmirroredParent.catch(() => (unmirroredParent = undefined));
226
+ }
227
+ const parent = await unmirroredParent;
228
+ const directory = sessionDirectory(parent, id);
229
+ await mkdir(directory, { recursive: true, mode: 0o700 });
230
+ for (const name of [STATE_FILE, OVERRIDES_FILE, ANNOTATIONS_FILE]) {
231
+ for (const failure of failures) {
232
+ const from = sessionDirectory(failure.parent, id);
233
+ if (await copyPrivateFile(from, directory, name)) {
234
+ copied.push(name);
235
+ copiedFrom ??= from;
236
+ break;
237
+ }
238
+ }
239
+ }
240
+ clm = await ClmSession.open(id, { ...settings, mirrorDir: parent }, { steering });
241
+ } catch (error) {
242
+ throw new Error(`no mirror directory could be used (${reason}), and no temporary directory either: ${describe(error)}`);
243
+ }
244
+ clm.mirrorUnavailable = reason;
245
+ await restoreAnnotations(clm);
246
+ const kept = copiedFrom ? `${copied.join(", ")} copied from ${copiedFrom}; changes stay` : "No saved session files found; the session's files are";
247
+ const message = `CLM editing not active for session ${id}: no mirror directory could be used (${reason}). Requests carry the raw history plus the continuity annotations and their size notice. ${kept} in ${clm.store.directory} for this server process only.`;
248
+ log("error", message);
249
+ toast(message, "error");
250
+ await clm.log({ event: "mirror-unavailable", reason, directory: clm.store.directory, ...(copiedFrom ? { copiedFrom, copied } : {}) });
251
+ return clm;
252
+ };
253
+
254
+ /**
255
+ * M1: a session without a mirror keeps its files in a per-process temp directory, so its
256
+ * annotations (resolutions made while degraded, and the snapshots copied in) would be lost
257
+ * at restart; pi keeps them in session entries. They go to OpenCode's session metadata
258
+ * (`metadata.clm.annotations`, bounded by PERSISTED_ANNOTATIONS_BYTES) after each change
259
+ * and are restored when the session opens without a mirror again.
260
+ */
261
+ const restoreAnnotations = async (clm: ClmSession): Promise<void> => {
262
+ const record = await inMetadataChain(clm.sessionID, () => sessionRecord(clm));
263
+ const stamp = record ? clmStamp(record.metadata) : {};
264
+ const persisted = stamp.annotations;
265
+ if (persisted === undefined) return;
266
+ // A fork's metadata is the origin's clone: the fork's first request applies the
267
+ // fork-point cutoff to that set (clm.ts `copyForkAnnotations`) instead.
268
+ const origin = record!.forkOf ?? stamp.origin;
269
+ if (typeof origin === "string" && origin !== clm.sessionID && !record!.child) {
270
+ clm.forkPersisted = restorePersisted([], persisted);
271
+ return;
272
+ }
273
+ try {
274
+ const snapshots = restorePersisted(await clm.annotations.list(), persisted);
275
+ await clm.annotations.appendAll(snapshots);
276
+ if (snapshots.length > 0) await clm.log({ event: "annotations-restored", count: snapshots.length });
277
+ } catch (error) {
278
+ await clm.log({ event: "annotations-restore-error", error: describe(error) });
279
+ }
280
+ };
281
+ const persistAnnotationsOf = async (sessionID: string): Promise<void> => {
282
+ const clm = await opened(sessionID);
283
+ if (!clm || clm.mirrorUnavailable === undefined) return;
284
+ await inMetadataChain(sessionID, () => persistNow(clm));
285
+ };
286
+ const persistNow = async (clm: ClmSession): Promise<void> => {
287
+ const record = await sessionRecord(clm);
288
+ if (!record || !deps.updateMetadata) return;
289
+ try {
290
+ const annotations = persistAnnotations(await clm.annotations.list());
291
+ const metadata = { ...record.metadata, clm: { ...clmStamp(record.metadata), annotations } };
292
+ await deps.updateMetadata(clm.sessionID, metadata);
293
+ await clm.log({ event: "annotations-persisted", count: annotations.annotations.length, resolved: annotations.resolved.length });
294
+ } catch (error) {
295
+ await clm.log({ event: "annotations-persist-error", error: describe(error) });
296
+ }
297
+ };
298
+
299
+ const session = (sessionID: string): Promise<ClmSession> => {
300
+ const id = checkSessionID(sessionID);
301
+ let existing = sessions.get(id);
302
+ if (!existing) {
303
+ existing = openSession(id).then(async (clm) => {
304
+ if (clm.loadWarning) toast(`state reset for ${id}: ${clm.loadWarning}`, "warning");
305
+ return clm;
306
+ });
307
+ existing.catch(() => sessions.delete(id));
308
+ sessions.set(id, existing);
309
+ }
310
+ return existing;
311
+ };
312
+ /** An already open session, without opening one. */
313
+ const opened = async (sessionID: string): Promise<ClmSession | undefined> => {
314
+ try {
315
+ return await sessions.get(sessionID);
316
+ } catch {
317
+ return undefined;
318
+ }
319
+ };
320
+
321
+ /** One setting through `changeSetting`, activated at once; returns `Label: value`. */
322
+ async function setSetting(clm: ClmSession, name: string, value: string): Promise<string> {
323
+ const result = await changeSetting(clm.changeRequest(deps.directory), name, value);
324
+ await clm.refreshSettings(true);
325
+ return result.text;
326
+ }
327
+
328
+ async function clearSettings(clm: ClmSession): Promise<void> {
329
+ await resetSettings(clm.store.directory);
330
+ await clm.refreshSettings(true);
331
+ toast("CLM settings reset to the defaults.");
332
+ }
333
+
334
+ async function dropRevision(clm: ClmSession): Promise<void> {
335
+ await clm.resetProjection("/clm reset");
336
+ toast("CLM revision dropped");
337
+ }
338
+
339
+ /**
340
+ * `/clm config …` as text (pi-clm's `/clm config` semantics; the TUI opens the settings
341
+ * page instead). `words` keep their case: values may be paths.
342
+ */
343
+ async function configCommand(clm: ClmSession, words: string[]): Promise<string> {
344
+ const [name, ...valueWords] = words;
345
+ const { base, effective } = clm.settingsValues();
346
+ const format = { modelWindow: clm.limits.context, modelOutput: clm.limits.output };
347
+ if (!name) return settingsText(base, effective, format, clm.settingsWarning);
348
+ if (name.toLowerCase() === "reset" && valueWords.length === 0) {
349
+ await clearSettings(clm);
350
+ return "CLM settings reset to the defaults for this session.";
351
+ }
352
+ if (valueWords.length === 0) return showSetting(name, effective, format);
353
+ const text = await setSetting(clm, name, valueWords.join(" "));
354
+ toast(`CLM ${text}.`);
355
+ return `CLM ${text}. It applies from the next request.`;
356
+ }
357
+
358
+ /** `/clm overview|input|edits|settings` outside the TUI: the panel page as plain text. */
359
+ async function pageCommand(clm: ClmSession, page: Page): Promise<string> {
360
+ const files = await readSessionDirectory(clm.store.directory, clm.sessionID);
361
+ const budget = fallbackBudget(clm.settings, clm.limits);
362
+ const model = buildPanelModel(files, budget ? { budget } : {});
363
+ model.enabled = clm.enabled;
364
+ if (clm.noticesOnly) model.noticesOnly = true;
365
+ // No mirror: name why, never the temporary directory (the text reaches the model).
366
+ if (clm.mirrorUnavailable !== undefined) {
367
+ model.mirrorUnavailable = clm.mirrorUnavailable;
368
+ model.mirrorPath = `unavailable (${clm.mirrorUnavailable})`;
369
+ }
370
+ const format = { ...(clm.limits.context ? { modelWindow: clm.limits.context } : {}), ...(clm.limits.output ? { modelOutput: clm.limits.output } : {}) };
371
+ model.settings = settingsView(clm.settingsValues(), model, { format, ...(clm.settingsWarning ? { warning: clm.settingsWarning } : {}) });
372
+ return panelPageText(model, page);
373
+ }
374
+
375
+ async function statusCommand(clm: ClmSession, args: string): Promise<string> {
376
+ await clm.refreshSettings();
377
+ const words = args.split(/\s+/).filter(Boolean);
378
+ const argument = (words[0] ?? "").toLowerCase();
379
+ if (argument === "config") return configCommand(clm, words.slice(1));
380
+ // pi's shorthand: `/clm budget <value>` = `/clm config budget <value>`.
381
+ if (argument === "budget") return configCommand(clm, ["budget", ...words.slice(1)]);
382
+ const command = parseClmCommand(`/clm ${args}`);
383
+ if (args !== "" && command?.kind === "open") return pageCommand(clm, command.page);
384
+ if (words.length > 1) return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
385
+ switch (argument) {
386
+ case "":
387
+ case "status": {
388
+ const text = statusText(clm.status());
389
+ toast(text.split("\n").slice(0, 3).join("\n"));
390
+ return text;
391
+ }
392
+ case "path":
393
+ if (clm.mirrorUnavailable !== undefined) return `CLM mirror: unavailable (${clm.mirrorUnavailable})`;
394
+ return clm.noticesOnly ? "CLM mirror: not used (mode notices-only)" : `CLM mirror: ${clm.mirrorPath}`;
395
+ case "on":
396
+ case "off": {
397
+ // The same override the TUI and `/clm config editing` write.
398
+ await setSetting(clm, "editing", argument);
399
+ toast(`CLM ${argument} for this session`);
400
+ if (clm.mirrorUnavailable !== undefined) {
401
+ return argument === "on"
402
+ ? "CLM is on for this session. No mirror is available: requests carry the raw history plus the continuity annotations."
403
+ : "CLM is off for this session: requests carry the raw history.";
404
+ }
405
+ if (clm.noticesOnly) {
406
+ return argument === "on"
407
+ ? "CLM is on for this session in notices-only mode: requests carry the raw history plus the budget notices; the model cannot edit its context."
408
+ : "CLM is off for this session: requests carry the raw history and no notices.";
409
+ }
410
+ return argument === "on"
411
+ ? "CLM is on for this session: the mirror is refreshed before the next request."
412
+ : "CLM is off for this session: requests carry the raw history and the mirror is no longer read.";
413
+ }
414
+ case "reset": {
415
+ await dropRevision(clm);
416
+ return "CLM dropped the accepted revision: the next request carries the stored history, and the mirror is rewritten from it.";
417
+ }
418
+ default:
419
+ return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Sessions with a `/clm-compact` prompt the model has not received yet, and that prompt.
425
+ * pi refuses a second `/clm-compact` while one waits (`compactPending`, pi src/index.ts:235,
426
+ * 997-1000): set when the command is accepted (:1008), cleared once its prompt is sent
427
+ * (:1046). OpenCode sends the prompt itself, into the running loop or a new one, so here
428
+ * pending ends at the first model request whose history holds the prompt, or when the
429
+ * session goes idle or fails without one. If the prompt never reaches a request, an entry
430
+ * older than COMPACT_PENDING_MS no longer refuses.
431
+ */
432
+ const compactPending = new Map<string, { prompt?: string; at: number }>();
433
+
434
+ /**
435
+ * `/clm-compact [instructions]`: the compaction prompt, with the size of the context as
436
+ * it would be sent now (`measure`). pi's checks (pi src/index.ts compactCommand): CLM off
437
+ * and an empty context are refused with a toast; the result is then `{ refused }` text for
438
+ * the host to show instead of the prompt. A second invocation before the model received
439
+ * the first prompt is refused like pi's (`compactPending`).
440
+ */
441
+ async function compactCommand(
442
+ clm: ClmSession,
443
+ sessionID: string,
444
+ argument: string,
445
+ measure: () => Promise<CompactMeasure>,
446
+ ): Promise<{ prompt: string } | { refused: string }> {
447
+ if (clm.mirrorUnavailable !== undefined) {
448
+ // pi-clm's compactCommand: nothing for the model to edit, so nothing is asked of it.
449
+ toast(`The context mirror is unavailable, so the model cannot edit its context (${clm.mirrorUnavailable}).`, "warning");
450
+ return { refused: "[CLM] The context mirror is unavailable, so the model cannot edit its context." };
451
+ }
452
+ await clm.refreshSettings();
453
+ if (clm.noticesOnly) {
454
+ // The compaction prompt asks for a mirror edit; notices-only has no mirror.
455
+ const message = "CLM runs in notices-only mode for this session, so the model cannot edit its context. Switch with /clm config mode edit.";
456
+ toast(message, "warning");
457
+ return { refused: `[CLM] ${message}` };
458
+ }
459
+ const waiting = compactPending.get(sessionID);
460
+ if (waiting && Date.now() - waiting.at < COMPACT_PENDING_MS) {
461
+ const message = "A /clm-compact is already queued and the model has not received it yet.";
462
+ toast(message, "info");
463
+ return { refused: `[CLM] ${message}` };
464
+ }
465
+ // Reserved before the first await, so a second invocation in the meantime is refused.
466
+ const entry: { prompt?: string; at: number } = { at: Date.now() };
467
+ compactPending.set(sessionID, entry);
468
+ const refuse = (message: string) => {
469
+ if (compactPending.get(sessionID) === entry) compactPending.delete(sessionID);
470
+ toast(message, "warning");
471
+ return { refused: `[CLM] ${message}` };
472
+ };
473
+ try {
474
+ await clm.refreshSettings();
475
+ const template = loadCompactPrompt(clm.settings.compactPromptPath);
476
+ if (!clm.enabled) {
477
+ return refuse("CLM is off for this session, so there is no editable context to compact. Turn it on with /clm on.");
478
+ }
479
+ const { current, busy } = await measure();
480
+ if (current === undefined) return refuse("Nothing to compact yet: the context is empty.");
481
+ toast(`Asked the model to compact its context (now about ${formatTokens(current)} tokens` +
482
+ `${busy ? ", measured before the current run finishes" : ""}).`);
483
+ const prompt = buildCompactPrompt(template, {
484
+ mirror: clm.mirrorPath,
485
+ current,
486
+ budget: clm.resolvedBudget()?.budget,
487
+ instructions: argument,
488
+ });
489
+ entry.prompt = prompt;
490
+ entry.at = Date.now();
491
+ return { prompt };
492
+ } catch (error) {
493
+ if (compactPending.get(sessionID) === entry) compactPending.delete(sessionID);
494
+ throw error;
495
+ }
496
+ }
497
+
498
+ return {
499
+ sessions,
500
+ session,
501
+ opened,
502
+ linked,
503
+ linkFork,
504
+ persistAnnotationsOf,
505
+ setSetting,
506
+ clearSettings,
507
+ dropRevision,
508
+ statusCommand,
509
+ compactCommand,
510
+ compactPending,
511
+ };
512
+ }