@codemem/opencode-plugin 0.43.2 → 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.
@@ -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.43.2";
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;
@@ -34,10 +43,31 @@ const VIEWER_HEALTH_TIMEOUT_MS = 5_000;
34
43
  const VIEWER_HEALTH_RESTART_THRESHOLD = 3;
35
44
  const VIEWER_HEALTH_RESTART_COOLDOWN_MS = 5 * 60_000;
36
45
  const RAW_EVENTS_STATUS_TIMEOUT_MS = 5_000;
46
+ const DEFAULT_EMBEDDING_MODEL = "Xenova/bge-small-en-v1.5";
47
+ const DEFAULT_EMBEDDING_REVISION = "ea104dacec62c0de699686887e3f920caeb4f3e3";
48
+ const PLUGIN_REGISTRATIONS_KEY = Symbol.for("codemem.opencode-plugin.registrations");
37
49
 
38
50
  let compatCheckCache = null;
39
51
  const notifiedReleaseVersions = new Set();
40
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
+
41
71
  // Release an unread response body without surfacing cancellation failures.
42
72
  const discardResponseBody = (response) => {
43
73
  try {
@@ -352,10 +382,74 @@ const clearCompatCheckCache = () => {
352
382
  compatCheckCache = null;
353
383
  };
354
384
 
355
- const isStableReleaseVersion = (value) => {
356
- if (typeof value !== "string" || value.length > MAX_UPDATE_VERSION_CHARS) return false;
357
- const match = STABLE_RELEASE_VERSION.exec(value);
358
- 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);
359
453
  };
360
454
 
361
455
  const parseReleaseNotification = (result) => {
@@ -366,18 +460,24 @@ const parseReleaseNotification = (result) => {
366
460
  const status = JSON.parse(stdout);
367
461
  if (!status || typeof status !== "object" || Array.isArray(status)) return null;
368
462
  if (status.update_available !== true) return null;
369
- 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
+ ) {
370
468
  return null;
371
469
  }
372
470
  if (
373
471
  typeof status.recommended_action !== "string"
374
472
  || !status.recommended_action.trim()
375
473
  || status.recommended_action.length > MAX_UPDATE_ACTION_CHARS
474
+ || !guidanceMatchesRelease(status.recommended_action, status.latest_version)
376
475
  ) {
377
476
  return null;
378
477
  }
379
478
  return {
380
479
  latestVersion: status.latest_version,
480
+ channel: PINNED_RELEASE_CHANNEL,
381
481
  recommendedAction: status.recommended_action.trim(),
382
482
  autoUpdateEligible: status.auto_update_eligible === true,
383
483
  };
@@ -386,6 +486,14 @@ const parseReleaseNotification = (result) => {
386
486
  }
387
487
  };
388
488
 
489
+ const resolveAutoUpdateEnvironment = ({
490
+ platform = process.platform,
491
+ env = process.env,
492
+ } = {}) => {
493
+ if (platform !== "linux") return env;
494
+ return { ...env, ONNXRUNTIME_NODE_INSTALL: "skip" };
495
+ };
496
+
389
497
  const createLogLine = (logPath) => async (line) => {
390
498
  if (!logPath) {
391
499
  return;
@@ -632,6 +740,17 @@ const buildViewerIdentityTarget = (env = process.env, cwd = process.cwd()) => {
632
740
  : trimmed;
633
741
  return resolve(cwd, expanded);
634
742
  };
743
+ const embeddingModel = env.CODEMEM_EMBEDDING_MODEL || DEFAULT_EMBEDDING_MODEL;
744
+ const configuredEmbeddingRevision = env.CODEMEM_EMBEDDING_REVISION?.trim();
745
+ // Match core's configured request identity. The runtime resolves mutable refs
746
+ // to canonical commits before vectors are persisted.
747
+ let embeddingRevision;
748
+ if (configuredEmbeddingRevision) {
749
+ embeddingRevision = configuredEmbeddingRevision;
750
+ } else {
751
+ embeddingRevision =
752
+ embeddingModel === DEFAULT_EMBEDDING_MODEL ? DEFAULT_EMBEDDING_REVISION : null;
753
+ }
635
754
  return {
636
755
  device_id: env.CODEMEM_DEVICE_ID?.trim() || null,
637
756
  actor_id_present: Object.hasOwn(env, "CODEMEM_ACTOR_ID"),
@@ -644,7 +763,11 @@ const buildViewerIdentityTarget = (env = process.env, cwd = process.cwd()) => {
644
763
  embedding_disabled: ["1", "true", "yes"].includes(
645
764
  String(env.CODEMEM_EMBEDDING_DISABLED || "").toLowerCase(),
646
765
  ),
647
- embedding_model: env.CODEMEM_EMBEDDING_MODEL || "Xenova/bge-small-en-v1.5",
766
+ embedding_offline: ["1", "true", "yes"].includes(
767
+ String(env.CODEMEM_EMBEDDING_OFFLINE || "").toLowerCase(),
768
+ ),
769
+ embedding_model: embeddingModel,
770
+ embedding_revision: embeddingRevision,
648
771
  };
649
772
  };
650
773
 
@@ -736,6 +859,20 @@ const isViewerInvalidRequestPayload = (payload) =>
736
859
  && isRecord(payload.error)
737
860
  && payload.error.code === "invalid_request";
738
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
+
739
876
  // Dependency-free port of @codemem/core prompt-transport semantics. Keep the
740
877
  // range and classifier parity pinned by the plugin injection tests.
741
878
  const PROMPT_TRANSPORT_PROTOCOL_RANGE = Object.freeze({
@@ -1569,7 +1706,11 @@ const trimEventQueue = ({ events, maxEvents, hardMaxEvents, onUnsentPressure, on
1569
1706
  hardMaxEvents > 0 &&
1570
1707
  events.length > hardMaxEvents
1571
1708
  ) {
1572
- 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);
1573
1714
  if (typeof onForcedDrop === "function") {
1574
1715
  onForcedDrop(dropped, events.length, hardMaxEvents);
1575
1716
  }
@@ -1668,22 +1809,10 @@ const detectRunner = ({ cwd, envRunner }) => {
1668
1809
  // terminal on every OpenCode startup for npx-only installs.
1669
1810
  stdio: ["ignore", "pipe", "ignore"],
1670
1811
  }).trim();
1671
- // Accept any global codemem at or above the pinned backend version, so a
1672
- // newer global install is used instead of silently downgrading to
1673
- // `npx codemem@<pin>` (the old check only matched the exact pin or the
1674
- // long-obsolete 0.2x series).
1675
- // Accept a global codemem only when it is the EXACT pinned version, or a
1676
- // clean (non-prerelease) release at least as new as the pin. isVersionAtLeast
1677
- // ignores prerelease suffixes, so without this a prerelease like
1678
- // "0.36.1-alpha.0" would wrongly compare >= the "0.36.1" release even though
1679
- // SemVer orders it below. Requiring an exact match for prereleases also
1680
- // handles a prerelease PIN correctly (the release script can set one): only
1681
- // the identical prerelease is trusted, never an older/different one.
1682
- const isPrerelease = versionOutput.includes("-");
1683
- const matchesOrCleanNewer =
1684
- versionOutput === PINNED_BACKEND_VERSION ||
1685
- (!isPrerelease && isVersionAtLeast(versionOutput, PINNED_BACKEND_VERSION));
1686
- 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)) {
1687
1816
  return "codemem";
1688
1817
  }
1689
1818
  } catch {
@@ -1704,13 +1833,47 @@ const tsCliAvailable = (cliPath) => {
1704
1833
  }
1705
1834
  };
1706
1835
 
1836
+ // When an explicit npx override targets the codemem package, pair it with the
1837
+ // matching @codemem/embeddings spec so the detached Viewer can resolve the
1838
+ // optional runtime instead of silently falling back to lexical search. Returns
1839
+ // null for any spec that is not a bare `codemem`/`codemem@<version>`, so custom
1840
+ // package overrides pass through unchanged.
1841
+ const pairedEmbeddingsForCodememSpec = (spec) => {
1842
+ if (typeof spec !== "string") return null;
1843
+ const trimmed = spec.trim();
1844
+ if (trimmed === "codemem") return "@codemem/embeddings";
1845
+ const match = /^codemem@([^\s]+)$/.exec(trimmed);
1846
+ if (!match || !/^[A-Za-z0-9~^<>=*][A-Za-z0-9._~^<>=*|-]*$/.test(match[1])) return null;
1847
+ return `@codemem/embeddings@${match[1]}`;
1848
+ };
1849
+
1707
1850
  const buildRunnerArgs = ({ runner, runnerFrom, runnerFromExplicit }) => {
1708
1851
  if (runner === "codemem") {
1709
1852
  return [];
1710
1853
  }
1711
1854
  if (runner === "npx") {
1712
- const pkg = runnerFromExplicit ? runnerFrom : `codemem@${PINNED_BACKEND_VERSION}`;
1713
- return ["-y", pkg];
1855
+ if (runnerFromExplicit) {
1856
+ const pairedEmbeddings = pairedEmbeddingsForCodememSpec(runnerFrom);
1857
+ if (pairedEmbeddings) {
1858
+ return [
1859
+ "-y",
1860
+ "--package",
1861
+ runnerFrom,
1862
+ "--package",
1863
+ pairedEmbeddings,
1864
+ "codemem",
1865
+ ];
1866
+ }
1867
+ return ["-y", runnerFrom];
1868
+ }
1869
+ return [
1870
+ "-y",
1871
+ "--package",
1872
+ `codemem@${PINNED_BACKEND_VERSION}`,
1873
+ "--package",
1874
+ `@codemem/embeddings@${PINNED_BACKEND_VERSION}`,
1875
+ "codemem",
1876
+ ];
1714
1877
  }
1715
1878
  if (runner === "node") {
1716
1879
  const cliPath = runnerFromExplicit
@@ -1763,6 +1926,13 @@ export const CodememPlugin = async ({
1763
1926
  if (pluginIgnored) {
1764
1927
  return {};
1765
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
+ }
1766
1936
 
1767
1937
  const runner = detectRunner({
1768
1938
  cwd,
@@ -1864,9 +2034,22 @@ export const CodememPlugin = async ({
1864
2034
  process.env.CODEMEM_RAW_EVENTS_HARD_MAX || "2000",
1865
2035
  2000
1866
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();
1867
2050
  let streamUnavailableUntil = 0;
1868
- let streamErrorNoted = false;
1869
- let fallbackFailureNoted = false;
2051
+ let spoolPersistenceFailureNoted = null;
2052
+ let spoolLoadFailureNoted = false;
1870
2053
  let lastStatusCheckAt = 0;
1871
2054
  let lastStatusAvailable = true;
1872
2055
  let promptPackTransportUnavailableUntil = 0;
@@ -1878,9 +2061,9 @@ export const CodememPlugin = async ({
1878
2061
  return `${Date.now()}-${Math.random()}`;
1879
2062
  };
1880
2063
 
1881
- const queueRawEventViaCli = async (body) => {
2064
+ const queueRawEventViaCli = async (body, serialized = JSON.stringify(body)) => {
1882
2065
  const runFallback = () => runCli(["enqueue-raw-event"], {
1883
- stdinText: JSON.stringify(body),
2066
+ stdinText: serialized,
1884
2067
  });
1885
2068
  let result = await runFallback();
1886
2069
  let classification = classifyFallbackCommandResult(result);
@@ -1895,23 +2078,191 @@ export const CodememPlugin = async ({
1895
2078
  const error = new Error(
1896
2079
  retryExhausted ? `${classification.cause} after retry` : classification.cause
1897
2080
  );
1898
- error.retryable = classification.retryable && !attemptedRetry;
2081
+ error.retryable = classification.retryable;
1899
2082
  throw error;
1900
2083
  }
1901
2084
  return true;
1902
2085
  };
1903
2086
 
1904
2087
  const lastToastAtBySession = new Map();
1905
- const shouldToast = (sessionID) => {
2088
+ const shouldToast = (sessionID, category = "general") => {
1906
2089
  const now = Date.now();
1907
- const last = lastToastAtBySession.get(sessionID) || 0;
2090
+ const key = `${sessionID || "unknown"}:${category}`;
2091
+ const last = lastToastAtBySession.get(key) || 0;
1908
2092
  if (now - last < 60000) {
1909
2093
  return false;
1910
2094
  }
1911
- lastToastAtBySession.set(sessionID, now);
2095
+ lastToastAtBySession.set(key, now);
1912
2096
  return true;
1913
2097
  };
1914
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
+
1915
2266
  const buildViewerCliArgs = (action) => {
1916
2267
  const args = ["serve", action, "--host", viewerHost, "--port", viewerPort];
1917
2268
  if (String(viewerDbPath || "").trim()) {
@@ -1931,66 +2282,54 @@ export const CodememPlugin = async ({
1931
2282
  return false;
1932
2283
  }
1933
2284
  const now = Date.now();
1934
- const body = buildRawEventEnvelope({
1935
- sessionID,
1936
- type,
1937
- payload,
1938
- cwd,
1939
- project: resolveProjectName(project, cwd),
1940
- startedAt: sessionStartedAt,
1941
- nowMs: now,
1942
- nowMono:
1943
- typeof performance !== "undefined" && performance.now
1944
- ? performance.now()
1945
- : null,
1946
- nextEventId,
1947
- });
1948
-
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;
1949
2310
  if (now < streamUnavailableUntil) {
2311
+ const durable = await persistRawEventForRetry({ body, serialized, payload, sessionID });
1950
2312
  try {
1951
- await queueRawEventViaCli(body);
1952
- fallbackFailureNoted = false;
2313
+ await queueRawEventViaCli(body, serialized);
2314
+ await removeRawEventFromSpool({ eventId: body.event_id, payload });
1953
2315
  if (payload && typeof payload === "object") {
1954
2316
  payload._raw_enqueued = true;
1955
2317
  }
2318
+ await notifyRawEventDelivery({
2319
+ category: "connection",
2320
+ delivered: true,
2321
+ durable,
2322
+ sessionID,
2323
+ });
1956
2324
  return true;
1957
- } catch (fallbackErr) {
1958
- if (payload && typeof payload === "object") {
1959
- payload._raw_fallback_terminal = fallbackErr?.retryable !== true;
1960
- }
1961
- await logLine(
1962
- `raw_events.fallback.error sessionID=${sessionID} type=${type} err=${String(
1963
- fallbackErr
1964
- ).slice(0, 200)}`
1965
- );
1966
- if (!fallbackFailureNoted) {
1967
- fallbackFailureNoted = true;
1968
- try {
1969
- await client.app.log({
1970
- service: "codemem",
1971
- level: "error",
1972
- message: "codemem fallback enqueue failed during stream backoff",
1973
- extra: {
1974
- sessionID,
1975
- backoffMs: rawEventsBackoffMs,
1976
- },
1977
- });
1978
- } catch (logErr) {
1979
- // best-effort logging only
1980
- }
1981
- if (client.tui?.showToast && shouldToast(sessionID)) {
1982
- try {
1983
- await client.tui.showToast({
1984
- body: {
1985
- message: "codemem: fallback enqueue failed while stream is down",
1986
- variant: "error",
1987
- },
1988
- });
1989
- } catch (toastErr) {
1990
- // best-effort only
1991
- }
1992
- }
1993
- }
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
+ });
1994
2333
  return false;
1995
2334
  }
1996
2335
  }
@@ -2021,130 +2360,46 @@ export const CodememPlugin = async ({
2021
2360
  });
2022
2361
  if (!postResp.ok) {
2023
2362
  let responseBody = null;
2024
- if (postResp.status === 409) {
2025
- try {
2026
- responseBody = await postResp.json();
2027
- } catch {
2028
- // Generic failure handling below remains the safe fallback.
2029
- }
2363
+ try {
2364
+ responseBody = await postResp.json();
2365
+ } catch {
2366
+ // Generic connection guidance below remains the safe fallback.
2030
2367
  }
2031
- const targetMismatch = isViewerDbMismatchPayload(responseBody)
2032
- || isViewerIdentityMismatchPayload(responseBody)
2033
- || isViewerContractUnsupportedPayload(responseBody);
2034
2368
  const postError = new Error(`raw-events post failed (${postResp.status})`);
2035
- postError.viewerTargetMismatch = targetMismatch;
2369
+ postError.rawEventFailureCategory = classifyRawEventViewerFailure(responseBody);
2036
2370
  throw postError;
2037
2371
  }
2038
2372
  streamUnavailableUntil = 0;
2039
- streamErrorNoted = false;
2040
- fallbackFailureNoted = false;
2041
2373
  lastStatusAvailable = true;
2374
+ await removeRawEventFromSpool({ eventId: body.event_id, payload });
2042
2375
  if (payload && typeof payload === "object") {
2043
2376
  payload._raw_enqueued = true;
2044
2377
  }
2045
2378
  return true;
2046
2379
  } catch (err) {
2380
+ const category = err?.rawEventFailureCategory || "connection";
2047
2381
  streamUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
2048
- await logLine(`raw_events.error sessionID=${sessionID} type=${type} err=${String(err).slice(0, 200)}`);
2049
- try {
2050
- await client.app.log({
2051
- service: "codemem",
2052
- level: "error",
2053
- message: "Failed to stream raw events to codemem viewer",
2054
- extra: {
2055
- sessionID,
2056
- type,
2057
- viewerHost,
2058
- viewerPort,
2059
- viewerTargetMismatch: err?.viewerTargetMismatch === true,
2060
- error: String(err),
2061
- },
2062
- });
2063
- } catch (logErr) {
2064
- // best-effort logging only
2065
- }
2382
+ await logLine(`raw_events.error category=${category}`);
2383
+ const durable = await persistRawEventForRetry({ body, serialized, payload, sessionID });
2066
2384
 
2067
2385
  let fallbackOk = false;
2068
2386
  try {
2069
- await queueRawEventViaCli(body);
2387
+ await queueRawEventViaCli(body, serialized);
2388
+ await removeRawEventFromSpool({ eventId: body.event_id, payload });
2070
2389
  fallbackOk = true;
2071
- } catch (fallbackErr) {
2072
- if (payload && typeof payload === "object") {
2073
- payload._raw_fallback_terminal = fallbackErr?.retryable !== true;
2074
- }
2075
- await logLine(
2076
- `raw_events.fallback.error sessionID=${sessionID} type=${type} err=${String(
2077
- fallbackErr
2078
- ).slice(0, 200)}`
2079
- );
2390
+ } catch {
2391
+ await logLine(`raw_events.fallback.error category=${category}`);
2080
2392
  }
2081
2393
 
2082
2394
  if (fallbackOk) {
2083
- fallbackFailureNoted = false;
2084
2395
  if (payload && typeof payload === "object") {
2085
2396
  payload._raw_enqueued = true;
2086
2397
  }
2087
- if (!streamErrorNoted) {
2088
- streamErrorNoted = true;
2089
- try {
2090
- await client.app.log({
2091
- service: "codemem",
2092
- level: "warn",
2093
- message: "codemem stream unavailable; queued raw event via CLI fallback",
2094
- extra: {
2095
- sessionID,
2096
- backoffMs: rawEventsBackoffMs,
2097
- },
2098
- });
2099
- } catch (logErr) {
2100
- // best-effort logging only
2101
- }
2102
- }
2103
- if (client.tui?.showToast && shouldToast(sessionID)) {
2104
- try {
2105
- await client.tui.showToast({
2106
- body: {
2107
- message: "codemem: viewer stream unavailable; queue fallback active",
2108
- variant: "warning",
2109
- },
2110
- });
2111
- } catch (toastErr) {
2112
- // best-effort only
2113
- }
2114
- }
2398
+ await notifyRawEventDelivery({ category, delivered: true, durable, sessionID });
2115
2399
  return true;
2116
2400
  }
2117
2401
 
2118
- if (!streamErrorNoted) {
2119
- streamErrorNoted = true;
2120
- fallbackFailureNoted = true;
2121
- try {
2122
- await client.app.log({
2123
- service: "codemem",
2124
- level: "error",
2125
- message: "codemem stream unavailable; fallback enqueue failed",
2126
- extra: {
2127
- sessionID,
2128
- backoffMs: rawEventsBackoffMs,
2129
- },
2130
- });
2131
- } catch (logErr) {
2132
- // best-effort logging only
2133
- }
2134
- }
2135
-
2136
- if (client.tui?.showToast && shouldToast(sessionID)) {
2137
- try {
2138
- await client.tui.showToast({
2139
- body: {
2140
- message: `codemem: stream unavailable (${viewerHost}:${viewerPort}); fallback failed`,
2141
- variant: "error",
2142
- },
2143
- });
2144
- } catch (toastErr) {
2145
- // best-effort only
2146
- }
2147
- }
2402
+ await notifyRawEventDelivery({ category, delivered: false, durable, sessionID });
2148
2403
  return false;
2149
2404
  }
2150
2405
  };
@@ -2421,12 +2676,16 @@ export const CodememPlugin = async ({
2421
2676
  };
2422
2677
 
2423
2678
  const runCommand = async (cmd, options = {}) => {
2424
- const { stdinText = null, timeoutMs = commandTimeout } = options;
2679
+ const {
2680
+ env = process.env,
2681
+ stdinText = null,
2682
+ timeoutMs = commandTimeout,
2683
+ } = options;
2425
2684
  const [command, ...args] = cmd;
2426
2685
  return new Promise((resolve) => {
2427
2686
  const proc = nodeSpawn(command, args, {
2428
2687
  cwd,
2429
- env: process.env,
2688
+ env,
2430
2689
  stdio: ["pipe", "pipe", "pipe"],
2431
2690
  });
2432
2691
  let stdout = "";
@@ -2847,11 +3106,36 @@ export const CodememPlugin = async ({
2847
3106
 
2848
3107
  if (backendUpdatePolicy === "auto") {
2849
3108
  compatibilityAutoUpdateAttempted = true;
2850
- await logLine("compat.auto_update_start cmd=codemem update install --json");
2851
- const updateResult = await runCli(
2852
- ["update", "install", "--json"],
2853
- { timeoutMs: 420_000 }
3109
+ const notification = parseReleaseNotification(
3110
+ await runCli(["update", "check", "--json"])
2854
3111
  );
3112
+ if (!notification?.autoUpdateEligible) {
3113
+ await logLine("compat.auto_update_skipped reason=release_not_eligible");
3114
+ await showToast(
3115
+ `${message}. Auto-update skipped (not eligible). Suggested action: ${guidance.action}`,
3116
+ "warning"
3117
+ );
3118
+ return;
3119
+ }
3120
+ const autoPlan = resolveAutoUpdatePlan({
3121
+ runner,
3122
+ runnerFrom,
3123
+ runnerFromExplicit,
3124
+ targetVersion: notification.latestVersion,
3125
+ });
3126
+ if (!autoPlan.allowed) {
3127
+ await logLine(`compat.auto_update_skipped reason=${autoPlan.reason || "not_eligible"}`);
3128
+ await showToast(
3129
+ `${message}. Auto-update skipped (not eligible). Suggested action: ${guidance.action}`,
3130
+ "warning"
3131
+ );
3132
+ return;
3133
+ }
3134
+ await logLine(`compat.auto_update_start cmd=${autoPlan.commandText}`);
3135
+ const updateResult = await runCommand(autoPlan.command, {
3136
+ env: resolveAutoUpdateEnvironment(),
3137
+ timeoutMs: 480_000,
3138
+ });
2855
3139
  if (updateResult?.exitCode === 0) {
2856
3140
  await logLine(
2857
3141
  `compat.auto_update_result exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog(
@@ -2863,6 +3147,7 @@ export const CodememPlugin = async ({
2863
3147
  const refreshedVersion = (refreshedResult?.stdout || "").trim();
2864
3148
  if (
2865
3149
  refreshedResult?.exitCode === 0
3150
+ && refreshedVersion === notification.latestVersion
2866
3151
  && isVersionAtLeast(refreshedVersion, minVersion)
2867
3152
  ) {
2868
3153
  writeCompatCheckCache(cacheKey, refreshedVersion);
@@ -2891,22 +3176,11 @@ export const CodememPlugin = async ({
2891
3176
  );
2892
3177
  return;
2893
3178
  }
2894
- let installError = null;
2895
- try {
2896
- installError = JSON.parse((updateResult?.stdout || "").trim())?.error || null;
2897
- } catch {
2898
- // Non-JSON output is treated as an installation failure.
2899
- }
2900
- const failureReason = installError === "update_install_locked"
2901
- ? "another update is already running"
2902
- : installError === "update_install_refused"
2903
- ? "not eligible"
2904
- : "installation failed";
2905
3179
  await logLine(
2906
- `compat.auto_update_skipped reason=${installError || "update_install_failed"} exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog((updateResult?.stderr || "").trim())}`
3180
+ `compat.auto_update_skipped reason=update_install_failed exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog((updateResult?.stderr || "").trim())}`
2907
3181
  );
2908
3182
  await showToast(
2909
- `${message}. Auto-update skipped (${failureReason}). Suggested action: ${guidance.action}`,
3183
+ `${message}. Auto-update skipped (installation failed). Suggested action: ${guidance.action}`,
2910
3184
  "warning"
2911
3185
  );
2912
3186
  return;
@@ -2916,7 +3190,7 @@ export const CodememPlugin = async ({
2916
3190
  };
2917
3191
 
2918
3192
  const checkForReleaseUpdate = async () => {
2919
- if (backendUpdatePolicy === "off") return;
3193
+ if (backendUpdatePolicy === "off" || compatibilityAutoUpdateAttempted) return;
2920
3194
  const notification = parseReleaseNotification(
2921
3195
  await runCli(["update", "check", "--json"])
2922
3196
  );
@@ -2932,13 +3206,31 @@ export const CodememPlugin = async ({
2932
3206
  && notification.autoUpdateEligible
2933
3207
  && !compatibilityAutoUpdateAttempted
2934
3208
  ) {
2935
- const autoPlan = resolveAutoUpdatePlan({ runner, runnerFrom, runnerFromExplicit });
3209
+ const autoPlan = resolveAutoUpdatePlan({
3210
+ runner,
3211
+ runnerFrom,
3212
+ runnerFromExplicit,
3213
+ targetVersion: notification.latestVersion,
3214
+ });
2936
3215
  if (autoPlan.allowed) {
2937
- const installation = await runCli(
2938
- ["update", "install", "--json"],
2939
- { timeoutMs: 420_000 }
2940
- );
3216
+ await logLine(`release.auto_update_start cmd=${autoPlan.commandText}`);
3217
+ const installation = await runCommand(autoPlan.command, {
3218
+ env: resolveAutoUpdateEnvironment(),
3219
+ timeoutMs: 480_000,
3220
+ });
2941
3221
  if (installation?.exitCode === 0) {
3222
+ const verification = await runCli(["version"]);
3223
+ const installedVersion = (verification?.stdout || "").trim();
3224
+ if (verification?.exitCode !== 0 || installedVersion !== notification.latestVersion) {
3225
+ await logLine(
3226
+ `release.auto_update_verification_failed current=${redactLog(installedVersion || "unknown")} expected=${notification.latestVersion}`
3227
+ );
3228
+ await showToast(
3229
+ `codemem ${notification.latestVersion} was installed, but the active CLI failed verification.`,
3230
+ "warning"
3231
+ );
3232
+ return;
3233
+ }
2942
3234
  const viewerRestart = await restartViewerAfterAutoUpdate();
2943
3235
  await showToast(`Updated codemem to ${notification.latestVersion}.`, "success");
2944
3236
  if (viewerRestart.attempted && !viewerRestart.ok) {
@@ -3479,9 +3771,17 @@ export const CodememPlugin = async ({
3479
3771
  void logLine(`queue.pressure unsent_preserved queued=${queuedCount} max_events=${cap}`);
3480
3772
  },
3481
3773
  onForcedDrop: (dropped, queuedCount, hardCap) => {
3774
+ const durableSpoolCoverage = dropped?._raw_spooled === true;
3482
3775
  void logLine(
3483
- `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}`
3484
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
+ });
3485
3785
  },
3486
3786
  });
3487
3787
  };
@@ -3511,7 +3811,6 @@ export const CodememPlugin = async ({
3511
3811
  ...adapterAnnotatedEvent,
3512
3812
  _raw_event_id: rawEventId,
3513
3813
  _raw_session_id: resolvedSessionID,
3514
- _raw_retry_count: 0,
3515
3814
  };
3516
3815
  recordEvent(queuedEvent);
3517
3816
  void emitRawEvent({
@@ -3523,6 +3822,7 @@ export const CodememPlugin = async ({
3523
3822
 
3524
3823
  const flushEvents = async () => {
3525
3824
  if (!events.length) {
3825
+ await drainRawEventSpool();
3526
3826
  await logLine("flush.skip empty");
3527
3827
  return;
3528
3828
  }
@@ -3534,18 +3834,10 @@ export const CodememPlugin = async ({
3534
3834
  }
3535
3835
 
3536
3836
  const failed = [];
3537
- let droppedCount = 0;
3538
3837
  for (const queuedEvent of batch) {
3539
3838
  if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_enqueued) {
3540
3839
  continue;
3541
3840
  }
3542
- if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_fallback_terminal) {
3543
- droppedCount += 1;
3544
- await logLine(
3545
- `flush.drop terminal_fallback event_id=${queuedEvent?._raw_event_id || "unknown"}`
3546
- );
3547
- continue;
3548
- }
3549
3841
  const queuedSessionID =
3550
3842
  queuedEvent?._raw_session_id ||
3551
3843
  queuedEvent?.properties?.sessionID ||
@@ -3556,27 +3848,13 @@ export const CodememPlugin = async ({
3556
3848
  payload: queuedEvent,
3557
3849
  });
3558
3850
  if (!ok) {
3559
- if (queuedEvent?._raw_fallback_terminal) {
3560
- droppedCount += 1;
3561
- await logLine(
3562
- `flush.drop terminal_fallback event_id=${queuedEvent?._raw_event_id || "unknown"}`
3563
- );
3564
- continue;
3565
- }
3566
- const currentRetry =
3567
- typeof queuedEvent?._raw_retry_count === "number" && Number.isFinite(queuedEvent._raw_retry_count)
3568
- ? queuedEvent._raw_retry_count
3569
- : 0;
3570
- const nextRetry = currentRetry + 1;
3571
- failed.push({
3572
- ...queuedEvent,
3573
- _raw_retry_count: nextRetry,
3574
- });
3851
+ failed.push(queuedEvent);
3575
3852
  }
3576
3853
  }
3577
3854
  if (failed.length) {
3578
3855
  events.unshift(...failed);
3579
3856
  await logLine(`flush.retry_deferred count=${failed.length}`);
3857
+ await drainRawEventSpool();
3580
3858
  return;
3581
3859
  }
3582
3860
 
@@ -3587,12 +3865,20 @@ export const CodememPlugin = async ({
3587
3865
  await logLine(
3588
3866
  `flush.stream_only finalize count=${batch.length} tools=${sessionContext.toolCount} prompts=${sessionContext.promptCount} duration=${Math.round(durationMs / 1000)}s`
3589
3867
  );
3590
- await logLine(`flush.ok count=${batch.length - droppedCount} dropped=${droppedCount}`);
3868
+ await logLine(`flush.ok count=${batch.length} dropped=0`);
3591
3869
  sessionStartedAt = null;
3592
3870
  resetSessionContext();
3871
+ await drainRawEventSpool();
3593
3872
  };
3594
3873
 
3874
+ void drainRawEventSpool();
3875
+
3595
3876
  return {
3877
+ dispose: () => {
3878
+ clearTimeout(updateCheckTimer);
3879
+ stopHealthCheck();
3880
+ releasePluginRegistration();
3881
+ },
3596
3882
  "experimental.session.compacting": async (input) => {
3597
3883
  const sessionID = extractHookSessionID(input) || activeSessionID;
3598
3884
  markCompactionInjectionSkip(compactionInjectionSkips, sessionID);
@@ -3908,6 +4194,11 @@ export const CodememPlugin = async ({
3908
4194
  if (eventType === "session.deleted") {
3909
4195
  activeSessionID = null;
3910
4196
  if (sessionID) {
4197
+ for (const key of lastToastAtBySession.keys()) {
4198
+ if (key.startsWith(`${sessionID}:`)) {
4199
+ lastToastAtBySession.delete(key);
4200
+ }
4201
+ }
3911
4202
  injectionToastShown.delete(sessionID);
3912
4203
  messageInjectionCache.delete(sessionID);
3913
4204
  compactionInjectionSkips.delete(sessionID);
@@ -4034,6 +4325,8 @@ export const __testUtils = {
4034
4325
  redactPackCommand,
4035
4326
  rejectsInternalLedgerFlag,
4036
4327
  classifyFallbackCommandResult,
4328
+ classifyRawEventViewerFailure,
4329
+ detectRunner,
4037
4330
  PROMPT_TRANSPORT_PROTOCOL_RANGE,
4038
4331
  normalizePromptTransportProtocolRange,
4039
4332
  arePromptTransportProtocolRangesCompatible,