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/index.ts CHANGED
@@ -27,22 +27,48 @@
27
27
  * - `tool.definition` carries no session id; it runs per tool each time OpenCode builds the
28
28
  * tool set, before the request's `messages.transform`.
29
29
  */
30
+ import { rmSync } from "node:fs";
31
+ import { mkdir, readdir, readFile, realpath, stat } from "node:fs/promises";
32
+ import { isAbsolute, relative, resolve, sep } from "node:path";
33
+
30
34
  import { tool, type Hooks, type Plugin, type PluginInput, type PluginOptions } from "@opencode-ai/plugin";
31
35
 
36
+ import { formatTokens } from "./src/budget.ts";
32
37
  import { ClmSession } from "./src/clm.ts";
38
+ import { copyPrivateFile, MirrorDirectoryError, privateTemporaryParent } from "./src/mirror-store.ts";
39
+ import {
40
+ applyCompactionMode,
41
+ compactionAuto,
42
+ lastFinishedStep,
43
+ nativeCompactionText,
44
+ oneToolText,
45
+ openCodeMaxOutput,
46
+ openCodeUsable,
47
+ OPENCODE_OUTPUT_TOKEN_MAX,
48
+ outputTokenMaxFlag,
49
+ overflowNotCompactedText,
50
+ thresholdCancelledNoticeText,
51
+ thresholdCancelledText,
52
+ thresholdPausedText,
53
+ thresholdReachable,
54
+ ThresholdWatch,
55
+ ToolCallCounter,
56
+ } from "./src/compaction.ts";
33
57
  import { buildCompactPrompt, loadCompactPrompt } from "./src/compact.ts";
34
- import { continuityTools } from "./src/continuity.ts";
35
- import { replaceInPlace, type OcMessage } from "./src/opencode.ts";
58
+ import { ANNOTATIONS_FILE, continuityTools, persistAnnotations, restorePersisted } from "./src/continuity.ts";
59
+ import { filterCompacted, replaceInPlace, type OcMessage } from "./src/opencode.ts";
36
60
  import { statusText, systemGuidance } from "./src/presentation.ts";
37
61
  import { COMPACT_COMMAND, COMPACT_TEMPLATE, STATUS_COMMAND, STATUS_TEMPLATE } from "./src/commands.ts";
38
- import { changeSetting, resetSettings, showSetting } from "./src/overrides.ts";
62
+ import { changeSetting, OVERRIDES_FILE, resetSettings, showSetting } from "./src/overrides.ts";
63
+ import { STATE_FILE } from "./src/state.ts";
64
+ import { CHANNEL_VERSION, commandOf, decodeRequest, encodeReply, MAX_READ_BYTES, type ChannelReply, type ChannelRequest } from "./src/channel.ts";
39
65
  import { settingsText } from "./src/settings-table.ts";
40
66
  import { parseClmCommand, type Page } from "./src/panel/command.ts";
41
67
  import { buildPanelModel } from "./src/panel/model.ts";
42
68
  import { panelPageText } from "./src/panel/text.ts";
43
- import { readSessionDirectory } from "./src/session-files.ts";
69
+ import { readSessionDirectory, sessionDirectory } from "./src/session-files.ts";
44
70
  import { fallbackBudget, settingsView } from "./src/tui/data.ts";
45
- import { resolveSettings, SKILLS_DIR, type ClmSettings } from "./src/settings.ts";
71
+ import { defaultMirrorDir, resolveSettings, SKILLS_DIR, type ClmSettings } from "./src/settings.ts";
46
72
  import { loadSteeringDocument, steeringPromptSection, type SteeringDocument } from "./src/steering.ts";
47
73
 
48
74
  export const PLUGIN_ID = "opencode-clm";
@@ -55,7 +81,9 @@ const HELPER_PROMPTS = [
55
81
  "You are a context summarization agent",
56
82
  ];
57
83
 
58
- const STATUS_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | config [setting [value] | reset]]";
84
+ const STATUS_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | budget [value] | config [setting [value] | reset]]";
85
+ /** A pending `/clm-compact` older than this no longer refuses a new one (its prompt may have failed to send). */
86
+ const COMPACT_PENDING_MS = 5 * 60_000;
59
87
 
60
88
  type ToastVariant = "info" | "success" | "warning" | "error";
61
89
 
@@ -74,6 +102,13 @@ export function checkSessionID(sessionID: unknown): string {
74
102
  return sessionID;
75
103
  }
76
104
 
105
+ /** ENOENT/ENOTDIR → undefined (missing); anything else rethrown. */
106
+ function missingAsUndefined(error: unknown): undefined {
107
+ const code = (error as NodeJS.ErrnoException)?.code;
108
+ if (code === "ENOENT" || code === "ENOTDIR") return undefined;
109
+ throw error;
110
+ }
111
+
77
112
  function isHelperPrompt(system: readonly string[]): boolean {
78
113
  const first = system[0]?.trimStart() ?? "";
79
114
  return HELPER_PROMPTS.some((prompt) => first.startsWith(prompt));
@@ -87,8 +122,18 @@ function relay(text: string): string {
87
122
  export const server: Plugin = async (input: PluginInput, options?: PluginOptions): Promise<Hooks> => {
88
123
  const settings: ClmSettings = resolveSettings(options ?? {}, process.env, input.directory);
89
124
  if (!settings.enabled) return {};
90
- // Read once at load: a missing or empty steering file fails the plugin with its path.
91
- const steering: SteeringDocument | undefined = settings.steeringPath ? loadSteeringDocument(settings.steeringPath) : undefined;
125
+ // Read once at load. A missing or empty steering file does not stop the plugin (OpenCode
126
+ // would drop it silently, plugin/index.ts:230-241): sessions run protocol-only, each one
127
+ // records the error in its settings warning (`/clm status`), and a toast says so.
128
+ let steering: SteeringDocument | undefined;
129
+ let steeringError: string | undefined;
130
+ if (settings.steeringPath) {
131
+ try {
132
+ steering = loadSteeringDocument(settings.steeringPath);
133
+ } catch (error) {
134
+ steeringError = describe(error);
135
+ }
136
+ }
92
137
 
93
138
  const sessions = new Map<string, Promise<ClmSession>>();
94
139
  /** Estimated tokens per tool schema, from `tool.definition` (no session id there). */
@@ -107,14 +152,309 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
107
152
  .then(() => input.client?.app?.log?.({ body: { service: PLUGIN_ID, level, message } }))
108
153
  .catch(() => undefined);
109
154
  };
155
+ /**
156
+ * The live config object from the `config` hook, and the user's own `compaction.auto`
157
+ * (undefined = not set) for the `compaction` setting. See src/compaction.ts.
158
+ */
159
+ let liveConfig: { compaction?: { auto?: boolean; reserved?: number } } | undefined;
160
+ let userAutoCompaction: boolean | undefined;
161
+ /** Which requests ran with the flag turned off by the plugin, and crossings reported (K1, K5). */
162
+ const thresholds = new ThresholdWatch();
163
+ /** Whether the user's config had a `compaction` object; one the plugin added is removed again. */
164
+ let userHadCompaction = false;
165
+ /**
166
+ * The `compaction.auto` value each session's newest request decided (undefined = the user's
167
+ * value). Re-applied after each of the session's tool calls: a subagent (`task` tool) runs
168
+ * its own requests inside the parent's step and overwrites the instance-wide flag before
169
+ * OpenCode's finish-step check of the parent reads it (session/processor.ts:491).
170
+ */
171
+ const flagDecisions = new Map<string, boolean | undefined>();
172
+ const applyFlag = (value: boolean | undefined) => {
173
+ if (!liveConfig) return;
174
+ if (value !== undefined) {
175
+ (liveConfig.compaction ??= {}).auto = value;
176
+ return;
177
+ }
178
+ applyCompactionMode(liveConfig, "auto", userAutoCompaction);
179
+ if (!userHadCompaction && liveConfig.compaction && Object.keys(liveConfig.compaction).length === 0) delete liveConfig.compaction;
180
+ };
181
+ const decideFlag = (sessionID: string, value: boolean | undefined) => {
182
+ if (!liveConfig) return;
183
+ // Re-inserted, so the map's order is the order of the newest decisions.
184
+ flagDecisions.delete(sessionID);
185
+ flagDecisions.set(sessionID, value);
186
+ applyFlag(value);
187
+ };
188
+ /**
189
+ * A session went idle (`session.idle`, session/status.ts:41-44; e.g. a subagent finished,
190
+ * also when the parent's `task` call then threw, which skips `tool.execute.after`,
191
+ * session/tools.ts:111): its decision no longer governs anything, so
192
+ * the newest decision of another session takes the flag back. With no other session the
193
+ * flag stays: the idle session's own next prompt is checked against it first
194
+ * (prompt.ts:1161). Best effort: the event reaches the plugin through the bus and can
195
+ * arrive after the parent's finish-step check.
196
+ */
197
+ const endFlag = (sessionID: string) => {
198
+ if (!flagDecisions.delete(sessionID) || flagDecisions.size === 0) return;
199
+ applyFlag([...flagDecisions.values()].at(-1));
200
+ };
201
+ /** A request that goes out without CLM (fail-open): the user's flag, no pause left behind. */
202
+ const releaseFlag = (sessionID: string) => {
203
+ decideFlag(sessionID, undefined);
204
+ thresholds.set(sessionID, undefined);
205
+ };
206
+ /** OpenCode's threshold for the session's model (src/compaction.ts `openCodeUsable`). */
207
+ const thresholdOf = (clm: ClmSession) => openCodeUsable(clm.limits, liveConfig?.compaction?.reserved, outputTokenMaxFlag());
208
+ /** Sessions whose newest transform was OpenCode's compaction request (no overflow notice for it). */
209
+ const compactionRequests = new Set<string>();
210
+ /** Tool calls per session since its last model request (`one-tool`). */
211
+ const toolCalls = new ToolCallCounter();
212
+
213
+ if (steeringError) {
214
+ const message = `steering document not loaded, sessions run without it: ${steeringError}`;
215
+ log("error", message);
216
+ toast(message, "error");
217
+ }
110
218
  /** Command names this plugin defined; a user's own `clm` / `clm-compact` stays theirs. */
111
219
  const ownCommands = new Set<string>();
112
220
 
221
+ /**
222
+ * OpenCode's record of a session: its metadata (`Session.Info.metadata`, a free record) and
223
+ * whether it is a child (subagent) session (`parentID`). Undefined when the client is
224
+ * unavailable; a failed call is logged as `stamp-error`. The v1 SDK's `Session` type omits
225
+ * `metadata`.
226
+ */
227
+ const sessionRecord = async (clm: ClmSession): Promise<{ metadata: Record<string, unknown>; child: boolean; created?: number } | undefined> => {
228
+ const get = input.client?.session?.get;
229
+ if (typeof get !== "function") return undefined;
230
+ try {
231
+ const result = await input.client.session.get({ path: { id: clm.sessionID } });
232
+ const failed = (result as { error?: unknown } | undefined)?.error;
233
+ const data = (result as { data?: { metadata?: unknown; parentID?: unknown; time?: { created?: unknown } } } | undefined)?.data;
234
+ if (failed !== undefined || !data) {
235
+ await clm.log({ event: "stamp-error", stage: "get", error: JSON.stringify(failed ?? "no data") });
236
+ return undefined;
237
+ }
238
+ const metadata = data.metadata;
239
+ return {
240
+ metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? (metadata as Record<string, unknown>) : {},
241
+ child: typeof data.parentID === "string" && data.parentID !== "",
242
+ ...(typeof data.time?.created === "number" ? { created: data.time.created } : {}),
243
+ };
244
+ } catch (error) {
245
+ await clm.log({ event: "stamp-error", stage: "get", error: describe(error) });
246
+ return undefined;
247
+ }
248
+ };
249
+ /**
250
+ * Read-modify-write of a session's metadata, one at a time per session: `PATCH
251
+ * /session/:id` replaces the whole record, so concurrent writers (the fork stamp, two
252
+ * annotation changes in one step) must each build their body from the previous result.
253
+ */
254
+ const metadataChains = new Map<string, Promise<unknown>>();
255
+ const inMetadataChain = <T>(sessionID: string, task: () => Promise<T>): Promise<T> => {
256
+ const run = (metadataChains.get(sessionID) ?? Promise.resolve()).then(task, task);
257
+ const tail = run.catch(() => undefined);
258
+ metadataChains.set(sessionID, tail);
259
+ void tail.then(() => {
260
+ if (metadataChains.get(sessionID) === tail) metadataChains.delete(sessionID);
261
+ });
262
+ return run;
263
+ };
264
+
265
+ /** Sessions whose stamp was handled in this process (`linkFork`). */
266
+ const linked = new Set<string>();
267
+
268
+ /**
269
+ * Fork detection. The plugin stamps `metadata.clm.origin = <own id>` on every session it
270
+ * opens; `Session.fork` copies the metadata into the fork, so a stamp naming another session
271
+ * marks a fork of it. A fresh fork restores the origin's newest matching revision on its
272
+ * first request (clm.ts `restoreFromFork`); the stamp is then rewritten to the fork's id.
273
+ * `PATCH /session/:id` replaces the whole metadata record, so the other keys are copied.
274
+ */
275
+ const linkFork = (clm: ClmSession): Promise<void> => inMetadataChain(clm.sessionID, async () => {
276
+ const record = await sessionRecord(clm);
277
+ // Child (subagent) sessions are never forked: no stamp, no PATCH bumping their time.
278
+ if (!record || record.child) return;
279
+ const metadata = record.metadata;
280
+ const stamp = metadata.clm && typeof metadata.clm === "object" && !Array.isArray(metadata.clm) ? (metadata.clm as Record<string, unknown>) : {};
281
+ const origin = stamp.origin;
282
+ // `annotations` is the store of a session without a mirror (M1). A session with a mirror
283
+ // drops it, so a later run without one does not restore a stale set; a fork drops the
284
+ // origin's copy and persists its own after its first request.
285
+ const own = origin === clm.sessionID;
286
+ const fork = typeof origin === "string" && !own;
287
+ const dropAnnotations = "annotations" in stamp && (fork || clm.mirrorUnavailable === undefined);
288
+ if (own && !dropAnnotations) return;
289
+ if (!own && typeof origin === "string" && !clm.state.checkpoint && clm.state.revision === 0) {
290
+ try {
291
+ clm.forkOrigin = {
292
+ sessionID: origin,
293
+ directory: sessionDirectory(settings.mirrorDir, checkSessionID(origin)),
294
+ ...(record.created !== undefined ? { created: record.created } : {}),
295
+ };
296
+ } catch {
297
+ // a foreign stamp that is not a usable session id: ignore it
298
+ }
299
+ }
300
+ try {
301
+ const { annotations: _dropped, ...kept } = stamp;
302
+ const next = { ...(dropAnnotations ? kept : stamp), origin: clm.sessionID };
303
+ const result = await input.client?.session?.update?.({ path: { id: clm.sessionID }, body: { metadata: { ...metadata, clm: next } } } as never);
304
+ const error = (result as { error?: unknown } | undefined)?.error;
305
+ if (error) await clm.log({ event: "stamp-error", stage: "update", error: JSON.stringify(error) });
306
+ } catch (error) {
307
+ await clm.log({ event: "stamp-error", stage: "update", error: describe(error) });
308
+ }
309
+ });
310
+
311
+ /** The project's default mirror parent, inside the project (no external-directory prompts). */
312
+ const projectMirrorDir = defaultMirrorDir(input.directory);
313
+ /** Sessions already told that CLM could not start for them (one error toast each). */
314
+ const openFailures = new Set<string>();
315
+
316
+ /** Private parent for sessions without a usable mirror directory, made on first use (`openWithoutMirror`). */
317
+ let unmirroredParent: Promise<string> | undefined;
318
+
319
+ /**
320
+ * Opens a session in `mirrorDir`. Only a failure to create or use the session directory
321
+ * (`MirrorDirectoryError`) is handled here; any other error propagates as before.
322
+ * - An explicitly configured `mirrorDir` that fails: the session uses the project default
323
+ * `.opencode/clm` instead, and a toast names both. Inside the project the model's tools
324
+ * reach it without an `external_directory` permission prompt.
325
+ * - The default fails too: the session runs without a mirror (`openWithoutMirror`).
326
+ */
327
+ const openSession = async (id: string): Promise<ClmSession> => {
328
+ const failures: MirrorDirectoryError[] = [];
329
+ try {
330
+ return await ClmSession.open(id, settings, { steering });
331
+ } catch (error) {
332
+ if (!(error instanceof MirrorDirectoryError)) throw error;
333
+ failures.push(error);
334
+ }
335
+ if (settings.mirrorDir !== projectMirrorDir) {
336
+ try {
337
+ const clm = await ClmSession.open(id, { ...settings, mirrorDir: projectMirrorDir }, { steering });
338
+ const error = failures[0]!;
339
+ const message = `could not use the configured mirrorDir ${settings.mirrorDir} (${describe(error.cause)}); the files of session ${id} are in ${clm.store.directory} instead.`;
340
+ log("warn", message);
341
+ toast(message, "warning");
342
+ await clm.log({ event: "mirror-dir-fallback", configured: settings.mirrorDir, used: projectMirrorDir, reason: describe(error.cause) });
343
+ return clm;
344
+ } catch (error) {
345
+ if (!(error instanceof MirrorDirectoryError)) throw error;
346
+ failures.push(error);
347
+ }
348
+ }
349
+ return await openWithoutMirror(id, failures);
350
+ };
351
+
352
+ /**
353
+ * pi-clm's degraded mode (src/index.ts `replaceStore` and the `context` handler's `!store`
354
+ * branch): no mirror, no protocol prompt, no edits; requests carry the raw history plus the
355
+ * continuity annotations and their size notice. The session's files go to a private
356
+ * directory under the OS temp directory, made once per process and removed at exit. The
357
+ * model is never pointed there, so no `external_directory` prompt arises. state.json,
358
+ * overrides.json and annotations.jsonl still readable in a failed session directory (ours,
359
+ * not a symlink) are copied in before the session loads them; changes stay in the copy.
360
+ * When even that fails, the open fails: raw history, one "not active" toast naming every
361
+ * reason.
362
+ */
363
+ const openWithoutMirror = async (id: string, failures: MirrorDirectoryError[]): Promise<ClmSession> => {
364
+ const reason = failures.map((failure) => `${failure.parent}: ${describe(failure.cause)}`).join("; ");
365
+ const copied: string[] = [];
366
+ let copiedFrom: string | undefined;
367
+ let clm: ClmSession;
368
+ try {
369
+ if (!unmirroredParent) {
370
+ unmirroredParent = privateTemporaryParent().then((parent) => {
371
+ process.once("exit", () => rmSync(parent, { recursive: true, force: true }));
372
+ return parent;
373
+ });
374
+ unmirroredParent.catch(() => (unmirroredParent = undefined));
375
+ }
376
+ const parent = await unmirroredParent;
377
+ const directory = sessionDirectory(parent, id);
378
+ await mkdir(directory, { recursive: true, mode: 0o700 });
379
+ for (const name of [STATE_FILE, OVERRIDES_FILE, ANNOTATIONS_FILE]) {
380
+ for (const failure of failures) {
381
+ const from = sessionDirectory(failure.parent, id);
382
+ if (await copyPrivateFile(from, directory, name)) {
383
+ copied.push(name);
384
+ copiedFrom ??= from;
385
+ break;
386
+ }
387
+ }
388
+ }
389
+ clm = await ClmSession.open(id, { ...settings, mirrorDir: parent }, { steering });
390
+ } catch (error) {
391
+ throw new Error(`no mirror directory could be used (${reason}), and no temporary directory either: ${describe(error)}`);
392
+ }
393
+ clm.mirrorUnavailable = reason;
394
+ await restoreAnnotations(clm);
395
+ const kept = copiedFrom ? `${copied.join(", ")} copied from ${copiedFrom}; changes stay` : "No saved session files found; the session's files are";
396
+ 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.`;
397
+ log("error", message);
398
+ toast(message, "error");
399
+ await clm.log({ event: "mirror-unavailable", reason, directory: clm.store.directory, ...(copiedFrom ? { copiedFrom, copied } : {}) });
400
+ return clm;
401
+ };
402
+
403
+ /** `metadata.clm` of a session record, or an empty object. */
404
+ const clmStamp = (metadata: Record<string, unknown>): Record<string, unknown> =>
405
+ metadata.clm && typeof metadata.clm === "object" && !Array.isArray(metadata.clm) ? (metadata.clm as Record<string, unknown>) : {};
406
+
407
+ /**
408
+ * M1: a session without a mirror keeps its files in a per-process temp directory, so its
409
+ * annotations (resolutions made while degraded, and the snapshots copied in) would be lost
410
+ * at restart; pi keeps them in session entries. They go to OpenCode's session metadata
411
+ * (`metadata.clm.annotations`, bounded by PERSISTED_ANNOTATIONS_BYTES) after each change
412
+ * and are restored when the session opens without a mirror again.
413
+ */
414
+ const restoreAnnotations = async (clm: ClmSession): Promise<void> => {
415
+ const record = await inMetadataChain(clm.sessionID, () => sessionRecord(clm));
416
+ const stamp = record ? clmStamp(record.metadata) : {};
417
+ const persisted = stamp.annotations;
418
+ if (persisted === undefined) return;
419
+ // A fork's metadata is the origin's clone: the fork's first request applies the
420
+ // fork-point cutoff to that set (clm.ts `copyForkAnnotations`) instead.
421
+ if (typeof stamp.origin === "string" && stamp.origin !== clm.sessionID && !record!.child) {
422
+ clm.forkPersisted = restorePersisted([], persisted);
423
+ return;
424
+ }
425
+ try {
426
+ const snapshots = restorePersisted(await clm.annotations.list(), persisted);
427
+ await clm.annotations.appendAll(snapshots);
428
+ if (snapshots.length > 0) await clm.log({ event: "annotations-restored", count: snapshots.length });
429
+ } catch (error) {
430
+ await clm.log({ event: "annotations-restore-error", error: describe(error) });
431
+ }
432
+ };
433
+ const persistAnnotationsOf = async (sessionID: string): Promise<void> => {
434
+ const clm = await opened(sessionID);
435
+ if (!clm || clm.mirrorUnavailable === undefined) return;
436
+ await inMetadataChain(sessionID, () => persistNow(clm));
437
+ };
438
+ const persistNow = async (clm: ClmSession): Promise<void> => {
439
+ const record = await sessionRecord(clm);
440
+ if (!record) return;
441
+ try {
442
+ const annotations = persistAnnotations(await clm.annotations.list());
443
+ const metadata = { ...record.metadata, clm: { ...clmStamp(record.metadata), annotations } };
444
+ const result = await input.client?.session?.update?.({ path: { id: clm.sessionID }, body: { metadata } } as never);
445
+ const error = (result as { error?: unknown } | undefined)?.error;
446
+ if (error) throw new Error(JSON.stringify(error));
447
+ await clm.log({ event: "annotations-persisted", count: annotations.annotations.length, resolved: annotations.resolved.length });
448
+ } catch (error) {
449
+ await clm.log({ event: "annotations-persist-error", error: describe(error) });
450
+ }
451
+ };
452
+
113
453
  const session = (sessionID: string): Promise<ClmSession> => {
114
454
  const id = checkSessionID(sessionID);
115
455
  let existing = sessions.get(id);
116
456
  if (!existing) {
117
- existing = ClmSession.open(id, settings, { steering }).then((clm) => {
457
+ existing = openSession(id).then(async (clm) => {
118
458
  if (clm.loadWarning) toast(`state reset for ${id}: ${clm.loadWarning}`, "warning");
119
459
  return clm;
120
460
  });
@@ -139,6 +479,24 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
139
479
  return total;
140
480
  };
141
481
 
482
+ /** One setting through `changeSetting`, activated at once; returns `Label: value`. */
483
+ async function setSetting(clm: ClmSession, name: string, value: string): Promise<string> {
484
+ const result = await changeSetting(clm.changeRequest(input.directory), name, value);
485
+ await clm.refreshSettings(true);
486
+ return result.text;
487
+ }
488
+
489
+ async function clearSettings(clm: ClmSession): Promise<void> {
490
+ await resetSettings(clm.store.directory);
491
+ await clm.refreshSettings(true);
492
+ toast("CLM settings reset to the defaults.");
493
+ }
494
+
495
+ async function dropRevision(clm: ClmSession): Promise<void> {
496
+ await clm.resetProjection("/clm reset");
497
+ toast("CLM revision dropped");
498
+ }
499
+
142
500
  /**
143
501
  * `/clm config …` as text (pi-clm's `/clm config` semantics; the TUI opens the settings
144
502
  * page instead). `words` keep their case: values may be paths.
@@ -146,19 +504,16 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
146
504
  async function configCommand(clm: ClmSession, words: string[]): Promise<string> {
147
505
  const [name, ...valueWords] = words;
148
506
  const { base, effective } = clm.settingsValues();
149
- const format = { modelWindow: clm.limits.context };
507
+ const format = { modelWindow: clm.limits.context, modelOutput: clm.limits.output };
150
508
  if (!name) return settingsText(base, effective, format, clm.settingsWarning);
151
509
  if (name.toLowerCase() === "reset" && valueWords.length === 0) {
152
- await resetSettings(clm.store.directory);
153
- await clm.refreshSettings(true);
154
- toast("CLM settings reset to the defaults.");
510
+ await clearSettings(clm);
155
511
  return "CLM settings reset to the defaults for this session.";
156
512
  }
157
513
  if (valueWords.length === 0) return showSetting(name, effective, format);
158
- const result = await changeSetting(clm.changeRequest(input.directory), name, valueWords.join(" "));
159
- await clm.refreshSettings(true);
160
- toast(`CLM ${result.text}.`);
161
- return `CLM ${result.text}. It applies from the next request.`;
514
+ const text = await setSetting(clm, name, valueWords.join(" "));
515
+ toast(`CLM ${text}.`);
516
+ return `CLM ${text}. It applies from the next request.`;
162
517
  }
163
518
 
164
519
  /** `/clm overview|input|edits|settings` outside the TUI: the panel page as plain text. */
@@ -167,7 +522,13 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
167
522
  const budget = fallbackBudget(clm.settings, clm.limits);
168
523
  const model = buildPanelModel(files, budget ? { budget } : {});
169
524
  model.enabled = clm.enabled;
170
- const format = { ...(clm.limits.context ? { modelWindow: clm.limits.context } : {}) };
525
+ if (clm.noticesOnly) model.noticesOnly = true;
526
+ // No mirror: name why, never the temporary directory (the text reaches the model).
527
+ if (clm.mirrorUnavailable !== undefined) {
528
+ model.mirrorUnavailable = clm.mirrorUnavailable;
529
+ model.mirrorPath = `unavailable (${clm.mirrorUnavailable})`;
530
+ }
531
+ const format = { ...(clm.limits.context ? { modelWindow: clm.limits.context } : {}), ...(clm.limits.output ? { modelOutput: clm.limits.output } : {}) };
171
532
  model.settings = settingsView(clm.settingsValues(), model, { format, ...(clm.settingsWarning ? { warning: clm.settingsWarning } : {}) });
172
533
  return panelPageText(model, page);
173
534
  }
@@ -177,6 +538,8 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
177
538
  const words = args.split(/\s+/).filter(Boolean);
178
539
  const argument = (words[0] ?? "").toLowerCase();
179
540
  if (argument === "config") return configCommand(clm, words.slice(1));
541
+ // pi's shorthand: `/clm budget <value>` = `/clm config budget <value>`.
542
+ if (argument === "budget") return configCommand(clm, ["budget", ...words.slice(1)]);
180
543
  const command = parseClmCommand(`/clm ${args}`);
181
544
  if (args !== "" && command?.kind === "open") return pageCommand(clm, command.page);
182
545
  if (words.length > 1) return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
@@ -188,20 +551,29 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
188
551
  return text;
189
552
  }
190
553
  case "path":
191
- return `CLM mirror: ${clm.mirrorPath}`;
554
+ if (clm.mirrorUnavailable !== undefined) return `CLM mirror: unavailable (${clm.mirrorUnavailable})`;
555
+ return clm.noticesOnly ? "CLM mirror: not used (mode notices-only)" : `CLM mirror: ${clm.mirrorPath}`;
192
556
  case "on":
193
557
  case "off": {
194
558
  // The same override the TUI and `/clm config editing` write.
195
- await changeSetting(clm.changeRequest(input.directory), "editing", argument);
196
- await clm.refreshSettings(true);
559
+ await setSetting(clm, "editing", argument);
197
560
  toast(`CLM ${argument} for this session`);
561
+ if (clm.mirrorUnavailable !== undefined) {
562
+ return argument === "on"
563
+ ? "CLM is on for this session. No mirror is available: requests carry the raw history plus the continuity annotations."
564
+ : "CLM is off for this session: requests carry the raw history.";
565
+ }
566
+ if (clm.noticesOnly) {
567
+ return argument === "on"
568
+ ? "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."
569
+ : "CLM is off for this session: requests carry the raw history and no notices.";
570
+ }
198
571
  return argument === "on"
199
572
  ? "CLM is on for this session: the mirror is refreshed before the next request."
200
573
  : "CLM is off for this session: requests carry the raw history and the mirror is no longer read.";
201
574
  }
202
575
  case "reset": {
203
- await clm.resetProjection("/clm reset");
204
- toast("CLM revision dropped");
576
+ await dropRevision(clm);
205
577
  return "CLM dropped the accepted revision: the next request carries the stored history, and the mirror is rewritten from it.";
206
578
  }
207
579
  default:
@@ -209,17 +581,208 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
209
581
  }
210
582
  }
211
583
 
212
- function compactCommand(clm: ClmSession, argument: string): string {
213
- return buildCompactPrompt(loadCompactPrompt(clm.settings.compactPromptPath), {
584
+ /** Session ids the server confirmed; a panel reload sends several requests per session. */
585
+ const knownSessions = new Set<string>();
586
+
587
+ /** False when the server does not know the session (the request came from elsewhere). */
588
+ async function knownSession(sessionID: string): Promise<boolean> {
589
+ if (knownSessions.has(sessionID) || sessions.has(sessionID)) return true;
590
+ const get = input.client?.session?.get;
591
+ if (typeof get !== "function") return true;
592
+ try {
593
+ const result = await input.client.session.get({ path: { id: sessionID } });
594
+ const known = Boolean(result?.data) && !result?.error;
595
+ if (known) knownSessions.add(sessionID);
596
+ return known;
597
+ } catch {
598
+ return false;
599
+ }
600
+ }
601
+
602
+ /** A request from the TUI over the channel (src/channel.ts): apply it, answer with its id. */
603
+ async function channelRequest(request: ChannelRequest): Promise<Omit<ChannelReply, "v" | "id">> {
604
+ const sessionID = checkSessionID(request.session);
605
+ if (!(await knownSession(sessionID))) throw new Error(`no session ${sessionID} on this server`);
606
+ // A session that fell back from the configured mirrorDir (openSession) lives elsewhere.
607
+ const opened = await sessions.get(sessionID)?.catch(() => undefined);
608
+ const directory = opened?.store.directory ?? sessionDirectory(settings.mirrorDir, sessionID);
609
+ switch (request.op) {
610
+ case "locate":
611
+ return { ok: true, text: "", directory, root: input.directory };
612
+ case "read": {
613
+ const path = await insideSession(directory, request.path);
614
+ if (path === undefined) return { ok: true, text: "" };
615
+ const size = (await stat(path).catch(missingAsUndefined))?.size;
616
+ if (size === undefined) return { ok: true, text: "" };
617
+ if (size > MAX_READ_BYTES) {
618
+ return { ok: false, text: `${request.path} is ${size} bytes, more than the ${MAX_READ_BYTES} a channel reply carries` };
619
+ }
620
+ const content = await readFile(path, "utf8").catch(missingAsUndefined);
621
+ return { ok: true, text: "", ...(content !== undefined ? { content } : {}) };
622
+ }
623
+ case "list": {
624
+ const path = await insideSession(directory, request.path);
625
+ if (path === undefined) return { ok: true, text: "" };
626
+ const names = await readdir(path).catch(missingAsUndefined);
627
+ return { ok: true, text: "", ...(names !== undefined ? { names } : {}) };
628
+ }
629
+ }
630
+ const clm = await session(sessionID);
631
+ await clm.refreshSettings();
632
+ switch (request.op) {
633
+ case "set": {
634
+ const text = await setSetting(clm, request.setting, request.value);
635
+ toast(`CLM ${text}.`);
636
+ return { ok: true, text };
637
+ }
638
+ case "settings-reset":
639
+ await clearSettings(clm);
640
+ return { ok: true, text: "CLM settings reset to the defaults." };
641
+ case "reset":
642
+ await dropRevision(clm);
643
+ return { ok: true, text: "CLM revision dropped" };
644
+ }
645
+ }
646
+
647
+ /**
648
+ * `path` inside the session directory, else a refusal; undefined when it does not exist.
649
+ * Checked twice: lexically, then with symlinks resolved on both sides, as OpenCode's own
650
+ * file API does (packages/core/src/filesystem.ts:66-71), so a link the model created in
651
+ * the mirror directory cannot point a `read` elsewhere.
652
+ */
653
+ async function insideSession(directory: string, path: string): Promise<string | undefined> {
654
+ const outside = (root: string, target: string) => {
655
+ const rel = relative(root, target);
656
+ return isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`);
657
+ };
658
+ const target = resolve(directory, path);
659
+ if (outside(directory, target)) throw new Error(`${path} lies outside the session directory`);
660
+ const realRoot = await realpath(directory).catch(missingAsUndefined);
661
+ const realTarget = await realpath(target).catch(missingAsUndefined);
662
+ if (realRoot === undefined || realTarget === undefined) return undefined;
663
+ if (outside(realRoot, realTarget)) throw new Error(`${path} lies outside the session directory`);
664
+ return realTarget;
665
+ }
666
+
667
+ const reply = (value: ChannelReply) => {
668
+ void Promise.resolve()
669
+ .then(() => input.client?.tui?.publish?.({ body: { type: "tui.command.execute", properties: { command: encodeReply(value) } } as never }))
670
+ .catch(() => undefined);
671
+ };
672
+
673
+ /**
674
+ * `/clm-compact [instructions]`: the compaction prompt, with the size of the context as
675
+ * it would be sent now. pi's checks (pi src/index.ts compactCommand): CLM off and an empty
676
+ * context are refused with a toast; the model then gets a short relay instead of the
677
+ * prompt (the turn cannot be cancelled, see C14). OpenCode does not queue a command behind
678
+ * a busy session: `SessionPrompt.command` fires `command.execute.before` at once and its
679
+ * prompt joins the running loop (session/prompt.ts:1346, 1356-1466). Typed mid-run, the
680
+ * size is therefore measured mid-run, without the rest of the current turn; the toast says
681
+ * so. pi waits for idle instead; this port accepts the lag. A second invocation before the
682
+ * model received the first prompt is refused like pi's (`compactPending`, below).
683
+ */
684
+ async function compactCommand(clm: ClmSession, sessionID: string, argument: string): Promise<string> {
685
+ const waiting = compactPending.get(sessionID);
686
+ if (waiting && Date.now() - waiting.at < COMPACT_PENDING_MS) {
687
+ const message = "A /clm-compact is already queued and the model has not received it yet.";
688
+ toast(message, "info");
689
+ return relay(`[CLM] ${message}`);
690
+ }
691
+ // Reserved before the first await, so a second invocation in the meantime is refused.
692
+ const entry: { prompt?: string; at: number } = { at: Date.now() };
693
+ compactPending.set(sessionID, entry);
694
+ const refuse = (message: string) => {
695
+ if (compactPending.get(sessionID) === entry) compactPending.delete(sessionID);
696
+ toast(message, "warning");
697
+ return relay(`[CLM] ${message}`);
698
+ };
699
+ try {
700
+ return await compactPrompt(clm, sessionID, argument, entry, refuse);
701
+ } catch (error) {
702
+ if (compactPending.get(sessionID) === entry) compactPending.delete(sessionID);
703
+ throw error;
704
+ }
705
+ }
706
+
707
+ async function compactPrompt(
708
+ clm: ClmSession,
709
+ sessionID: string,
710
+ argument: string,
711
+ entry: { prompt?: string; at: number },
712
+ refuse: (message: string) => string,
713
+ ): Promise<string> {
714
+ await clm.refreshSettings();
715
+ const template = loadCompactPrompt(clm.settings.compactPromptPath);
716
+ if (!clm.enabled) {
717
+ return refuse("CLM is off for this session, so there is no editable context to compact. Turn it on with /clm on.");
718
+ }
719
+ // Without a history (the client cannot list messages) the last request's estimate stands in.
720
+ const history = await sessionMessages(sessionID);
721
+ const current = history ? clm.idleEstimate(filterCompacted(history)) : clm.lastReading?.estimated ?? 0;
722
+ if (current === undefined) return refuse("Nothing to compact yet: the context is empty.");
723
+ const busy = await sessionBusy(sessionID);
724
+ toast(`Asked the model to compact its context (now about ${formatTokens(current)} tokens` +
725
+ `${busy ? ", measured before the current run finishes" : ""}).`);
726
+ const prompt = buildCompactPrompt(template, {
214
727
  mirror: clm.mirrorPath,
215
- current: clm.lastReading?.estimated ?? 0,
728
+ current,
216
729
  budget: clm.resolvedBudget()?.budget,
217
730
  instructions: argument,
218
731
  });
732
+ entry.prompt = prompt;
733
+ entry.at = Date.now();
734
+ return prompt;
735
+ }
736
+
737
+ /**
738
+ * Sessions with a `/clm-compact` prompt the model has not received yet, and that prompt.
739
+ * pi refuses a second `/clm-compact` while one waits (`compactPending`, pi src/index.ts:235,
740
+ * 997-1000): set when the command is accepted (:1008), cleared once its prompt is sent
741
+ * (:1046). OpenCode sends the prompt itself, into the running loop or a new one, so here
742
+ * pending ends at the first model request whose history holds the prompt (the
743
+ * `messages.transform` hook), or when the session goes idle or fails without one
744
+ * (`session.idle`, session/status.ts:43; `session.error`). If OpenCode's prompt() fails after
745
+ * the hook, neither event may come; an entry older than COMPACT_PENDING_MS no longer refuses.
746
+ */
747
+ const compactPending = new Map<string, { prompt?: string; at: number }>();
748
+
749
+ const holdsPrompt = (raw: OcMessage[], prompt: string) =>
750
+ raw.some((message) => message.info.role === "user" && message.parts.some((part) => part.type === "text" && part.text === prompt));
751
+
752
+ /** Whether OpenCode reports the session busy or retrying; false when the client cannot tell. */
753
+ async function sessionBusy(sessionID: string): Promise<boolean> {
754
+ const status = input.client?.session?.status;
755
+ if (typeof status !== "function") return false;
756
+ try {
757
+ const result = await input.client.session.status();
758
+ const entry = (result?.data as Record<string, { type?: string }> | undefined)?.[sessionID];
759
+ return entry?.type === "busy" || entry?.type === "retry";
760
+ } catch {
761
+ return false;
762
+ }
763
+ }
764
+
765
+ /** The session's stored messages, or undefined when the client cannot list them. */
766
+ async function sessionMessages(sessionID: string): Promise<OcMessage[] | undefined> {
767
+ const list = input.client?.session?.messages;
768
+ if (typeof list !== "function") return undefined;
769
+ try {
770
+ const result = await input.client.session.messages({ path: { id: sessionID } });
771
+ return Array.isArray(result?.data) ? (result.data as unknown as OcMessage[]) : undefined;
772
+ } catch {
773
+ return undefined;
774
+ }
219
775
  }
220
776
 
221
777
  const hooks: Hooks = {
222
778
  async config(config) {
779
+ liveConfig = config as { compaction?: { auto?: boolean; reserved?: number } };
780
+ const auto = liveConfig.compaction?.auto;
781
+ userAutoCompaction = typeof auto === "boolean" ? auto : undefined;
782
+ userHadCompaction = liveConfig.compaction !== undefined;
783
+ // `enabled: false` returns no hooks at all (above), so this runs only with CLM enabled;
784
+ // the guard keeps it that way if that early return ever moves.
785
+ if (settings.enabled && settings.compaction !== "auto") applyCompactionMode(liveConfig, settings.compaction, userAutoCompaction);
223
786
  if (settings.commands) {
224
787
  const commands = (config.command ??= {});
225
788
  const ours = {
@@ -263,12 +826,15 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
263
826
  return;
264
827
  }
265
828
  await clm.refreshSettings();
266
- if (!clm.enabled) return;
267
- const limit = (hookInput.model as { limit?: { context?: number; output?: number } } | undefined)?.limit;
268
- if (limit) clm.limits = { context: limit.context || undefined, output: limit.output || undefined };
269
- const sections = [systemGuidance(clm.mirrorPath, clm.resolvedBudget()?.budget)];
829
+ // Without a mirror the model gets no protocol text (pi-clm's before_agent_start).
830
+ if (!clm.enabled || clm.mirrorUnavailable !== undefined) return;
831
+ const limit = (hookInput.model as { limit?: { context?: number; input?: number; output?: number } } | undefined)?.limit;
832
+ if (limit) clm.limits = { context: limit.context || undefined, output: limit.output || undefined, ...(limit.input ? { input: limit.input } : {}) };
833
+ if (hookInput.model) clm.limitsKnown = true;
834
+ // notices-only: no protocol section (there is no mirror); the steering document stays.
835
+ const sections = clm.noticesOnly ? [] : [systemGuidance(clm.mirrorPath, clm.resolvedBudget()?.budget)];
270
836
  if (clm.steering) sections.push(steeringPromptSection(clm.steering));
271
- output.system.push(sections.join("\n\n"));
837
+ if (sections.length > 0) output.system.push(sections.join("\n\n"));
272
838
  clm.scope = { ...clm.scope, systemTokens: clm.textTokens(output.system.join("\n")) };
273
839
  },
274
840
 
@@ -276,24 +842,87 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
276
842
  const raw = output.messages as unknown as OcMessage[];
277
843
  const sessionID = raw[0]?.info?.sessionID;
278
844
  if (!sessionID) return;
845
+ const pendingPrompt = compactPending.get(sessionID)?.prompt;
846
+ if (pendingPrompt !== undefined && holdsPrompt(raw, pendingPrompt)) compactPending.delete(sessionID);
847
+ // A new model request: `one-tool` counts its tool calls from zero.
848
+ toolCalls.reset(sessionID);
279
849
  let clm: ClmSession;
280
850
  try {
281
851
  clm = await session(sessionID);
282
852
  } catch (error) {
283
- toast(`not active for session ${sessionID}: ${describe(error)}`, "error");
853
+ // The request goes out with the raw history; say so once per session, not per step.
854
+ if (!openFailures.has(sessionID)) {
855
+ openFailures.add(sessionID);
856
+ toast(`not active for session ${sessionID}, requests carry the raw history: ${describe(error)}`, "error");
857
+ log("error", `not active for session ${sessionID}: ${describe(error)}`);
858
+ }
859
+ releaseFlag(sessionID);
284
860
  return;
285
861
  }
862
+ // Stamp (and detect a fork) once per process, only while CLM edits this session:
863
+ // a session with CLM off is left untouched in OpenCode's store.
864
+ if (!linked.has(sessionID)) {
865
+ await clm.refreshSettings().catch(() => undefined);
866
+ if (clm.enabled) {
867
+ linked.add(sessionID);
868
+ await linkFork(clm);
869
+ }
870
+ }
286
871
  const tools = toolTokens();
287
872
  if (tools !== undefined) clm.scope = { ...clm.scope, toolTokens: tools };
873
+ const compaction = clm.compacting;
874
+ if (compaction) compactionRequests.add(sessionID);
875
+ else compactionRequests.delete(sessionID);
876
+ if (!compaction) {
877
+ // K5: the step before this request reached OpenCode's threshold while the flag this
878
+ // session set was false, so OpenCode skipped a threshold compaction (prompt.ts:1161).
879
+ const usable = thresholdOf(clm);
880
+ const cancelled = thresholds.observe(sessionID, lastFinishedStep(raw), usable);
881
+ if (cancelled && usable !== undefined) {
882
+ await clm.log({ event: "compaction-cancelled", reason: "threshold", setting: cancelled.by, count: cancelled.count, usable });
883
+ if (cancelled.by === "off") {
884
+ clm.queueNotice(thresholdCancelledNoticeText(clm.noticesOnly ? undefined : clm.mirrorPath));
885
+ toast(thresholdCancelledText(cancelled.count, usable));
886
+ } else toast(thresholdPausedText(cancelled.count, usable, clm.guardLimit()));
887
+ }
888
+ }
288
889
  try {
289
890
  const result = await clm.transform(raw);
290
891
  replaceInPlace(raw, result.messages);
892
+ if (clm.annotationsImported) {
893
+ clm.annotationsImported = false;
894
+ await persistAnnotationsOf(sessionID);
895
+ }
896
+ // Instance-wide flag, set per request from this session's setting: OpenCode reads
897
+ // it after this step and before the next one (src/compaction.ts). Sessions running
898
+ // concurrently in one server overwrite each other's value.
899
+ if (!compaction && liveConfig) {
900
+ // Without a mirror, OpenCode's compaction is the only way to shrink: leave it as configured.
901
+ const active = clm.settings.enabled && clm.enabled && clm.mirrorUnavailable === undefined;
902
+ const mode = active ? clm.settings.compaction : "auto";
903
+ // K1, pi's `auto`: while the guard enforces a budget, pause threshold compaction for
904
+ // a request whose count can reach OpenCode's threshold (src/compaction.ts).
905
+ const usable = thresholdOf(clm);
906
+ const pause = active && mode === "auto" && userAutoCompaction !== false && clm.settings.guard !== "off" &&
907
+ clm.guardLimit() !== undefined && result.estimated !== undefined && usable !== undefined &&
908
+ thresholdReachable(result.estimated, usable, openCodeMaxOutput(clm.limits.output, Math.max(OPENCODE_OUTPUT_TOKEN_MAX, outputTokenMaxFlag() ?? 0)));
909
+ decideFlag(sessionID, pause ? false : compactionAuto(mode, userAutoCompaction));
910
+ if (pause !== (thresholds.by(sessionID) === "auto")) {
911
+ await clm.log({ event: "compaction-pause", paused: pause, estimated: result.estimated, usable });
912
+ }
913
+ thresholds.set(sessionID, pause ? "auto" : active && mode === "off" && userAutoCompaction !== false ? "off" : undefined);
914
+ }
291
915
  if (result.alert) {
292
916
  toast(result.alert, "warning");
293
917
  log("warn", result.alert);
294
918
  }
919
+ for (const error of result.errors ?? []) {
920
+ toast(error, "error");
921
+ log("error", `${sessionID}: ${error}`);
922
+ }
295
923
  } catch (error) {
296
- // Fail open: the request goes out with the raw history.
924
+ // Fail open: the request goes out with the raw history, under the user's flag.
925
+ if (!compaction) releaseFlag(sessionID);
297
926
  await clm.log({ event: "error", message: describe(error), stack: error instanceof Error ? error.stack : undefined });
298
927
  toast(`transform failed: ${describe(error)}`, "error");
299
928
  }
@@ -302,6 +931,11 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
302
931
  async "experimental.session.compacting"(hookInput, output) {
303
932
  const clm = await opened(hookInput.sessionID);
304
933
  if (!clm) return;
934
+ try {
935
+ await clm.commitBeforeCompaction();
936
+ } catch (error) {
937
+ await clm.log({ event: "error", message: `commit before compaction: ${describe(error)}` });
938
+ }
305
939
  clm.compacting = true;
306
940
  try {
307
941
  const context = await clm.compactionContext();
@@ -313,13 +947,65 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
313
947
 
314
948
  async "experimental.compaction.autocontinue"(hookInput) {
315
949
  const clm = await opened(hookInput.sessionID);
316
- if (clm) clm.compacted = true;
950
+ if (!clm) return;
951
+ clm.compacted = true;
952
+ thresholds.reset(hookInput.sessionID);
953
+ if (!clm.settings.enabled || !clm.enabled) return;
954
+ // Automatic compaction only (manual /compact does not reach this hook).
955
+ const overflow = hookInput.overflow === true;
956
+ await clm.log({ event: "native-compaction", reason: overflow ? "overflow" : "threshold", setting: clm.settings.compaction });
957
+ toast(nativeCompactionText(overflow, clm.settings.compaction, clm.noticesOnly));
317
958
  },
318
959
 
319
960
  async event({ event }) {
961
+ const command = commandOf(event);
962
+ if (command !== undefined) {
963
+ const request = decodeRequest(command);
964
+ if (!request) return;
965
+ let answer: Omit<ChannelReply, "v" | "id">;
966
+ try {
967
+ answer = await channelRequest(request);
968
+ } catch (error) {
969
+ answer = { ok: false, text: describe(error) };
970
+ log("warn", `channel ${request.op} for ${request.session}: ${answer.text}`);
971
+ }
972
+ reply({ ...answer, v: CHANNEL_VERSION, id: request.id });
973
+ return;
974
+ }
975
+ if (event.type === "session.idle" || event.type === "session.error") {
976
+ const ended = (event.properties as { sessionID?: string }).sessionID;
977
+ if (ended) {
978
+ compactPending.delete(ended);
979
+ // Not on session.error: the overflow check below reads the flag OpenCode read.
980
+ if (event.type === "session.idle") endFlag(ended);
981
+ }
982
+ }
983
+ if (event.type === "session.error") {
984
+ const { sessionID, error } = event.properties as { sessionID?: string; error?: { name?: string } };
985
+ if (!sessionID || error?.name !== "ContextOverflowError") return;
986
+ const clm = await opened(sessionID);
987
+ if (!clm || !clm.settings.enabled || !clm.enabled) return;
988
+ // OpenCode surfaced the overflow instead of compacting when the flag it read was
989
+ // false (processor.ts:620-628). The event follows that read synchronously, so the
990
+ // live flag, not this session's setting, says what happened (another session may
991
+ // have set it). An overflow of the compaction request itself is not this case.
992
+ if (liveConfig?.compaction?.auto !== false || compactionRequests.has(sessionID)) return;
993
+ if (clm.mirrorUnavailable !== undefined) {
994
+ await clm.log({ event: "overflow-not-compacted", setting: clm.settings.compaction });
995
+ toast("The provider rejected the request as too long, and OpenCode's automatic compaction is off. Run /compact.", "warning");
996
+ return;
997
+ }
998
+ clm.queueNotice(overflowNotCompactedText(clm.noticesOnly ? undefined : clm.mirrorPath));
999
+ await clm.log({ event: "overflow-not-compacted", setting: clm.settings.compaction });
1000
+ toast(clm.noticesOnly
1001
+ ? "The provider rejected the request as too long, and OpenCode's automatic compaction is off. Run /compact."
1002
+ : "The provider rejected the request as too long, and OpenCode's automatic compaction is off. Edit the mirror or run /compact.", "warning");
1003
+ return;
1004
+ }
320
1005
  if (event.type !== "session.compacted") return;
321
1006
  const clm = await opened(event.properties.sessionID);
322
1007
  if (clm) clm.compacted = true;
1008
+ thresholds.reset(event.properties.sessionID);
323
1009
  },
324
1010
 
325
1011
  async "tool.definition"(hookInput, output) {
@@ -337,7 +1023,20 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
337
1023
  toolSizes.set(hookInput.toolID, Math.ceil((size / 4) * settings.estimateFactor));
338
1024
  },
339
1025
 
1026
+ async "tool.execute.before"(hookInput) {
1027
+ // Counted before any await: parallel calls of one response arrive concurrently.
1028
+ const position = toolCalls.next(hookInput.sessionID);
1029
+ if (position < 2) return;
1030
+ const clm = await opened(hookInput.sessionID);
1031
+ if (!clm || !clm.settings.enabled || !clm.enabled || !clm.settings.oneTool) return;
1032
+ await clm.log({ event: "tool-blocked", tool: hookInput.tool, position });
1033
+ // OpenCode turns the throw into the call's error result, which the model sees.
1034
+ throw new Error(oneToolText(hookInput.tool, position));
1035
+ },
1036
+
340
1037
  async "tool.execute.after"(hookInput, output) {
1038
+ // Before any await: the session's own flag back after a subagent's requests.
1039
+ if (flagDecisions.has(hookInput.sessionID)) applyFlag(flagDecisions.get(hookInput.sessionID));
341
1040
  const clm = await opened(hookInput.sessionID);
342
1041
  if (!clm || typeof output.output !== "string") return;
343
1042
  // Runs inside the tool's own call: a throw here would fail the model's command.
@@ -346,10 +1045,12 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
346
1045
  receipt = clm.receipt(hookInput.tool, hookInput.args, input.directory);
347
1046
  } catch (error) {
348
1047
  await clm.log({ event: "receipt-error", tool: hookInput.tool, message: describe(error) });
349
- return;
350
1048
  }
351
- if (!receipt) return;
352
- output.output = output.output ? `${output.output}${output.output.endsWith("\n") ? "\n" : "\n\n"}${receipt}` : receipt;
1049
+ if (receipt) output.output = output.output ? `${output.output}${output.output.endsWith("\n") ? "\n" : "\n\n"}${receipt}` : receipt;
1050
+ // Last, so it measures the result as the model gets it. Not for failed calls:
1051
+ // OpenCode runs this hook only after a successful execute (session/tools.ts:105-123).
1052
+ const trailer = clm.sizeTrailer(output.output);
1053
+ if (trailer) output.output = `${output.output}${trailer}`;
353
1054
  },
354
1055
 
355
1056
  async "command.execute.before"(hookInput, output) {
@@ -359,9 +1060,20 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
359
1060
  try {
360
1061
  const clm = await session(hookInput.sessionID);
361
1062
  const argument = (hookInput.arguments ?? "").trim();
362
- text = hookInput.command === STATUS_COMMAND
363
- ? relay(await statusCommand(clm, argument))
364
- : compactCommand(clm, argument);
1063
+ if (hookInput.command === STATUS_COMMAND) text = relay(await statusCommand(clm, argument));
1064
+ else if (clm.mirrorUnavailable !== undefined) {
1065
+ // pi-clm's compactCommand: nothing for the model to edit, so nothing is asked of it.
1066
+ toast(`The context mirror is unavailable, so the model cannot edit its context (${clm.mirrorUnavailable}).`, "warning");
1067
+ text = relay("[CLM] The context mirror is unavailable, so the model cannot edit its context.");
1068
+ } else {
1069
+ await clm.refreshSettings();
1070
+ if (clm.noticesOnly) {
1071
+ // The compaction prompt asks for a mirror edit; notices-only has no mirror.
1072
+ const message = "CLM runs in notices-only mode for this session, so the model cannot edit its context. Switch with /clm config mode edit.";
1073
+ toast(message, "warning");
1074
+ text = relay(`[CLM] ${message}`);
1075
+ } else text = await compactCommand(clm, hookInput.sessionID, argument);
1076
+ }
365
1077
  } catch (error) {
366
1078
  const message = `/${hookInput.command} failed: ${describe(error)}`;
367
1079
  toast(message, "error");
@@ -378,6 +1090,7 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
378
1090
  schema: tool.schema,
379
1091
  store: async (sessionID) => (await session(sessionID)).annotations,
380
1092
  block: async (sessionID, blockId) => (await session(sessionID)).blockSource(blockId),
1093
+ onChange: persistAnnotationsOf,
381
1094
  }),
382
1095
  };
383
1096
  return hooks;