opencode-clm 0.1.1 → 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/index.ts CHANGED
@@ -27,19 +27,52 @@
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
- import { resolveSettings, SKILLS_DIR, type ClmSettings } from "./src/settings.ts";
61
+ import { COMPACT_COMMAND, COMPACT_TEMPLATE, STATUS_COMMAND, STATUS_TEMPLATE } from "./src/commands.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";
65
+ import { settingsText } from "./src/settings-table.ts";
66
+ import { parseClmCommand, type Page } from "./src/panel/command.ts";
67
+ import { buildPanelModel } from "./src/panel/model.ts";
68
+ import { panelPageText } from "./src/panel/text.ts";
69
+ import { readSessionDirectory, sessionDirectory } from "./src/session-files.ts";
70
+ import { fallbackBudget, settingsView } from "./src/tui/data.ts";
71
+ import { defaultMirrorDir, resolveSettings, SKILLS_DIR, type ClmSettings } from "./src/settings.ts";
38
72
  import { loadSteeringDocument, steeringPromptSection, type SteeringDocument } from "./src/steering.ts";
39
73
 
40
74
  export const PLUGIN_ID = "opencode-clm";
41
- export const STATUS_COMMAND = "clm";
42
- export const COMPACT_COMMAND = "clm-compact";
75
+ export { COMPACT_COMMAND, STATUS_COMMAND } from "./src/commands.ts";
43
76
 
44
77
  /** Opening words of the system prompts of OpenCode 1.18 helper agents (title, summary, compaction). */
45
78
  const HELPER_PROMPTS = [
@@ -48,7 +81,9 @@ const HELPER_PROMPTS = [
48
81
  "You are a context summarization agent",
49
82
  ];
50
83
 
51
- const STATUS_USAGE = "Usage: /clm [status | path | on | off | 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;
52
87
 
53
88
  type ToastVariant = "info" | "success" | "warning" | "error";
54
89
 
@@ -67,6 +102,13 @@ export function checkSessionID(sessionID: unknown): string {
67
102
  return sessionID;
68
103
  }
69
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
+
70
112
  function isHelperPrompt(system: readonly string[]): boolean {
71
113
  const first = system[0]?.trimStart() ?? "";
72
114
  return HELPER_PROMPTS.some((prompt) => first.startsWith(prompt));
@@ -80,8 +122,18 @@ function relay(text: string): string {
80
122
  export const server: Plugin = async (input: PluginInput, options?: PluginOptions): Promise<Hooks> => {
81
123
  const settings: ClmSettings = resolveSettings(options ?? {}, process.env, input.directory);
82
124
  if (!settings.enabled) return {};
83
- // Read once at load: a missing or empty steering file fails the plugin with its path.
84
- 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
+ }
85
137
 
86
138
  const sessions = new Map<string, Promise<ClmSession>>();
87
139
  /** Estimated tokens per tool schema, from `tool.definition` (no session id there). */
@@ -100,14 +152,309 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
100
152
  .then(() => input.client?.app?.log?.({ body: { service: PLUGIN_ID, level, message } }))
101
153
  .catch(() => undefined);
102
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
+ }
103
218
  /** Command names this plugin defined; a user's own `clm` / `clm-compact` stays theirs. */
104
219
  const ownCommands = new Set<string>();
105
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
+
106
453
  const session = (sessionID: string): Promise<ClmSession> => {
107
454
  const id = checkSessionID(sessionID);
108
455
  let existing = sessions.get(id);
109
456
  if (!existing) {
110
- existing = ClmSession.open(id, settings).then((clm) => {
457
+ existing = openSession(id).then(async (clm) => {
111
458
  if (clm.loadWarning) toast(`state reset for ${id}: ${clm.loadWarning}`, "warning");
112
459
  return clm;
113
460
  });
@@ -132,55 +479,313 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
132
479
  return total;
133
480
  };
134
481
 
135
- async function statusCommand(clm: ClmSession, argument: string): Promise<string> {
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
+
500
+ /**
501
+ * `/clm config …` as text (pi-clm's `/clm config` semantics; the TUI opens the settings
502
+ * page instead). `words` keep their case: values may be paths.
503
+ */
504
+ async function configCommand(clm: ClmSession, words: string[]): Promise<string> {
505
+ const [name, ...valueWords] = words;
506
+ const { base, effective } = clm.settingsValues();
507
+ const format = { modelWindow: clm.limits.context };
508
+ if (!name) return settingsText(base, effective, format, clm.settingsWarning);
509
+ if (name.toLowerCase() === "reset" && valueWords.length === 0) {
510
+ await clearSettings(clm);
511
+ return "CLM settings reset to the defaults for this session.";
512
+ }
513
+ if (valueWords.length === 0) return showSetting(name, effective, format);
514
+ const text = await setSetting(clm, name, valueWords.join(" "));
515
+ toast(`CLM ${text}.`);
516
+ return `CLM ${text}. It applies from the next request.`;
517
+ }
518
+
519
+ /** `/clm overview|input|edits|settings` outside the TUI: the panel page as plain text. */
520
+ async function pageCommand(clm: ClmSession, page: Page): Promise<string> {
521
+ const files = await readSessionDirectory(clm.store.directory, clm.sessionID);
522
+ const budget = fallbackBudget(clm.settings, clm.limits);
523
+ const model = buildPanelModel(files, budget ? { budget } : {});
524
+ model.enabled = clm.enabled;
525
+ // No mirror: name why, never the temporary directory (the text reaches the model).
526
+ if (clm.mirrorUnavailable !== undefined) {
527
+ model.mirrorUnavailable = clm.mirrorUnavailable;
528
+ model.mirrorPath = `unavailable (${clm.mirrorUnavailable})`;
529
+ }
530
+ const format = { ...(clm.limits.context ? { modelWindow: clm.limits.context } : {}) };
531
+ model.settings = settingsView(clm.settingsValues(), model, { format, ...(clm.settingsWarning ? { warning: clm.settingsWarning } : {}) });
532
+ return panelPageText(model, page);
533
+ }
534
+
535
+ async function statusCommand(clm: ClmSession, args: string): Promise<string> {
536
+ await clm.refreshSettings();
537
+ const words = args.split(/\s+/).filter(Boolean);
538
+ const argument = (words[0] ?? "").toLowerCase();
539
+ if (argument === "config") return configCommand(clm, words.slice(1));
540
+ // pi's shorthand: `/clm budget <value>` = `/clm config budget <value>`.
541
+ if (argument === "budget") return configCommand(clm, ["budget", ...words.slice(1)]);
542
+ const command = parseClmCommand(`/clm ${args}`);
543
+ if (args !== "" && command?.kind === "open") return pageCommand(clm, command.page);
544
+ if (words.length > 1) return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
136
545
  switch (argument) {
137
546
  case "":
138
547
  case "status": {
139
- const text = statusText(clm.status(steering));
548
+ const text = statusText(clm.status());
140
549
  toast(text.split("\n").slice(0, 3).join("\n"));
141
550
  return text;
142
551
  }
143
552
  case "path":
144
- return `CLM mirror: ${clm.mirrorPath}`;
553
+ return clm.mirrorUnavailable === undefined ? `CLM mirror: ${clm.mirrorPath}` : `CLM mirror: unavailable (${clm.mirrorUnavailable})`;
145
554
  case "on":
146
555
  case "off": {
147
- await clm.setEnabled(argument === "on");
556
+ // The same override the TUI and `/clm config editing` write.
557
+ await setSetting(clm, "editing", argument);
148
558
  toast(`CLM ${argument} for this session`);
559
+ if (clm.mirrorUnavailable !== undefined) {
560
+ return argument === "on"
561
+ ? "CLM is on for this session. No mirror is available: requests carry the raw history plus the continuity annotations."
562
+ : "CLM is off for this session: requests carry the raw history.";
563
+ }
149
564
  return argument === "on"
150
565
  ? "CLM is on for this session: the mirror is refreshed before the next request."
151
566
  : "CLM is off for this session: requests carry the raw history and the mirror is no longer read.";
152
567
  }
153
568
  case "reset": {
154
- await clm.resetProjection("/clm reset");
155
- toast("CLM revision dropped");
569
+ await dropRevision(clm);
156
570
  return "CLM dropped the accepted revision: the next request carries the stored history, and the mirror is rewritten from it.";
157
571
  }
158
572
  default:
159
- return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(argument)}`;
573
+ return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
574
+ }
575
+ }
576
+
577
+ /** Session ids the server confirmed; a panel reload sends several requests per session. */
578
+ const knownSessions = new Set<string>();
579
+
580
+ /** False when the server does not know the session (the request came from elsewhere). */
581
+ async function knownSession(sessionID: string): Promise<boolean> {
582
+ if (knownSessions.has(sessionID) || sessions.has(sessionID)) return true;
583
+ const get = input.client?.session?.get;
584
+ if (typeof get !== "function") return true;
585
+ try {
586
+ const result = await input.client.session.get({ path: { id: sessionID } });
587
+ const known = Boolean(result?.data) && !result?.error;
588
+ if (known) knownSessions.add(sessionID);
589
+ return known;
590
+ } catch {
591
+ return false;
160
592
  }
161
593
  }
162
594
 
163
- function compactCommand(clm: ClmSession, argument: string): string {
164
- return buildCompactPrompt(loadCompactPrompt(settings.compactPromptPath), {
595
+ /** A request from the TUI over the channel (src/channel.ts): apply it, answer with its id. */
596
+ async function channelRequest(request: ChannelRequest): Promise<Omit<ChannelReply, "v" | "id">> {
597
+ const sessionID = checkSessionID(request.session);
598
+ if (!(await knownSession(sessionID))) throw new Error(`no session ${sessionID} on this server`);
599
+ // A session that fell back from the configured mirrorDir (openSession) lives elsewhere.
600
+ const opened = await sessions.get(sessionID)?.catch(() => undefined);
601
+ const directory = opened?.store.directory ?? sessionDirectory(settings.mirrorDir, sessionID);
602
+ switch (request.op) {
603
+ case "locate":
604
+ return { ok: true, text: "", directory, root: input.directory };
605
+ case "read": {
606
+ const path = await insideSession(directory, request.path);
607
+ if (path === undefined) return { ok: true, text: "" };
608
+ const size = (await stat(path).catch(missingAsUndefined))?.size;
609
+ if (size === undefined) return { ok: true, text: "" };
610
+ if (size > MAX_READ_BYTES) {
611
+ return { ok: false, text: `${request.path} is ${size} bytes, more than the ${MAX_READ_BYTES} a channel reply carries` };
612
+ }
613
+ const content = await readFile(path, "utf8").catch(missingAsUndefined);
614
+ return { ok: true, text: "", ...(content !== undefined ? { content } : {}) };
615
+ }
616
+ case "list": {
617
+ const path = await insideSession(directory, request.path);
618
+ if (path === undefined) return { ok: true, text: "" };
619
+ const names = await readdir(path).catch(missingAsUndefined);
620
+ return { ok: true, text: "", ...(names !== undefined ? { names } : {}) };
621
+ }
622
+ }
623
+ const clm = await session(sessionID);
624
+ await clm.refreshSettings();
625
+ switch (request.op) {
626
+ case "set": {
627
+ const text = await setSetting(clm, request.setting, request.value);
628
+ toast(`CLM ${text}.`);
629
+ return { ok: true, text };
630
+ }
631
+ case "settings-reset":
632
+ await clearSettings(clm);
633
+ return { ok: true, text: "CLM settings reset to the defaults." };
634
+ case "reset":
635
+ await dropRevision(clm);
636
+ return { ok: true, text: "CLM revision dropped" };
637
+ }
638
+ }
639
+
640
+ /**
641
+ * `path` inside the session directory, else a refusal; undefined when it does not exist.
642
+ * Checked twice: lexically, then with symlinks resolved on both sides, as OpenCode's own
643
+ * file API does (packages/core/src/filesystem.ts:66-71), so a link the model created in
644
+ * the mirror directory cannot point a `read` elsewhere.
645
+ */
646
+ async function insideSession(directory: string, path: string): Promise<string | undefined> {
647
+ const outside = (root: string, target: string) => {
648
+ const rel = relative(root, target);
649
+ return isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`);
650
+ };
651
+ const target = resolve(directory, path);
652
+ if (outside(directory, target)) throw new Error(`${path} lies outside the session directory`);
653
+ const realRoot = await realpath(directory).catch(missingAsUndefined);
654
+ const realTarget = await realpath(target).catch(missingAsUndefined);
655
+ if (realRoot === undefined || realTarget === undefined) return undefined;
656
+ if (outside(realRoot, realTarget)) throw new Error(`${path} lies outside the session directory`);
657
+ return realTarget;
658
+ }
659
+
660
+ const reply = (value: ChannelReply) => {
661
+ void Promise.resolve()
662
+ .then(() => input.client?.tui?.publish?.({ body: { type: "tui.command.execute", properties: { command: encodeReply(value) } } as never }))
663
+ .catch(() => undefined);
664
+ };
665
+
666
+ /**
667
+ * `/clm-compact [instructions]`: the compaction prompt, with the size of the context as
668
+ * it would be sent now. pi's checks (pi src/index.ts compactCommand): CLM off and an empty
669
+ * context are refused with a toast; the model then gets a short relay instead of the
670
+ * prompt (the turn cannot be cancelled, see C14). OpenCode does not queue a command behind
671
+ * a busy session: `SessionPrompt.command` fires `command.execute.before` at once and its
672
+ * prompt joins the running loop (session/prompt.ts:1346, 1356-1466). Typed mid-run, the
673
+ * size is therefore measured mid-run, without the rest of the current turn; the toast says
674
+ * so. pi waits for idle instead; this port accepts the lag. A second invocation before the
675
+ * model received the first prompt is refused like pi's (`compactPending`, below).
676
+ */
677
+ async function compactCommand(clm: ClmSession, sessionID: string, argument: string): Promise<string> {
678
+ const waiting = compactPending.get(sessionID);
679
+ if (waiting && Date.now() - waiting.at < COMPACT_PENDING_MS) {
680
+ const message = "A /clm-compact is already queued and the model has not received it yet.";
681
+ toast(message, "info");
682
+ return relay(`[CLM] ${message}`);
683
+ }
684
+ // Reserved before the first await, so a second invocation in the meantime is refused.
685
+ const entry: { prompt?: string; at: number } = { at: Date.now() };
686
+ compactPending.set(sessionID, entry);
687
+ const refuse = (message: string) => {
688
+ if (compactPending.get(sessionID) === entry) compactPending.delete(sessionID);
689
+ toast(message, "warning");
690
+ return relay(`[CLM] ${message}`);
691
+ };
692
+ try {
693
+ return await compactPrompt(clm, sessionID, argument, entry, refuse);
694
+ } catch (error) {
695
+ if (compactPending.get(sessionID) === entry) compactPending.delete(sessionID);
696
+ throw error;
697
+ }
698
+ }
699
+
700
+ async function compactPrompt(
701
+ clm: ClmSession,
702
+ sessionID: string,
703
+ argument: string,
704
+ entry: { prompt?: string; at: number },
705
+ refuse: (message: string) => string,
706
+ ): Promise<string> {
707
+ await clm.refreshSettings();
708
+ const template = loadCompactPrompt(clm.settings.compactPromptPath);
709
+ if (!clm.enabled) {
710
+ return refuse("CLM is off for this session, so there is no editable context to compact. Turn it on with /clm on.");
711
+ }
712
+ // Without a history (the client cannot list messages) the last request's estimate stands in.
713
+ const history = await sessionMessages(sessionID);
714
+ const current = history ? clm.idleEstimate(filterCompacted(history)) : clm.lastReading?.estimated ?? 0;
715
+ if (current === undefined) return refuse("Nothing to compact yet: the context is empty.");
716
+ const busy = await sessionBusy(sessionID);
717
+ toast(`Asked the model to compact its context (now about ${formatTokens(current)} tokens` +
718
+ `${busy ? ", measured before the current run finishes" : ""}).`);
719
+ const prompt = buildCompactPrompt(template, {
165
720
  mirror: clm.mirrorPath,
166
- current: clm.lastReading?.estimated ?? 0,
721
+ current,
167
722
  budget: clm.resolvedBudget()?.budget,
168
723
  instructions: argument,
169
724
  });
725
+ entry.prompt = prompt;
726
+ entry.at = Date.now();
727
+ return prompt;
728
+ }
729
+
730
+ /**
731
+ * Sessions with a `/clm-compact` prompt the model has not received yet, and that prompt.
732
+ * pi refuses a second `/clm-compact` while one waits (`compactPending`, pi src/index.ts:235,
733
+ * 997-1000): set when the command is accepted (:1008), cleared once its prompt is sent
734
+ * (:1046). OpenCode sends the prompt itself, into the running loop or a new one, so here
735
+ * pending ends at the first model request whose history holds the prompt (the
736
+ * `messages.transform` hook), or when the session goes idle or fails without one
737
+ * (`session.idle`, session/status.ts:43; `session.error`). If OpenCode's prompt() fails after
738
+ * the hook, neither event may come; an entry older than COMPACT_PENDING_MS no longer refuses.
739
+ */
740
+ const compactPending = new Map<string, { prompt?: string; at: number }>();
741
+
742
+ const holdsPrompt = (raw: OcMessage[], prompt: string) =>
743
+ raw.some((message) => message.info.role === "user" && message.parts.some((part) => part.type === "text" && part.text === prompt));
744
+
745
+ /** Whether OpenCode reports the session busy or retrying; false when the client cannot tell. */
746
+ async function sessionBusy(sessionID: string): Promise<boolean> {
747
+ const status = input.client?.session?.status;
748
+ if (typeof status !== "function") return false;
749
+ try {
750
+ const result = await input.client.session.status();
751
+ const entry = (result?.data as Record<string, { type?: string }> | undefined)?.[sessionID];
752
+ return entry?.type === "busy" || entry?.type === "retry";
753
+ } catch {
754
+ return false;
755
+ }
756
+ }
757
+
758
+ /** The session's stored messages, or undefined when the client cannot list them. */
759
+ async function sessionMessages(sessionID: string): Promise<OcMessage[] | undefined> {
760
+ const list = input.client?.session?.messages;
761
+ if (typeof list !== "function") return undefined;
762
+ try {
763
+ const result = await input.client.session.messages({ path: { id: sessionID } });
764
+ return Array.isArray(result?.data) ? (result.data as unknown as OcMessage[]) : undefined;
765
+ } catch {
766
+ return undefined;
767
+ }
170
768
  }
171
769
 
172
770
  const hooks: Hooks = {
173
771
  async config(config) {
772
+ liveConfig = config as { compaction?: { auto?: boolean; reserved?: number } };
773
+ const auto = liveConfig.compaction?.auto;
774
+ userAutoCompaction = typeof auto === "boolean" ? auto : undefined;
775
+ userHadCompaction = liveConfig.compaction !== undefined;
776
+ // `enabled: false` returns no hooks at all (above), so this runs only with CLM enabled;
777
+ // the guard keeps it that way if that early return ever moves.
778
+ if (settings.enabled && settings.compaction !== "auto") applyCompactionMode(liveConfig, settings.compaction, userAutoCompaction);
174
779
  if (settings.commands) {
175
780
  const commands = (config.command ??= {});
176
781
  const ours = {
177
782
  [STATUS_COMMAND]: {
178
783
  description: "CLM: status of the editable context; /clm path | on | off | reset",
179
- template: "Show the CLM status. $ARGUMENTS",
784
+ template: STATUS_TEMPLATE,
180
785
  },
181
786
  [COMPACT_COMMAND]: {
182
787
  description: "CLM: ask the model to compact its context by editing the mirror",
183
- template: "Compact your context. $ARGUMENTS",
788
+ template: COMPACT_TEMPLATE,
184
789
  },
185
790
  };
186
791
  for (const [name, definition] of Object.entries(ours)) {
@@ -213,11 +818,13 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
213
818
  } catch {
214
819
  return;
215
820
  }
216
- if (!clm.state.enabled) return;
217
- const limit = (hookInput.model as { limit?: { context?: number; output?: number } } | undefined)?.limit;
218
- if (limit) clm.limits = { context: limit.context || undefined, output: limit.output || undefined };
821
+ await clm.refreshSettings();
822
+ // Without a mirror the model gets no protocol text (pi-clm's before_agent_start).
823
+ if (!clm.enabled || clm.mirrorUnavailable !== undefined) return;
824
+ const limit = (hookInput.model as { limit?: { context?: number; input?: number; output?: number } } | undefined)?.limit;
825
+ if (limit) clm.limits = { context: limit.context || undefined, output: limit.output || undefined, ...(limit.input ? { input: limit.input } : {}) };
219
826
  const sections = [systemGuidance(clm.mirrorPath, clm.resolvedBudget()?.budget)];
220
- if (steering) sections.push(steeringPromptSection(steering));
827
+ if (clm.steering) sections.push(steeringPromptSection(clm.steering));
221
828
  output.system.push(sections.join("\n\n"));
222
829
  clm.scope = { ...clm.scope, systemTokens: clm.textTokens(output.system.join("\n")) };
223
830
  },
@@ -226,24 +833,87 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
226
833
  const raw = output.messages as unknown as OcMessage[];
227
834
  const sessionID = raw[0]?.info?.sessionID;
228
835
  if (!sessionID) return;
836
+ const pendingPrompt = compactPending.get(sessionID)?.prompt;
837
+ if (pendingPrompt !== undefined && holdsPrompt(raw, pendingPrompt)) compactPending.delete(sessionID);
838
+ // A new model request: `one-tool` counts its tool calls from zero.
839
+ toolCalls.reset(sessionID);
229
840
  let clm: ClmSession;
230
841
  try {
231
842
  clm = await session(sessionID);
232
843
  } catch (error) {
233
- toast(`not active for session ${sessionID}: ${describe(error)}`, "error");
844
+ // The request goes out with the raw history; say so once per session, not per step.
845
+ if (!openFailures.has(sessionID)) {
846
+ openFailures.add(sessionID);
847
+ toast(`not active for session ${sessionID}, requests carry the raw history: ${describe(error)}`, "error");
848
+ log("error", `not active for session ${sessionID}: ${describe(error)}`);
849
+ }
850
+ releaseFlag(sessionID);
234
851
  return;
235
852
  }
853
+ // Stamp (and detect a fork) once per process, only while CLM edits this session:
854
+ // a session with CLM off is left untouched in OpenCode's store.
855
+ if (!linked.has(sessionID)) {
856
+ await clm.refreshSettings().catch(() => undefined);
857
+ if (clm.enabled) {
858
+ linked.add(sessionID);
859
+ await linkFork(clm);
860
+ }
861
+ }
236
862
  const tools = toolTokens();
237
863
  if (tools !== undefined) clm.scope = { ...clm.scope, toolTokens: tools };
864
+ const compaction = clm.compacting;
865
+ if (compaction) compactionRequests.add(sessionID);
866
+ else compactionRequests.delete(sessionID);
867
+ if (!compaction) {
868
+ // K5: the step before this request reached OpenCode's threshold while the flag this
869
+ // session set was false, so OpenCode skipped a threshold compaction (prompt.ts:1161).
870
+ const usable = thresholdOf(clm);
871
+ const cancelled = thresholds.observe(sessionID, lastFinishedStep(raw), usable);
872
+ if (cancelled && usable !== undefined) {
873
+ await clm.log({ event: "compaction-cancelled", reason: "threshold", setting: cancelled.by, count: cancelled.count, usable });
874
+ if (cancelled.by === "off") {
875
+ clm.queueNotice(thresholdCancelledNoticeText(clm.mirrorPath));
876
+ toast(thresholdCancelledText(cancelled.count, usable));
877
+ } else toast(thresholdPausedText(cancelled.count, usable, clm.guardLimit()));
878
+ }
879
+ }
238
880
  try {
239
881
  const result = await clm.transform(raw);
240
882
  replaceInPlace(raw, result.messages);
883
+ if (clm.annotationsImported) {
884
+ clm.annotationsImported = false;
885
+ await persistAnnotationsOf(sessionID);
886
+ }
887
+ // Instance-wide flag, set per request from this session's setting: OpenCode reads
888
+ // it after this step and before the next one (src/compaction.ts). Sessions running
889
+ // concurrently in one server overwrite each other's value.
890
+ if (!compaction && liveConfig) {
891
+ // Without a mirror, OpenCode's compaction is the only way to shrink: leave it as configured.
892
+ const active = clm.settings.enabled && clm.enabled && clm.mirrorUnavailable === undefined;
893
+ const mode = active ? clm.settings.compaction : "auto";
894
+ // K1, pi's `auto`: while the guard enforces a budget, pause threshold compaction for
895
+ // a request whose count can reach OpenCode's threshold (src/compaction.ts).
896
+ const usable = thresholdOf(clm);
897
+ const pause = active && mode === "auto" && userAutoCompaction !== false && clm.settings.guard !== "off" &&
898
+ clm.guardLimit() !== undefined && result.estimated !== undefined && usable !== undefined &&
899
+ thresholdReachable(result.estimated, usable, openCodeMaxOutput(clm.limits.output, Math.max(OPENCODE_OUTPUT_TOKEN_MAX, outputTokenMaxFlag() ?? 0)));
900
+ decideFlag(sessionID, pause ? false : compactionAuto(mode, userAutoCompaction));
901
+ if (pause !== (thresholds.by(sessionID) === "auto")) {
902
+ await clm.log({ event: "compaction-pause", paused: pause, estimated: result.estimated, usable });
903
+ }
904
+ thresholds.set(sessionID, pause ? "auto" : active && mode === "off" && userAutoCompaction !== false ? "off" : undefined);
905
+ }
241
906
  if (result.alert) {
242
907
  toast(result.alert, "warning");
243
908
  log("warn", result.alert);
244
909
  }
910
+ for (const error of result.errors ?? []) {
911
+ toast(error, "error");
912
+ log("error", `${sessionID}: ${error}`);
913
+ }
245
914
  } catch (error) {
246
- // Fail open: the request goes out with the raw history.
915
+ // Fail open: the request goes out with the raw history, under the user's flag.
916
+ if (!compaction) releaseFlag(sessionID);
247
917
  await clm.log({ event: "error", message: describe(error), stack: error instanceof Error ? error.stack : undefined });
248
918
  toast(`transform failed: ${describe(error)}`, "error");
249
919
  }
@@ -252,6 +922,11 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
252
922
  async "experimental.session.compacting"(hookInput, output) {
253
923
  const clm = await opened(hookInput.sessionID);
254
924
  if (!clm) return;
925
+ try {
926
+ await clm.commitBeforeCompaction();
927
+ } catch (error) {
928
+ await clm.log({ event: "error", message: `commit before compaction: ${describe(error)}` });
929
+ }
255
930
  clm.compacting = true;
256
931
  try {
257
932
  const context = await clm.compactionContext();
@@ -263,13 +938,63 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
263
938
 
264
939
  async "experimental.compaction.autocontinue"(hookInput) {
265
940
  const clm = await opened(hookInput.sessionID);
266
- if (clm) clm.compacted = true;
941
+ if (!clm) return;
942
+ clm.compacted = true;
943
+ thresholds.reset(hookInput.sessionID);
944
+ if (!clm.settings.enabled || !clm.enabled) return;
945
+ // Automatic compaction only (manual /compact does not reach this hook).
946
+ const overflow = hookInput.overflow === true;
947
+ await clm.log({ event: "native-compaction", reason: overflow ? "overflow" : "threshold", setting: clm.settings.compaction });
948
+ toast(nativeCompactionText(overflow, clm.settings.compaction));
267
949
  },
268
950
 
269
951
  async event({ event }) {
952
+ const command = commandOf(event);
953
+ if (command !== undefined) {
954
+ const request = decodeRequest(command);
955
+ if (!request) return;
956
+ let answer: Omit<ChannelReply, "v" | "id">;
957
+ try {
958
+ answer = await channelRequest(request);
959
+ } catch (error) {
960
+ answer = { ok: false, text: describe(error) };
961
+ log("warn", `channel ${request.op} for ${request.session}: ${answer.text}`);
962
+ }
963
+ reply({ ...answer, v: CHANNEL_VERSION, id: request.id });
964
+ return;
965
+ }
966
+ if (event.type === "session.idle" || event.type === "session.error") {
967
+ const ended = (event.properties as { sessionID?: string }).sessionID;
968
+ if (ended) {
969
+ compactPending.delete(ended);
970
+ // Not on session.error: the overflow check below reads the flag OpenCode read.
971
+ if (event.type === "session.idle") endFlag(ended);
972
+ }
973
+ }
974
+ if (event.type === "session.error") {
975
+ const { sessionID, error } = event.properties as { sessionID?: string; error?: { name?: string } };
976
+ if (!sessionID || error?.name !== "ContextOverflowError") return;
977
+ const clm = await opened(sessionID);
978
+ if (!clm || !clm.settings.enabled || !clm.enabled) return;
979
+ // OpenCode surfaced the overflow instead of compacting when the flag it read was
980
+ // false (processor.ts:620-628). The event follows that read synchronously, so the
981
+ // live flag, not this session's setting, says what happened (another session may
982
+ // have set it). An overflow of the compaction request itself is not this case.
983
+ if (liveConfig?.compaction?.auto !== false || compactionRequests.has(sessionID)) return;
984
+ if (clm.mirrorUnavailable !== undefined) {
985
+ await clm.log({ event: "overflow-not-compacted", setting: clm.settings.compaction });
986
+ toast("The provider rejected the request as too long, and OpenCode's automatic compaction is off. Run /compact.", "warning");
987
+ return;
988
+ }
989
+ clm.queueNotice(overflowNotCompactedText(clm.mirrorPath));
990
+ await clm.log({ event: "overflow-not-compacted", setting: clm.settings.compaction });
991
+ toast("The provider rejected the request as too long, and OpenCode's automatic compaction is off. Edit the mirror or run /compact.", "warning");
992
+ return;
993
+ }
270
994
  if (event.type !== "session.compacted") return;
271
995
  const clm = await opened(event.properties.sessionID);
272
996
  if (clm) clm.compacted = true;
997
+ thresholds.reset(event.properties.sessionID);
273
998
  },
274
999
 
275
1000
  async "tool.definition"(hookInput, output) {
@@ -287,7 +1012,20 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
287
1012
  toolSizes.set(hookInput.toolID, Math.ceil((size / 4) * settings.estimateFactor));
288
1013
  },
289
1014
 
1015
+ async "tool.execute.before"(hookInput) {
1016
+ // Counted before any await: parallel calls of one response arrive concurrently.
1017
+ const position = toolCalls.next(hookInput.sessionID);
1018
+ if (position < 2) return;
1019
+ const clm = await opened(hookInput.sessionID);
1020
+ if (!clm || !clm.settings.enabled || !clm.enabled || !clm.settings.oneTool) return;
1021
+ await clm.log({ event: "tool-blocked", tool: hookInput.tool, position });
1022
+ // OpenCode turns the throw into the call's error result, which the model sees.
1023
+ throw new Error(oneToolText(hookInput.tool, position));
1024
+ },
1025
+
290
1026
  async "tool.execute.after"(hookInput, output) {
1027
+ // Before any await: the session's own flag back after a subagent's requests.
1028
+ if (flagDecisions.has(hookInput.sessionID)) applyFlag(flagDecisions.get(hookInput.sessionID));
291
1029
  const clm = await opened(hookInput.sessionID);
292
1030
  if (!clm || typeof output.output !== "string") return;
293
1031
  // Runs inside the tool's own call: a throw here would fail the model's command.
@@ -296,10 +1034,12 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
296
1034
  receipt = clm.receipt(hookInput.tool, hookInput.args, input.directory);
297
1035
  } catch (error) {
298
1036
  await clm.log({ event: "receipt-error", tool: hookInput.tool, message: describe(error) });
299
- return;
300
1037
  }
301
- if (!receipt) return;
302
- output.output = output.output ? `${output.output}${output.output.endsWith("\n") ? "\n" : "\n\n"}${receipt}` : receipt;
1038
+ if (receipt) output.output = output.output ? `${output.output}${output.output.endsWith("\n") ? "\n" : "\n\n"}${receipt}` : receipt;
1039
+ // Last, so it measures the result as the model gets it. Not for failed calls:
1040
+ // OpenCode runs this hook only after a successful execute (session/tools.ts:105-123).
1041
+ const trailer = clm.sizeTrailer(output.output);
1042
+ if (trailer) output.output = `${output.output}${trailer}`;
303
1043
  },
304
1044
 
305
1045
  async "command.execute.before"(hookInput, output) {
@@ -309,9 +1049,12 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
309
1049
  try {
310
1050
  const clm = await session(hookInput.sessionID);
311
1051
  const argument = (hookInput.arguments ?? "").trim();
312
- text = hookInput.command === STATUS_COMMAND
313
- ? relay(await statusCommand(clm, argument.toLowerCase()))
314
- : compactCommand(clm, argument);
1052
+ if (hookInput.command === STATUS_COMMAND) text = relay(await statusCommand(clm, argument));
1053
+ else if (clm.mirrorUnavailable !== undefined) {
1054
+ // pi-clm's compactCommand: nothing for the model to edit, so nothing is asked of it.
1055
+ toast(`The context mirror is unavailable, so the model cannot edit its context (${clm.mirrorUnavailable}).`, "warning");
1056
+ text = relay("[CLM] The context mirror is unavailable, so the model cannot edit its context.");
1057
+ } else text = await compactCommand(clm, hookInput.sessionID, argument);
315
1058
  } catch (error) {
316
1059
  const message = `/${hookInput.command} failed: ${describe(error)}`;
317
1060
  toast(message, "error");
@@ -328,6 +1071,7 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
328
1071
  schema: tool.schema,
329
1072
  store: async (sessionID) => (await session(sessionID)).annotations,
330
1073
  block: async (sessionID, blockId) => (await session(sessionID)).blockSource(blockId),
1074
+ onChange: persistAnnotationsOf,
331
1075
  }),
332
1076
  };
333
1077
  return hooks;