@codemem/opencode-plugin 0.44.0-alpha.1 → 0.44.0-beta.1

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.
@@ -31,7 +31,7 @@ export const resolveUpgradeGuidance = ({ runner, runnerFrom }) => {
31
31
  return {
32
32
  mode: "global",
33
33
  action:
34
- "Run `npm install -g codemem @codemem/embeddings` to update both the CLI and the optional semantic runtime, then restart OpenCode. On Linux, prefix with `ONNXRUNTIME_NODE_INSTALL=skip` to avoid the unused GPU provider download.",
34
+ "Run `npm install -g codemem` to update the CLI and its optional semantic runtime, then restart OpenCode. On Linux, prefix with `ONNXRUNTIME_NODE_INSTALL=skip` to avoid the unused GPU provider download.",
35
35
  note: "detected global codemem runner mode",
36
36
  };
37
37
  }
@@ -40,7 +40,7 @@ export const resolveUpgradeGuidance = ({ runner, runnerFrom }) => {
40
40
  return {
41
41
  mode: "npx",
42
42
  action:
43
- "Run `npm install -g codemem @codemem/embeddings` to update the CLI and optional semantic runtime, then restart OpenCode. On Linux, prefix with `ONNXRUNTIME_NODE_INSTALL=skip` to avoid the unused GPU provider download.",
43
+ "Run `npm install -g codemem` to update the CLI and its optional semantic runtime, then restart OpenCode. On Linux, prefix with `ONNXRUNTIME_NODE_INSTALL=skip` to avoid the unused GPU provider download.",
44
44
  note: "detected npx runner mode",
45
45
  };
46
46
  }
@@ -117,6 +117,8 @@ const isPinnedGitSource = (runnerFrom) => {
117
117
  };
118
118
 
119
119
  const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org/";
120
+ const UPDATE_TARGET_VERSION =
121
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc)(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
120
122
 
121
123
  const createNpmUpdatePlan = (targetVersion) => {
122
124
  const command = [
@@ -136,7 +138,7 @@ export const resolveAutoUpdatePlan = ({
136
138
  runner,
137
139
  runnerFrom,
138
140
  runnerFromExplicit = false,
139
- targetVersion = "latest",
141
+ targetVersion,
140
142
  platform = process.platform,
141
143
  }) => {
142
144
  const normalizedRunner = String(runner || "").trim();
@@ -145,9 +147,6 @@ export const resolveAutoUpdatePlan = ({
145
147
  if (platform === "win32") {
146
148
  return { allowed: false, reason: "unsupported-platform", command: null, commandText: null };
147
149
  }
148
- if (normalizedTargetVersion !== "latest" && !/^\d+\.\d+\.\d+$/.test(normalizedTargetVersion)) {
149
- return { allowed: false, reason: "invalid-version", command: null, commandText: null };
150
- }
151
150
  if (isPinnedGitSource(source) || /^codemem@(?!latest$|next$|\*$)[^\s]+$/i.test(source)) {
152
151
  return {
153
152
  allowed: false,
@@ -175,6 +174,9 @@ export const resolveAutoUpdatePlan = ({
175
174
  commandText: null,
176
175
  };
177
176
  }
177
+ if (!UPDATE_TARGET_VERSION.test(normalizedTargetVersion)) {
178
+ return { allowed: false, reason: "invalid-version", command: null, commandText: null };
179
+ }
178
180
  return createNpmUpdatePlan(normalizedTargetVersion);
179
181
  }
180
182
 
@@ -197,6 +199,9 @@ export const resolveAutoUpdatePlan = ({
197
199
  }
198
200
 
199
201
  if (normalizedRunner === "codemem") {
202
+ if (!UPDATE_TARGET_VERSION.test(normalizedTargetVersion)) {
203
+ return { allowed: false, reason: "invalid-version", command: null, commandText: null };
204
+ }
200
205
  return createNpmUpdatePlan(normalizedTargetVersion);
201
206
  }
202
207
 
@@ -0,0 +1,197 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ link,
5
+ mkdir,
6
+ readFile,
7
+ readdir,
8
+ rm,
9
+ stat,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ const DEFAULT_DRAIN_LIMIT = 20;
16
+ const DEFAULT_MAX_ENTRIES = 2000;
17
+ const RAW_EVENT_SPOOL_FULL_CODE = "raw_event_spool_full";
18
+ const SPOOL_DIRECTORY_NAME = "opencode-raw-event-spool";
19
+ const spoolWritesInFlight = new Map();
20
+
21
+ const resolveSpoolDirectory = (homeDir = homedir()) =>
22
+ join(homeDir, ".codemem", SPOOL_DIRECTORY_NAME);
23
+
24
+ const spoolFilename = (eventId) => {
25
+ const digest = createHash("sha256").update(String(eventId)).digest("hex");
26
+ return `${digest}.json`;
27
+ };
28
+
29
+ const requireEventId = (envelope) => {
30
+ const eventId = typeof envelope?.event_id === "string"
31
+ ? envelope.event_id.trim()
32
+ : "";
33
+ if (!eventId) {
34
+ throw new Error("raw event spool requires event_id");
35
+ }
36
+ return eventId;
37
+ };
38
+
39
+ const ensurePrivateDirectory = async (directory) => {
40
+ await mkdir(directory, { recursive: true, mode: 0o700 });
41
+ await chmod(directory, 0o700);
42
+ };
43
+
44
+ const writeRawEventSpoolEntryUnlocked = async ({
45
+ envelope,
46
+ serialized,
47
+ homeDir = homedir(),
48
+ maxEntries = DEFAULT_MAX_ENTRIES,
49
+ }) => {
50
+ const eventId = requireEventId(envelope);
51
+ const directory = resolveSpoolDirectory(homeDir);
52
+ const bytes = typeof serialized === "string" ? serialized : JSON.stringify(envelope);
53
+ const normalizedMaxEntries = Number.isFinite(maxEntries) && maxEntries > 0
54
+ ? Math.trunc(maxEntries)
55
+ : DEFAULT_MAX_ENTRIES;
56
+ const destination = join(directory, spoolFilename(eventId));
57
+ const temporary = join(
58
+ directory,
59
+ `.${spoolFilename(eventId)}.${process.pid}.${randomUUID()}.tmp`,
60
+ );
61
+
62
+ await ensurePrivateDirectory(directory);
63
+ try {
64
+ const existing = await readFile(destination, "utf8");
65
+ if (existing !== bytes) {
66
+ throw new Error("raw event spool entry conflicts with existing event_id");
67
+ }
68
+ await chmod(destination, 0o600);
69
+ return { eventId, serialized: existing };
70
+ } catch (error) {
71
+ if (error?.code !== "ENOENT") {
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ const directoryEntries = await readdir(directory, { withFileTypes: true });
77
+ const entryCount = directoryEntries.filter(
78
+ (entry) => entry.isFile() && entry.name.endsWith(".json"),
79
+ ).length;
80
+ if (entryCount >= normalizedMaxEntries) {
81
+ const error = new Error("raw event spool is full");
82
+ error.code = RAW_EVENT_SPOOL_FULL_CODE;
83
+ throw error;
84
+ }
85
+
86
+ try {
87
+ await writeFile(temporary, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" });
88
+ try {
89
+ await link(temporary, destination);
90
+ } catch (error) {
91
+ if (error?.code !== "EEXIST") {
92
+ throw error;
93
+ }
94
+ const existing = await readFile(destination, "utf8");
95
+ if (existing !== bytes) {
96
+ throw new Error("raw event spool entry conflicts with existing event_id");
97
+ }
98
+ await chmod(destination, 0o600);
99
+ return { eventId, serialized: existing };
100
+ }
101
+ await chmod(destination, 0o600);
102
+ } finally {
103
+ await rm(temporary, { force: true }).catch(() => {});
104
+ }
105
+
106
+ return { eventId, serialized: bytes };
107
+ };
108
+
109
+ const writeRawEventSpoolEntry = async (options) => {
110
+ const directory = resolveSpoolDirectory(options?.homeDir);
111
+ const previousWrite = spoolWritesInFlight.get(directory);
112
+ const writePromise = (previousWrite ? previousWrite.catch(() => {}) : Promise.resolve())
113
+ .then(() => writeRawEventSpoolEntryUnlocked(options));
114
+ const trackedWrite = writePromise.finally(() => {
115
+ if (spoolWritesInFlight.get(directory) === trackedWrite) {
116
+ spoolWritesInFlight.delete(directory);
117
+ }
118
+ });
119
+ spoolWritesInFlight.set(directory, trackedWrite);
120
+ return trackedWrite;
121
+ };
122
+
123
+ const removeRawEventSpoolEntry = async ({ eventId, homeDir = homedir() }) => {
124
+ const normalizedEventId = requireEventId({ event_id: eventId });
125
+ await rm(
126
+ join(resolveSpoolDirectory(homeDir), spoolFilename(normalizedEventId)),
127
+ { force: true },
128
+ );
129
+ };
130
+
131
+ const loadRawEventSpoolEntries = async ({
132
+ homeDir = homedir(),
133
+ limit = DEFAULT_DRAIN_LIMIT,
134
+ } = {}) => {
135
+ const directory = resolveSpoolDirectory(homeDir);
136
+ let directoryEntries;
137
+ try {
138
+ directoryEntries = await readdir(directory, { withFileTypes: true });
139
+ } catch (error) {
140
+ if (error?.code === "ENOENT") {
141
+ return { entries: [], corruptCount: 0 };
142
+ }
143
+ throw error;
144
+ }
145
+
146
+ const candidates = [];
147
+ for (const directoryEntry of directoryEntries) {
148
+ if (!directoryEntry.isFile() || !directoryEntry.name.endsWith(".json")) {
149
+ continue;
150
+ }
151
+ const path = join(directory, directoryEntry.name);
152
+ try {
153
+ const details = await stat(path);
154
+ candidates.push({ path, mtimeMs: details.mtimeMs, name: directoryEntry.name });
155
+ } catch {
156
+ // A concurrently removed entry needs no recovery attempt.
157
+ }
158
+ }
159
+ candidates.sort(
160
+ (left, right) => left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name),
161
+ );
162
+
163
+ const entries = [];
164
+ let corruptCount = 0;
165
+ const normalizedLimit = Number.isFinite(limit) && limit > 0
166
+ ? Math.trunc(limit)
167
+ : 0;
168
+ for (const candidate of candidates) {
169
+ if (entries.length >= normalizedLimit) {
170
+ break;
171
+ }
172
+ try {
173
+ const serialized = await readFile(candidate.path, "utf8");
174
+ const envelope = JSON.parse(serialized);
175
+ const eventId = requireEventId(envelope);
176
+ if (candidate.name !== spoolFilename(eventId)) {
177
+ corruptCount += 1;
178
+ continue;
179
+ }
180
+ entries.push({ eventId, envelope, serialized });
181
+ } catch {
182
+ corruptCount += 1;
183
+ }
184
+ }
185
+
186
+ return { entries, corruptCount };
187
+ };
188
+
189
+ export {
190
+ DEFAULT_DRAIN_LIMIT,
191
+ DEFAULT_MAX_ENTRIES,
192
+ RAW_EVENT_SPOOL_FULL_CODE,
193
+ loadRawEventSpoolEntries,
194
+ removeRawEventSpoolEntry,
195
+ resolveSpoolDirectory,
196
+ writeRawEventSpoolEntry,
197
+ };
@@ -13,17 +13,26 @@ import {
13
13
  resolveAutoUpdatePlan,
14
14
  resolveUpgradeGuidance,
15
15
  } from "../lib/compat.js";
16
+ import {
17
+ DEFAULT_DRAIN_LIMIT,
18
+ DEFAULT_MAX_ENTRIES,
19
+ RAW_EVENT_SPOOL_FULL_CODE,
20
+ loadRawEventSpoolEntries,
21
+ removeRawEventSpoolEntry,
22
+ resolveSpoolDirectory,
23
+ writeRawEventSpoolEntry,
24
+ } from "../lib/raw-event-spool.js";
16
25
 
17
26
  const TRUTHY_VALUES = ["1", "true", "yes"];
18
27
  const DISABLED_VALUES = ["0", "false", "off"];
19
- const PINNED_BACKEND_VERSION = "0.44.0-alpha.1";
28
+ const PINNED_BACKEND_VERSION = "0.44.0-beta.1";
20
29
  const COMPAT_CHECK_DELAY_MS = 1500;
21
30
  const COMPAT_CHECK_CACHE_TTL_MS = 5 * 60 * 1000;
22
31
  const MAX_UPDATE_STATUS_BYTES = 16 * 1024;
23
32
  const MAX_UPDATE_ACTION_CHARS = 1000;
24
33
  const MAX_UPDATE_VERSION_CHARS = 128;
25
- const STABLE_RELEASE_VERSION =
26
- /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
34
+ const RELEASE_VERSION =
35
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
27
36
  const CODEMEM_CONTEXT_PART_ID_PREFIX = "codemem-context-";
28
37
  const MAX_MESSAGE_INJECTION_CACHE_SESSIONS = 20;
29
38
  const MAX_MESSAGE_INJECTION_CACHE_MESSAGES = 100;
@@ -36,10 +45,29 @@ const VIEWER_HEALTH_RESTART_COOLDOWN_MS = 5 * 60_000;
36
45
  const RAW_EVENTS_STATUS_TIMEOUT_MS = 5_000;
37
46
  const DEFAULT_EMBEDDING_MODEL = "Xenova/bge-small-en-v1.5";
38
47
  const DEFAULT_EMBEDDING_REVISION = "ea104dacec62c0de699686887e3f920caeb4f3e3";
48
+ const PLUGIN_REGISTRATIONS_KEY = Symbol.for("codemem.opencode-plugin.registrations");
39
49
 
40
50
  let compatCheckCache = null;
41
51
  const notifiedReleaseVersions = new Set();
42
52
 
53
+ const claimPluginRegistration = (cwd) => {
54
+ let registrations = globalThis[PLUGIN_REGISTRATIONS_KEY];
55
+ if (!(registrations instanceof Map)) {
56
+ registrations = new Map();
57
+ globalThis[PLUGIN_REGISTRATIONS_KEY] = registrations;
58
+ }
59
+ const projectPath = resolve(cwd);
60
+ if (registrations.has(projectPath)) return null;
61
+ const registration = Symbol(projectPath);
62
+ registrations.set(projectPath, registration);
63
+ return () => {
64
+ if (registrations.get(projectPath) === registration) {
65
+ registrations.delete(projectPath);
66
+ }
67
+ };
68
+ };
69
+ const rawEventSpoolDrainsInFlight = new Map();
70
+
43
71
  // Release an unread response body without surfacing cancellation failures.
44
72
  const discardResponseBody = (response) => {
45
73
  try {
@@ -354,10 +382,74 @@ const clearCompatCheckCache = () => {
354
382
  compatCheckCache = null;
355
383
  };
356
384
 
357
- const isStableReleaseVersion = (value) => {
358
- if (typeof value !== "string" || value.length > MAX_UPDATE_VERSION_CHARS) return false;
359
- const match = STABLE_RELEASE_VERSION.exec(value);
360
- return Boolean(match && match.slice(1, 4).map(Number).every(Number.isSafeInteger));
385
+ const parseReleaseVersion = (value) => {
386
+ if (typeof value !== "string" || value.length > MAX_UPDATE_VERSION_CHARS) return null;
387
+ const match = RELEASE_VERSION.exec(value);
388
+ if (!match || !match.slice(1, 4).map(Number).every(Number.isSafeInteger)) return null;
389
+ const prerelease = match[4]?.split(".") || [];
390
+ if (prerelease.some((identifier) => /^0\d+$/.test(identifier))) return null;
391
+ return { core: match.slice(1, 4).map(Number), prerelease };
392
+ };
393
+
394
+ const releaseChannelForVersion = (value) => {
395
+ const parsed = parseReleaseVersion(value);
396
+ if (!parsed) return null;
397
+ if (parsed.prerelease.length === 0) return "latest";
398
+ const channel = parsed.prerelease[0];
399
+ return channel === "alpha" || channel === "beta" || channel === "rc" ? channel : null;
400
+ };
401
+
402
+ const comparePrereleaseIdentifier = (left, right) => {
403
+ if (left === undefined) return right === undefined ? 0 : -1;
404
+ if (right === undefined) return 1;
405
+ if (left === right) return 0;
406
+ const leftNumeric = /^\d+$/.test(left);
407
+ const rightNumeric = /^\d+$/.test(right);
408
+ if (leftNumeric && rightNumeric && left.length !== right.length) {
409
+ return Math.sign(left.length - right.length);
410
+ }
411
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
412
+ return left < right ? -1 : 1;
413
+ };
414
+
415
+ const compareReleaseVersions = (left, right) => {
416
+ const leftVersion = parseReleaseVersion(left);
417
+ const rightVersion = parseReleaseVersion(right);
418
+ if (!leftVersion || !rightVersion) return null;
419
+ for (let index = 0; index < leftVersion.core.length; index += 1) {
420
+ const difference = leftVersion.core[index] - rightVersion.core[index];
421
+ if (difference !== 0) return Math.sign(difference);
422
+ }
423
+ if (leftVersion.prerelease.length === 0) {
424
+ return rightVersion.prerelease.length === 0 ? 0 : 1;
425
+ }
426
+ if (rightVersion.prerelease.length === 0) return -1;
427
+ const length = Math.max(leftVersion.prerelease.length, rightVersion.prerelease.length);
428
+ for (let index = 0; index < length; index += 1) {
429
+ const comparison = comparePrereleaseIdentifier(
430
+ leftVersion.prerelease[index],
431
+ rightVersion.prerelease[index],
432
+ );
433
+ if (comparison !== 0) return comparison;
434
+ }
435
+ return 0;
436
+ };
437
+
438
+ const isSameChannelVersionAtLeast = (currentVersion, minimumVersion) => {
439
+ const currentChannel = releaseChannelForVersion(currentVersion);
440
+ if (!currentChannel || currentChannel !== releaseChannelForVersion(minimumVersion)) return false;
441
+ const comparison = compareReleaseVersions(currentVersion, minimumVersion);
442
+ return comparison !== null && comparison >= 0;
443
+ };
444
+
445
+ const PINNED_RELEASE_CHANNEL = releaseChannelForVersion(PINNED_BACKEND_VERSION);
446
+
447
+ const guidanceMatchesRelease = (guidance, latestVersion) => {
448
+ const versions =
449
+ String(guidance || "").match(
450
+ /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?/g,
451
+ ) || [];
452
+ return versions.length === 0 || versions.every((version) => version === latestVersion);
361
453
  };
362
454
 
363
455
  const parseReleaseNotification = (result) => {
@@ -368,18 +460,24 @@ const parseReleaseNotification = (result) => {
368
460
  const status = JSON.parse(stdout);
369
461
  if (!status || typeof status !== "object" || Array.isArray(status)) return null;
370
462
  if (status.update_available !== true) return null;
371
- if (!isStableReleaseVersion(status.latest_version)) {
463
+ if (
464
+ !PINNED_RELEASE_CHANNEL
465
+ || status.channel !== PINNED_RELEASE_CHANNEL
466
+ || releaseChannelForVersion(status.latest_version) !== PINNED_RELEASE_CHANNEL
467
+ ) {
372
468
  return null;
373
469
  }
374
470
  if (
375
471
  typeof status.recommended_action !== "string"
376
472
  || !status.recommended_action.trim()
377
473
  || status.recommended_action.length > MAX_UPDATE_ACTION_CHARS
474
+ || !guidanceMatchesRelease(status.recommended_action, status.latest_version)
378
475
  ) {
379
476
  return null;
380
477
  }
381
478
  return {
382
479
  latestVersion: status.latest_version,
480
+ channel: PINNED_RELEASE_CHANNEL,
383
481
  recommendedAction: status.recommended_action.trim(),
384
482
  autoUpdateEligible: status.auto_update_eligible === true,
385
483
  };
@@ -761,6 +859,20 @@ const isViewerInvalidRequestPayload = (payload) =>
761
859
  && isRecord(payload.error)
762
860
  && payload.error.code === "invalid_request";
763
861
 
862
+ const classifyRawEventViewerFailure = (payload) => {
863
+ if (isViewerDbMismatchPayload(payload)) return "database";
864
+ if (isViewerIdentityMismatchPayload(payload)) return "identity";
865
+ if (isViewerContractUnsupportedPayload(payload)) return "contract";
866
+ return "connection";
867
+ };
868
+
869
+ const RAW_EVENT_FAILURE_ACTIONS = Object.freeze({
870
+ database: "restart the viewer from the same workspace/config",
871
+ identity: "restart Codemem and OpenCode with the same environment",
872
+ contract: "update Codemem on the installed channel, then restart OpenCode",
873
+ connection: "check or restart the viewer",
874
+ });
875
+
764
876
  // Dependency-free port of @codemem/core prompt-transport semantics. Keep the
765
877
  // range and classifier parity pinned by the plugin injection tests.
766
878
  const PROMPT_TRANSPORT_PROTOCOL_RANGE = Object.freeze({
@@ -1594,7 +1706,11 @@ const trimEventQueue = ({ events, maxEvents, hardMaxEvents, onUnsentPressure, on
1594
1706
  hardMaxEvents > 0 &&
1595
1707
  events.length > hardMaxEvents
1596
1708
  ) {
1597
- const dropped = events.shift();
1709
+ const durableIndex = events.findIndex(
1710
+ (queued) => queued && typeof queued === "object" && queued._raw_spooled
1711
+ );
1712
+ const dropIndex = durableIndex >= 0 ? durableIndex : 0;
1713
+ const [dropped] = events.splice(dropIndex, 1);
1598
1714
  if (typeof onForcedDrop === "function") {
1599
1715
  onForcedDrop(dropped, events.length, hardMaxEvents);
1600
1716
  }
@@ -1693,22 +1809,10 @@ const detectRunner = ({ cwd, envRunner }) => {
1693
1809
  // terminal on every OpenCode startup for npx-only installs.
1694
1810
  stdio: ["ignore", "pipe", "ignore"],
1695
1811
  }).trim();
1696
- // Accept any global codemem at or above the pinned backend version, so a
1697
- // newer global install is used instead of silently downgrading to
1698
- // `npx codemem@<pin>` (the old check only matched the exact pin or the
1699
- // long-obsolete 0.2x series).
1700
- // Accept a global codemem only when it is the EXACT pinned version, or a
1701
- // clean (non-prerelease) release at least as new as the pin. isVersionAtLeast
1702
- // ignores prerelease suffixes, so without this a prerelease like
1703
- // "0.36.1-alpha.0" would wrongly compare >= the "0.36.1" release even though
1704
- // SemVer orders it below. Requiring an exact match for prereleases also
1705
- // handles a prerelease PIN correctly (the release script can set one): only
1706
- // the identical prerelease is trusted, never an older/different one.
1707
- const isPrerelease = versionOutput.includes("-");
1708
- const matchesOrCleanNewer =
1709
- versionOutput === PINNED_BACKEND_VERSION ||
1710
- (!isPrerelease && isVersionAtLeast(versionOutput, PINNED_BACKEND_VERSION));
1711
- if (matchesOrCleanNewer) {
1812
+ // Use a newer global CLI only when it remains on the plugin's release
1813
+ // channel. This prevents both channel crossing and fallback to an older pin
1814
+ // immediately after a successful prerelease auto-update.
1815
+ if (isSameChannelVersionAtLeast(versionOutput, PINNED_BACKEND_VERSION)) {
1712
1816
  return "codemem";
1713
1817
  }
1714
1818
  } catch {
@@ -1822,6 +1926,13 @@ export const CodememPlugin = async ({
1822
1926
  if (pluginIgnored) {
1823
1927
  return {};
1824
1928
  }
1929
+ const releasePluginRegistration = claimPluginRegistration(cwd);
1930
+ if (!releasePluginRegistration) {
1931
+ await log("warn", "codemem duplicate plugin registration skipped", {
1932
+ next_action: "remove either the configured npm plugin or the project-local Codemem plugin",
1933
+ });
1934
+ return {};
1935
+ }
1825
1936
 
1826
1937
  const runner = detectRunner({
1827
1938
  cwd,
@@ -1923,9 +2034,22 @@ export const CodememPlugin = async ({
1923
2034
  process.env.CODEMEM_RAW_EVENTS_HARD_MAX || "2000",
1924
2035
  2000
1925
2036
  );
2037
+ const rawEventSpoolDrainLimit = parsePositiveInt(
2038
+ process.env.CODEMEM_RAW_EVENT_SPOOL_DRAIN_LIMIT,
2039
+ DEFAULT_DRAIN_LIMIT,
2040
+ );
2041
+ const rawEventSpoolMaxEntries = parsePositiveInt(
2042
+ process.env.CODEMEM_RAW_EVENT_SPOOL_MAX_ENTRIES,
2043
+ DEFAULT_MAX_ENTRIES,
2044
+ );
2045
+ const rawEventSpoolHome = process.env.HOME?.trim() || homedir();
2046
+ const rawEventSpoolDirectory = resolveSpoolDirectory(rawEventSpoolHome);
2047
+ // Memoize the exact serialized envelope by queued-object identity so retries
2048
+ // cannot drift in timestamp, ID, property order, or bytes.
2049
+ const rawEventEnvelopes = new WeakMap();
1926
2050
  let streamUnavailableUntil = 0;
1927
- let streamErrorNoted = false;
1928
- let fallbackFailureNoted = false;
2051
+ let spoolPersistenceFailureNoted = null;
2052
+ let spoolLoadFailureNoted = false;
1929
2053
  let lastStatusCheckAt = 0;
1930
2054
  let lastStatusAvailable = true;
1931
2055
  let promptPackTransportUnavailableUntil = 0;
@@ -1937,9 +2061,9 @@ export const CodememPlugin = async ({
1937
2061
  return `${Date.now()}-${Math.random()}`;
1938
2062
  };
1939
2063
 
1940
- const queueRawEventViaCli = async (body) => {
2064
+ const queueRawEventViaCli = async (body, serialized = JSON.stringify(body)) => {
1941
2065
  const runFallback = () => runCli(["enqueue-raw-event"], {
1942
- stdinText: JSON.stringify(body),
2066
+ stdinText: serialized,
1943
2067
  });
1944
2068
  let result = await runFallback();
1945
2069
  let classification = classifyFallbackCommandResult(result);
@@ -1954,23 +2078,191 @@ export const CodememPlugin = async ({
1954
2078
  const error = new Error(
1955
2079
  retryExhausted ? `${classification.cause} after retry` : classification.cause
1956
2080
  );
1957
- error.retryable = classification.retryable && !attemptedRetry;
2081
+ error.retryable = classification.retryable;
1958
2082
  throw error;
1959
2083
  }
1960
2084
  return true;
1961
2085
  };
1962
2086
 
1963
2087
  const lastToastAtBySession = new Map();
1964
- const shouldToast = (sessionID) => {
2088
+ const shouldToast = (sessionID, category = "general") => {
1965
2089
  const now = Date.now();
1966
- const last = lastToastAtBySession.get(sessionID) || 0;
2090
+ const key = `${sessionID || "unknown"}:${category}`;
2091
+ const last = lastToastAtBySession.get(key) || 0;
1967
2092
  if (now - last < 60000) {
1968
2093
  return false;
1969
2094
  }
1970
- lastToastAtBySession.set(sessionID, now);
2095
+ lastToastAtBySession.set(key, now);
1971
2096
  return true;
1972
2097
  };
1973
2098
 
2099
+ const warnSpoolPersistenceFailure = async (sessionID, reason) => {
2100
+ const message = reason === "spool_full"
2101
+ ? "codemem raw-event retry spool is full; repair Codemem, then archive retained entries"
2102
+ : "codemem could not save a raw event for retry; it remains queued in memory";
2103
+ try {
2104
+ await client.app.log({
2105
+ service: "codemem",
2106
+ level: "error",
2107
+ message,
2108
+ extra: { category: "persistence", reason },
2109
+ });
2110
+ } catch {
2111
+ // Best-effort app logging only.
2112
+ }
2113
+ if (spoolPersistenceFailureNoted === reason) return;
2114
+ spoolPersistenceFailureNoted = reason;
2115
+ if (!client.tui?.showToast || !shouldToast(sessionID, `persistence:${reason}`)) return;
2116
+ try {
2117
+ await client.tui.showToast({
2118
+ body: { message: `codemem: ${message}`, variant: "error" },
2119
+ });
2120
+ } catch {
2121
+ // Best-effort toast only.
2122
+ }
2123
+ };
2124
+
2125
+ const persistRawEventForRetry = async ({ body, serialized, payload, sessionID }) => {
2126
+ try {
2127
+ await writeRawEventSpoolEntry({
2128
+ envelope: body,
2129
+ serialized,
2130
+ homeDir: rawEventSpoolHome,
2131
+ maxEntries: rawEventSpoolMaxEntries,
2132
+ });
2133
+ spoolPersistenceFailureNoted = null;
2134
+ if (payload && typeof payload === "object") {
2135
+ payload._raw_spooled = true;
2136
+ }
2137
+ return true;
2138
+ } catch (error) {
2139
+ const reason = error?.code === RAW_EVENT_SPOOL_FULL_CODE ? "spool_full" : "write_failed";
2140
+ await logLine(`raw_events.spool.${reason} category=persistence`);
2141
+ await warnSpoolPersistenceFailure(sessionID, reason);
2142
+ return false;
2143
+ }
2144
+ };
2145
+
2146
+ const removeRawEventFromSpool = async ({ eventId, payload }) => {
2147
+ try {
2148
+ await removeRawEventSpoolEntry({ eventId, homeDir: rawEventSpoolHome });
2149
+ if (payload && typeof payload === "object") {
2150
+ payload._raw_spooled = false;
2151
+ }
2152
+ } catch {
2153
+ await logLine("raw_events.spool.cleanup_failed category=persistence");
2154
+ }
2155
+ };
2156
+
2157
+ const notifyRawEventDelivery = async ({ category, delivered, durable, sessionID }) => {
2158
+ const action = RAW_EVENT_FAILURE_ACTIONS[category] || RAW_EVENT_FAILURE_ACTIONS.connection;
2159
+ let outcome = "was saved for retry";
2160
+ if (delivered) {
2161
+ outcome = "was queued via CLI";
2162
+ } else if (!durable) {
2163
+ outcome = "was not saved for retry; still queued in memory";
2164
+ }
2165
+ const message = `codemem raw event ${outcome}; ${action}`;
2166
+ let delivery = "memory";
2167
+ if (delivered) {
2168
+ delivery = "cli";
2169
+ } else if (durable) {
2170
+ delivery = "spool";
2171
+ }
2172
+ try {
2173
+ await client.app.log({
2174
+ service: "codemem",
2175
+ level: delivered ? "warn" : "error",
2176
+ message,
2177
+ extra: {
2178
+ category,
2179
+ delivery,
2180
+ },
2181
+ });
2182
+ } catch {
2183
+ // Best-effort app logging only.
2184
+ }
2185
+ if (!client.tui?.showToast || !shouldToast(sessionID, `${category}:${delivery}`)) return;
2186
+ try {
2187
+ await client.tui.showToast({
2188
+ body: { message: `codemem: ${message}`, variant: delivered ? "warning" : "error" },
2189
+ });
2190
+ } catch {
2191
+ // Best-effort toast only.
2192
+ }
2193
+ };
2194
+
2195
+ const drainRawEventSpool = () => {
2196
+ if (!rawEventsEnabled) {
2197
+ return Promise.resolve();
2198
+ }
2199
+ const existingDrain = rawEventSpoolDrainsInFlight.get(rawEventSpoolDirectory);
2200
+ if (existingDrain) {
2201
+ return existingDrain;
2202
+ }
2203
+ const drainPromise = (async () => {
2204
+ let loaded;
2205
+ try {
2206
+ loaded = await loadRawEventSpoolEntries({
2207
+ homeDir: rawEventSpoolHome,
2208
+ limit: rawEventSpoolDrainLimit,
2209
+ });
2210
+ } catch {
2211
+ await logLine("raw_events.spool.load_failed category=persistence");
2212
+ const message = "codemem could not read saved raw events; spool entries were left untouched";
2213
+ try {
2214
+ await client.app.log({
2215
+ service: "codemem",
2216
+ level: "error",
2217
+ message,
2218
+ extra: { category: "persistence", delivery: "load" },
2219
+ });
2220
+ } catch {
2221
+ // Best-effort app logging only.
2222
+ }
2223
+ if (!spoolLoadFailureNoted) {
2224
+ spoolLoadFailureNoted = true;
2225
+ if (client.tui?.showToast && shouldToast(null, "persistence:load")) {
2226
+ try {
2227
+ await client.tui.showToast({
2228
+ body: { message: `codemem: ${message}`, variant: "error" },
2229
+ });
2230
+ } catch {
2231
+ // Best-effort toast only.
2232
+ }
2233
+ }
2234
+ }
2235
+ return;
2236
+ }
2237
+ spoolLoadFailureNoted = false;
2238
+ if (loaded.corruptCount > 0) {
2239
+ await logLine(`raw_events.spool.corrupt_retained count=${loaded.corruptCount}`);
2240
+ }
2241
+ for (const entry of loaded.entries) {
2242
+ try {
2243
+ await queueRawEventViaCli(entry.envelope, entry.serialized);
2244
+ await removeRawEventFromSpool({ eventId: entry.eventId });
2245
+ } catch (error) {
2246
+ await logLine("raw_events.spool.drain_deferred category=fallback");
2247
+ if (error?.retryable === true) {
2248
+ break;
2249
+ }
2250
+ }
2251
+ }
2252
+ })();
2253
+ const trackedDrain = drainPromise
2254
+ .catch(async () => {
2255
+ await logLine("raw_events.spool.drain_failed category=persistence");
2256
+ })
2257
+ .finally(() => {
2258
+ if (rawEventSpoolDrainsInFlight.get(rawEventSpoolDirectory) === trackedDrain) {
2259
+ rawEventSpoolDrainsInFlight.delete(rawEventSpoolDirectory);
2260
+ }
2261
+ });
2262
+ rawEventSpoolDrainsInFlight.set(rawEventSpoolDirectory, trackedDrain);
2263
+ return trackedDrain;
2264
+ };
2265
+
1974
2266
  const buildViewerCliArgs = (action) => {
1975
2267
  const args = ["serve", action, "--host", viewerHost, "--port", viewerPort];
1976
2268
  if (String(viewerDbPath || "").trim()) {
@@ -1990,66 +2282,54 @@ export const CodememPlugin = async ({
1990
2282
  return false;
1991
2283
  }
1992
2284
  const now = Date.now();
1993
- const body = buildRawEventEnvelope({
1994
- sessionID,
1995
- type,
1996
- payload,
1997
- cwd,
1998
- project: resolveProjectName(project, cwd),
1999
- startedAt: sessionStartedAt,
2000
- nowMs: now,
2001
- nowMono:
2002
- typeof performance !== "undefined" && performance.now
2003
- ? performance.now()
2004
- : null,
2005
- nextEventId,
2006
- });
2007
-
2285
+ let cachedEnvelope = payload && typeof payload === "object"
2286
+ ? rawEventEnvelopes.get(payload)
2287
+ : null;
2288
+ if (!cachedEnvelope) {
2289
+ const builtEnvelope = buildRawEventEnvelope({
2290
+ sessionID,
2291
+ type,
2292
+ payload,
2293
+ cwd,
2294
+ project: resolveProjectName(project, cwd),
2295
+ startedAt: sessionStartedAt,
2296
+ nowMs: now,
2297
+ nowMono:
2298
+ typeof performance !== "undefined" && performance.now
2299
+ ? performance.now()
2300
+ : null,
2301
+ nextEventId,
2302
+ });
2303
+ const serialized = JSON.stringify(builtEnvelope);
2304
+ cachedEnvelope = { body: JSON.parse(serialized), serialized };
2305
+ if (payload && typeof payload === "object") {
2306
+ rawEventEnvelopes.set(payload, cachedEnvelope);
2307
+ }
2308
+ }
2309
+ const { body, serialized } = cachedEnvelope;
2008
2310
  if (now < streamUnavailableUntil) {
2311
+ const durable = await persistRawEventForRetry({ body, serialized, payload, sessionID });
2009
2312
  try {
2010
- await queueRawEventViaCli(body);
2011
- fallbackFailureNoted = false;
2313
+ await queueRawEventViaCli(body, serialized);
2314
+ await removeRawEventFromSpool({ eventId: body.event_id, payload });
2012
2315
  if (payload && typeof payload === "object") {
2013
2316
  payload._raw_enqueued = true;
2014
2317
  }
2318
+ await notifyRawEventDelivery({
2319
+ category: "connection",
2320
+ delivered: true,
2321
+ durable,
2322
+ sessionID,
2323
+ });
2015
2324
  return true;
2016
- } catch (fallbackErr) {
2017
- if (payload && typeof payload === "object") {
2018
- payload._raw_fallback_terminal = fallbackErr?.retryable !== true;
2019
- }
2020
- await logLine(
2021
- `raw_events.fallback.error sessionID=${sessionID} type=${type} err=${String(
2022
- fallbackErr
2023
- ).slice(0, 200)}`
2024
- );
2025
- if (!fallbackFailureNoted) {
2026
- fallbackFailureNoted = true;
2027
- try {
2028
- await client.app.log({
2029
- service: "codemem",
2030
- level: "error",
2031
- message: "codemem fallback enqueue failed during stream backoff",
2032
- extra: {
2033
- sessionID,
2034
- backoffMs: rawEventsBackoffMs,
2035
- },
2036
- });
2037
- } catch (logErr) {
2038
- // best-effort logging only
2039
- }
2040
- if (client.tui?.showToast && shouldToast(sessionID)) {
2041
- try {
2042
- await client.tui.showToast({
2043
- body: {
2044
- message: "codemem: fallback enqueue failed while stream is down",
2045
- variant: "error",
2046
- },
2047
- });
2048
- } catch (toastErr) {
2049
- // best-effort only
2050
- }
2051
- }
2052
- }
2325
+ } catch {
2326
+ await logLine("raw_events.fallback.error category=connection");
2327
+ await notifyRawEventDelivery({
2328
+ category: "connection",
2329
+ delivered: false,
2330
+ durable,
2331
+ sessionID,
2332
+ });
2053
2333
  return false;
2054
2334
  }
2055
2335
  }
@@ -2080,130 +2360,46 @@ export const CodememPlugin = async ({
2080
2360
  });
2081
2361
  if (!postResp.ok) {
2082
2362
  let responseBody = null;
2083
- if (postResp.status === 409) {
2084
- try {
2085
- responseBody = await postResp.json();
2086
- } catch {
2087
- // Generic failure handling below remains the safe fallback.
2088
- }
2363
+ try {
2364
+ responseBody = await postResp.json();
2365
+ } catch {
2366
+ // Generic connection guidance below remains the safe fallback.
2089
2367
  }
2090
- const targetMismatch = isViewerDbMismatchPayload(responseBody)
2091
- || isViewerIdentityMismatchPayload(responseBody)
2092
- || isViewerContractUnsupportedPayload(responseBody);
2093
2368
  const postError = new Error(`raw-events post failed (${postResp.status})`);
2094
- postError.viewerTargetMismatch = targetMismatch;
2369
+ postError.rawEventFailureCategory = classifyRawEventViewerFailure(responseBody);
2095
2370
  throw postError;
2096
2371
  }
2097
2372
  streamUnavailableUntil = 0;
2098
- streamErrorNoted = false;
2099
- fallbackFailureNoted = false;
2100
2373
  lastStatusAvailable = true;
2374
+ await removeRawEventFromSpool({ eventId: body.event_id, payload });
2101
2375
  if (payload && typeof payload === "object") {
2102
2376
  payload._raw_enqueued = true;
2103
2377
  }
2104
2378
  return true;
2105
2379
  } catch (err) {
2380
+ const category = err?.rawEventFailureCategory || "connection";
2106
2381
  streamUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
2107
- await logLine(`raw_events.error sessionID=${sessionID} type=${type} err=${String(err).slice(0, 200)}`);
2108
- try {
2109
- await client.app.log({
2110
- service: "codemem",
2111
- level: "error",
2112
- message: "Failed to stream raw events to codemem viewer",
2113
- extra: {
2114
- sessionID,
2115
- type,
2116
- viewerHost,
2117
- viewerPort,
2118
- viewerTargetMismatch: err?.viewerTargetMismatch === true,
2119
- error: String(err),
2120
- },
2121
- });
2122
- } catch (logErr) {
2123
- // best-effort logging only
2124
- }
2382
+ await logLine(`raw_events.error category=${category}`);
2383
+ const durable = await persistRawEventForRetry({ body, serialized, payload, sessionID });
2125
2384
 
2126
2385
  let fallbackOk = false;
2127
2386
  try {
2128
- await queueRawEventViaCli(body);
2387
+ await queueRawEventViaCli(body, serialized);
2388
+ await removeRawEventFromSpool({ eventId: body.event_id, payload });
2129
2389
  fallbackOk = true;
2130
- } catch (fallbackErr) {
2131
- if (payload && typeof payload === "object") {
2132
- payload._raw_fallback_terminal = fallbackErr?.retryable !== true;
2133
- }
2134
- await logLine(
2135
- `raw_events.fallback.error sessionID=${sessionID} type=${type} err=${String(
2136
- fallbackErr
2137
- ).slice(0, 200)}`
2138
- );
2390
+ } catch {
2391
+ await logLine(`raw_events.fallback.error category=${category}`);
2139
2392
  }
2140
2393
 
2141
2394
  if (fallbackOk) {
2142
- fallbackFailureNoted = false;
2143
2395
  if (payload && typeof payload === "object") {
2144
2396
  payload._raw_enqueued = true;
2145
2397
  }
2146
- if (!streamErrorNoted) {
2147
- streamErrorNoted = true;
2148
- try {
2149
- await client.app.log({
2150
- service: "codemem",
2151
- level: "warn",
2152
- message: "codemem stream unavailable; queued raw event via CLI fallback",
2153
- extra: {
2154
- sessionID,
2155
- backoffMs: rawEventsBackoffMs,
2156
- },
2157
- });
2158
- } catch (logErr) {
2159
- // best-effort logging only
2160
- }
2161
- }
2162
- if (client.tui?.showToast && shouldToast(sessionID)) {
2163
- try {
2164
- await client.tui.showToast({
2165
- body: {
2166
- message: "codemem: viewer stream unavailable; queue fallback active",
2167
- variant: "warning",
2168
- },
2169
- });
2170
- } catch (toastErr) {
2171
- // best-effort only
2172
- }
2173
- }
2398
+ await notifyRawEventDelivery({ category, delivered: true, durable, sessionID });
2174
2399
  return true;
2175
2400
  }
2176
2401
 
2177
- if (!streamErrorNoted) {
2178
- streamErrorNoted = true;
2179
- fallbackFailureNoted = true;
2180
- try {
2181
- await client.app.log({
2182
- service: "codemem",
2183
- level: "error",
2184
- message: "codemem stream unavailable; fallback enqueue failed",
2185
- extra: {
2186
- sessionID,
2187
- backoffMs: rawEventsBackoffMs,
2188
- },
2189
- });
2190
- } catch (logErr) {
2191
- // best-effort logging only
2192
- }
2193
- }
2194
-
2195
- if (client.tui?.showToast && shouldToast(sessionID)) {
2196
- try {
2197
- await client.tui.showToast({
2198
- body: {
2199
- message: `codemem: stream unavailable (${viewerHost}:${viewerPort}); fallback failed`,
2200
- variant: "error",
2201
- },
2202
- });
2203
- } catch (toastErr) {
2204
- // best-effort only
2205
- }
2206
- }
2402
+ await notifyRawEventDelivery({ category, delivered: false, durable, sessionID });
2207
2403
  return false;
2208
2404
  }
2209
2405
  };
@@ -3575,9 +3771,17 @@ export const CodememPlugin = async ({
3575
3771
  void logLine(`queue.pressure unsent_preserved queued=${queuedCount} max_events=${cap}`);
3576
3772
  },
3577
3773
  onForcedDrop: (dropped, queuedCount, hardCap) => {
3774
+ const durableSpoolCoverage = dropped?._raw_spooled === true;
3578
3775
  void logLine(
3579
- `queue.drop hard_cap event_id=${dropped?._raw_event_id || "unknown"} queued=${queuedCount} hard_max=${hardCap}`
3776
+ `queue.drop hard_cap durable_spool_coverage=${durableSpoolCoverage} queued=${queuedCount} hard_max=${hardCap}`
3580
3777
  );
3778
+ void log("error", "codemem raw event dropped at in-memory hard cap", {
3779
+ category: "queue_capacity",
3780
+ delivery: durableSpoolCoverage ? "spool" : "memory",
3781
+ durable_spool_coverage: durableSpoolCoverage,
3782
+ queued_count: queuedCount,
3783
+ hard_max_events: hardCap,
3784
+ });
3581
3785
  },
3582
3786
  });
3583
3787
  };
@@ -3607,7 +3811,6 @@ export const CodememPlugin = async ({
3607
3811
  ...adapterAnnotatedEvent,
3608
3812
  _raw_event_id: rawEventId,
3609
3813
  _raw_session_id: resolvedSessionID,
3610
- _raw_retry_count: 0,
3611
3814
  };
3612
3815
  recordEvent(queuedEvent);
3613
3816
  void emitRawEvent({
@@ -3619,6 +3822,7 @@ export const CodememPlugin = async ({
3619
3822
 
3620
3823
  const flushEvents = async () => {
3621
3824
  if (!events.length) {
3825
+ await drainRawEventSpool();
3622
3826
  await logLine("flush.skip empty");
3623
3827
  return;
3624
3828
  }
@@ -3630,18 +3834,10 @@ export const CodememPlugin = async ({
3630
3834
  }
3631
3835
 
3632
3836
  const failed = [];
3633
- let droppedCount = 0;
3634
3837
  for (const queuedEvent of batch) {
3635
3838
  if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_enqueued) {
3636
3839
  continue;
3637
3840
  }
3638
- if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_fallback_terminal) {
3639
- droppedCount += 1;
3640
- await logLine(
3641
- `flush.drop terminal_fallback event_id=${queuedEvent?._raw_event_id || "unknown"}`
3642
- );
3643
- continue;
3644
- }
3645
3841
  const queuedSessionID =
3646
3842
  queuedEvent?._raw_session_id ||
3647
3843
  queuedEvent?.properties?.sessionID ||
@@ -3652,27 +3848,13 @@ export const CodememPlugin = async ({
3652
3848
  payload: queuedEvent,
3653
3849
  });
3654
3850
  if (!ok) {
3655
- if (queuedEvent?._raw_fallback_terminal) {
3656
- droppedCount += 1;
3657
- await logLine(
3658
- `flush.drop terminal_fallback event_id=${queuedEvent?._raw_event_id || "unknown"}`
3659
- );
3660
- continue;
3661
- }
3662
- const currentRetry =
3663
- typeof queuedEvent?._raw_retry_count === "number" && Number.isFinite(queuedEvent._raw_retry_count)
3664
- ? queuedEvent._raw_retry_count
3665
- : 0;
3666
- const nextRetry = currentRetry + 1;
3667
- failed.push({
3668
- ...queuedEvent,
3669
- _raw_retry_count: nextRetry,
3670
- });
3851
+ failed.push(queuedEvent);
3671
3852
  }
3672
3853
  }
3673
3854
  if (failed.length) {
3674
3855
  events.unshift(...failed);
3675
3856
  await logLine(`flush.retry_deferred count=${failed.length}`);
3857
+ await drainRawEventSpool();
3676
3858
  return;
3677
3859
  }
3678
3860
 
@@ -3683,12 +3865,20 @@ export const CodememPlugin = async ({
3683
3865
  await logLine(
3684
3866
  `flush.stream_only finalize count=${batch.length} tools=${sessionContext.toolCount} prompts=${sessionContext.promptCount} duration=${Math.round(durationMs / 1000)}s`
3685
3867
  );
3686
- await logLine(`flush.ok count=${batch.length - droppedCount} dropped=${droppedCount}`);
3868
+ await logLine(`flush.ok count=${batch.length} dropped=0`);
3687
3869
  sessionStartedAt = null;
3688
3870
  resetSessionContext();
3871
+ await drainRawEventSpool();
3689
3872
  };
3690
3873
 
3874
+ void drainRawEventSpool();
3875
+
3691
3876
  return {
3877
+ dispose: () => {
3878
+ clearTimeout(updateCheckTimer);
3879
+ stopHealthCheck();
3880
+ releasePluginRegistration();
3881
+ },
3692
3882
  "experimental.session.compacting": async (input) => {
3693
3883
  const sessionID = extractHookSessionID(input) || activeSessionID;
3694
3884
  markCompactionInjectionSkip(compactionInjectionSkips, sessionID);
@@ -4004,6 +4194,11 @@ export const CodememPlugin = async ({
4004
4194
  if (eventType === "session.deleted") {
4005
4195
  activeSessionID = null;
4006
4196
  if (sessionID) {
4197
+ for (const key of lastToastAtBySession.keys()) {
4198
+ if (key.startsWith(`${sessionID}:`)) {
4199
+ lastToastAtBySession.delete(key);
4200
+ }
4201
+ }
4007
4202
  injectionToastShown.delete(sessionID);
4008
4203
  messageInjectionCache.delete(sessionID);
4009
4204
  compactionInjectionSkips.delete(sessionID);
@@ -4130,6 +4325,8 @@ export const __testUtils = {
4130
4325
  redactPackCommand,
4131
4326
  rejectsInternalLedgerFlag,
4132
4327
  classifyFallbackCommandResult,
4328
+ classifyRawEventViewerFailure,
4329
+ detectRunner,
4133
4330
  PROMPT_TRANSPORT_PROTOCOL_RANGE,
4134
4331
  normalizePromptTransportProtocolRange,
4135
4332
  arePromptTransportProtocolRangesCompatible,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemem/opencode-plugin",
3
- "version": "0.44.0-alpha.1",
3
+ "version": "0.44.0-beta.1",
4
4
  "description": "CodeMem plugin for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./index.js",