@codemem/opencode-plugin 0.40.1 → 0.41.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.
@@ -106,9 +106,17 @@ const isPinnedGitSource = (runnerFrom) => {
106
106
  }
107
107
  };
108
108
 
109
- export const resolveAutoUpdatePlan = ({ runner, runnerFrom }) => {
110
- const normalizedRunner = String(runner || "").trim();
111
- const source = String(runnerFrom || "").trim();
109
+ export const resolveAutoUpdatePlan = ({ runner, runnerFrom, runnerFromExplicit = false }) => {
110
+ const normalizedRunner = String(runner || "").trim();
111
+ const source = String(runnerFrom || "").trim();
112
+ if (isPinnedGitSource(source) || /^codemem@(?!latest$|next$|\*$)[^\s]+$/i.test(source)) {
113
+ return {
114
+ allowed: false,
115
+ reason: "pinned-source",
116
+ command: null,
117
+ commandText: null,
118
+ };
119
+ }
112
120
 
113
121
  if (normalizedRunner === "node") {
114
122
  return {
@@ -119,8 +127,16 @@ export const resolveAutoUpdatePlan = ({ runner, runnerFrom }) => {
119
127
  };
120
128
  }
121
129
 
122
- if (normalizedRunner === "npx") {
123
- return {
130
+ if (normalizedRunner === "npx") {
131
+ if (!runnerFromExplicit || !/^(?:codemem|codemem@(?:latest|next|\*))$/i.test(source)) {
132
+ return {
133
+ allowed: false,
134
+ reason: !runnerFromExplicit ? "implicit-pinned-source" : "custom-source",
135
+ command: null,
136
+ commandText: null,
137
+ };
138
+ }
139
+ return {
124
140
  allowed: true,
125
141
  reason: null,
126
142
  command: ["npm", "install", "-g", "codemem@latest"],
@@ -146,10 +162,14 @@ export const resolveAutoUpdatePlan = ({ runner, runnerFrom }) => {
146
162
  };
147
163
  }
148
164
 
149
- return {
150
- allowed: true,
151
- reason: null,
152
- command: ["npm", "install", "-g", "codemem@latest"],
153
- commandText: "npm install -g codemem@latest",
154
- };
165
+ if (normalizedRunner === "codemem") {
166
+ return {
167
+ allowed: true,
168
+ reason: null,
169
+ command: ["npm", "install", "-g", "codemem@latest"],
170
+ commandText: "npm install -g codemem@latest",
171
+ };
172
+ }
173
+
174
+ return { allowed: false, reason: "unknown-runner", command: null, commandText: null };
155
175
  };
@@ -1,6 +1,7 @@
1
1
  import { appendFile, mkdir } from "node:fs/promises";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { basename, dirname, join, posix, resolve, win32 } from "node:path";
4
+ import { homedir } from "node:os";
4
5
  import { createHash } from "node:crypto";
5
6
  import { spawn as nodeSpawn, execSync } from "node:child_process";
6
7
  import { tool } from "@opencode-ai/plugin";
@@ -15,16 +16,48 @@ import {
15
16
 
16
17
  const TRUTHY_VALUES = ["1", "true", "yes"];
17
18
  const DISABLED_VALUES = ["0", "false", "off"];
18
- const PINNED_BACKEND_VERSION = "0.40.1";
19
+ const PINNED_BACKEND_VERSION = "0.41.0";
19
20
  const COMPAT_CHECK_DELAY_MS = 1500;
20
21
  const COMPAT_CHECK_CACHE_TTL_MS = 5 * 60 * 1000;
22
+ const MAX_UPDATE_STATUS_BYTES = 16 * 1024;
23
+ const MAX_UPDATE_ACTION_CHARS = 1000;
24
+ 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-]+)*)?$/;
21
27
  const CODEMEM_CONTEXT_PART_ID_PREFIX = "codemem-context-";
22
28
  const MAX_MESSAGE_INJECTION_CACHE_SESSIONS = 20;
23
29
  const MAX_MESSAGE_INJECTION_CACHE_MESSAGES = 100;
24
30
  const COMPACTION_INJECTION_SKIP_TTL_MS = 30 * 1000;
25
31
  const MAX_WORKING_SET_PATH_CHARS = 400;
32
+ const VIEWER_HEALTH_CHECK_INTERVAL_MS = 60_000;
33
+ const VIEWER_HEALTH_TIMEOUT_MS = 5_000;
34
+ const VIEWER_HEALTH_RESTART_THRESHOLD = 3;
35
+ const VIEWER_HEALTH_RESTART_COOLDOWN_MS = 5 * 60_000;
36
+ const RAW_EVENTS_STATUS_TIMEOUT_MS = 5_000;
26
37
 
27
38
  let compatCheckCache = null;
39
+ const notifiedReleaseVersions = new Set();
40
+
41
+ // Release an unread response body without surfacing cancellation failures.
42
+ const discardResponseBody = (response) => {
43
+ try {
44
+ const cancelled = response?.body?.cancel?.();
45
+ if (cancelled && typeof cancelled.catch === "function") {
46
+ cancelled.catch(() => {});
47
+ }
48
+ } catch {
49
+ // Best effort — a locked or already-errored stream is fine to abandon.
50
+ }
51
+ };
52
+
53
+ // Bounded ingest-availability preflight: a hung viewer socket must not stall
54
+ // raw-event delivery indefinitely. Failures fall into the existing stream
55
+ // backoff + CLI enqueue fallback path.
56
+ const fetchRawEventsStatus = (url, fetchFn = fetch) =>
57
+ fetchFn(url, {
58
+ method: "GET",
59
+ signal: AbortSignal.timeout(RAW_EVENTS_STATUS_TIMEOUT_MS),
60
+ });
28
61
 
29
62
  const normalizeEnvValue = (value) => (value || "").toLowerCase();
30
63
  const envHasValue = (value, truthyValues) =>
@@ -32,6 +65,149 @@ const envHasValue = (value, truthyValues) =>
32
65
  const envNotDisabled = (value) =>
33
66
  !DISABLED_VALUES.includes(normalizeEnvValue(value));
34
67
 
68
+ const createViewerHealthMonitor = ({
69
+ viewerHealthUrl,
70
+ legacyStatusUrl,
71
+ isActive,
72
+ restartViewer,
73
+ logLine,
74
+ fetchFn = fetch,
75
+ now = Date.now,
76
+ setIntervalFn = setInterval,
77
+ clearIntervalFn = clearInterval,
78
+ timeoutSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs),
79
+ }) => {
80
+ let timer = null;
81
+ let consecutiveFailures = 0;
82
+ let lastRestartAttempt = 0;
83
+
84
+ const boundedFetch = (url) => fetchFn(url, {
85
+ method: "GET",
86
+ signal: timeoutSignal(VIEWER_HEALTH_TIMEOUT_MS),
87
+ });
88
+
89
+ const probe = async () => {
90
+ let response;
91
+ try {
92
+ response = await boundedFetch(viewerHealthUrl);
93
+ } catch (error) {
94
+ return { live: false, detail: `error: ${String(error).slice(0, 200)}` };
95
+ }
96
+
97
+ if (response.status === 404) {
98
+ discardResponseBody(response);
99
+ try {
100
+ // Old-viewer compatibility: released viewers serving this route
101
+ // always include the `ingest` availability object, so require that
102
+ // identifying evidence rather than trusting any 2xx from an
103
+ // arbitrary local service.
104
+ const fallbackResponse = await boundedFetch(legacyStatusUrl);
105
+ if (!fallbackResponse.ok) {
106
+ discardResponseBody(fallbackResponse);
107
+ return { live: false, detail: `fallback status=${fallbackResponse.status}` };
108
+ }
109
+ const fallbackPayload = await fallbackResponse.json();
110
+ const looksLikeViewer =
111
+ fallbackPayload &&
112
+ typeof fallbackPayload === "object" &&
113
+ fallbackPayload.ingest &&
114
+ typeof fallbackPayload.ingest === "object";
115
+ return looksLikeViewer
116
+ ? { live: true }
117
+ : { live: false, detail: "fallback unexpected payload" };
118
+ } catch (error) {
119
+ return { live: false, detail: `fallback error: ${String(error).slice(0, 200)}` };
120
+ }
121
+ }
122
+
123
+ if (!response.ok) {
124
+ // Release the unread body so a persistently failing viewer does not
125
+ // pin connections across the 60s monitor interval.
126
+ discardResponseBody(response);
127
+ return { live: false, detail: `status=${response.status}` };
128
+ }
129
+
130
+ let payload;
131
+ try {
132
+ payload = await response.json();
133
+ } catch (error) {
134
+ return { live: false, detail: `invalid JSON: ${String(error).slice(0, 200)}` };
135
+ }
136
+ if (!payload || typeof payload !== "object" || payload.service !== "codemem-viewer") {
137
+ return { live: false, detail: "unexpected service" };
138
+ }
139
+ return { live: true };
140
+ };
141
+
142
+ const check = async () => {
143
+ if (!isActive()) return;
144
+ const result = await probe();
145
+ if (result.live) {
146
+ if (consecutiveFailures > 0) {
147
+ await logLine(`viewer.health recovered after ${consecutiveFailures} failure(s)`);
148
+ }
149
+ consecutiveFailures = 0;
150
+ return;
151
+ }
152
+
153
+ consecutiveFailures += 1;
154
+ await logLine(
155
+ `viewer.health check failed (${result.detail}, consecutive=${consecutiveFailures})`
156
+ );
157
+ if (
158
+ consecutiveFailures < VIEWER_HEALTH_RESTART_THRESHOLD ||
159
+ now() - lastRestartAttempt < VIEWER_HEALTH_RESTART_COOLDOWN_MS
160
+ ) {
161
+ return;
162
+ }
163
+
164
+ // Re-check after the awaited probe: a stop requested while the probe was
165
+ // in flight must not be undone by a restart.
166
+ if (!isActive()) return;
167
+
168
+ lastRestartAttempt = now();
169
+ await logLine(`viewer.health restarting viewer after ${consecutiveFailures} consecutive failures`);
170
+ try {
171
+ const restartResult = await restartViewer();
172
+ const restarted = restartResult?.exitCode === 0;
173
+ await logLine(
174
+ `viewer.health restart ${restarted ? "succeeded" : "failed"} (exit=${restartResult?.exitCode ?? "unknown"})`
175
+ );
176
+ if (restarted) consecutiveFailures = 0;
177
+ } catch (error) {
178
+ await logLine(`viewer.health restart error: ${String(error).slice(0, 200)}`);
179
+ }
180
+ };
181
+
182
+ const start = () => {
183
+ if (timer) return;
184
+ consecutiveFailures = 0;
185
+ timer = setIntervalFn(() => {
186
+ check().catch(() => {});
187
+ }, VIEWER_HEALTH_CHECK_INTERVAL_MS);
188
+ if (timer?.unref) timer.unref();
189
+ };
190
+
191
+ const stop = () => {
192
+ if (timer) {
193
+ clearIntervalFn(timer);
194
+ timer = null;
195
+ }
196
+ consecutiveFailures = 0;
197
+ };
198
+
199
+ return {
200
+ check,
201
+ start,
202
+ stop,
203
+ state: () => ({
204
+ consecutiveFailures,
205
+ lastRestartAttempt,
206
+ running: timer !== null,
207
+ }),
208
+ };
209
+ };
210
+
35
211
  const resolveInjectSurface = (value) => {
36
212
  const normalized = String(value || "message").trim().toLowerCase();
37
213
  if (normalized === "system") {
@@ -176,6 +352,40 @@ const clearCompatCheckCache = () => {
176
352
  compatCheckCache = null;
177
353
  };
178
354
 
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));
359
+ };
360
+
361
+ const parseReleaseNotification = (result) => {
362
+ if (result?.exitCode !== 0) return null;
363
+ const stdout = String(result?.stdout || "").trim();
364
+ if (!stdout || Buffer.byteLength(stdout, "utf8") > MAX_UPDATE_STATUS_BYTES) return null;
365
+ try {
366
+ const status = JSON.parse(stdout);
367
+ if (!status || typeof status !== "object" || Array.isArray(status)) return null;
368
+ if (status.update_available !== true) return null;
369
+ if (!isStableReleaseVersion(status.latest_version)) {
370
+ return null;
371
+ }
372
+ if (
373
+ typeof status.recommended_action !== "string"
374
+ || !status.recommended_action.trim()
375
+ || status.recommended_action.length > MAX_UPDATE_ACTION_CHARS
376
+ ) {
377
+ return null;
378
+ }
379
+ return {
380
+ latestVersion: status.latest_version,
381
+ recommendedAction: status.recommended_action.trim(),
382
+ autoUpdateEligible: status.auto_update_eligible === true,
383
+ };
384
+ } catch {
385
+ return null;
386
+ }
387
+ };
388
+
179
389
  const createLogLine = (logPath) => async (line) => {
180
390
  if (!logPath) {
181
391
  return;
@@ -445,6 +655,141 @@ const parsePackOutput = (result) => {
445
655
  };
446
656
  };
447
657
 
658
+ const isRecord = (value) => value != null && typeof value === "object" && !Array.isArray(value);
659
+
660
+ const isValidPackHttpPayload = (payload) => {
661
+ if (!isRecord(payload) || typeof payload.pack_text !== "string" || !isRecord(payload.metrics)) {
662
+ return false;
663
+ }
664
+ const itemCount = payload.metrics.total_items;
665
+ if (!Number.isInteger(itemCount) || itemCount < 0) {
666
+ return false;
667
+ }
668
+ if (
669
+ payload.ledger_artifact_fingerprint != null
670
+ && !/^[0-9a-f]{64}$/i.test(payload.ledger_artifact_fingerprint)
671
+ ) {
672
+ return false;
673
+ }
674
+ if (payload.ledger_outcome != null) {
675
+ return isRecord(payload.ledger_outcome)
676
+ && payload.ledger_outcome.ok === false
677
+ && payload.ledger_outcome.errorCode === "retrieval_ledger_write_failed"
678
+ && payload.ledger_outcome.reason === "idempotency_conflict";
679
+ }
680
+ return true;
681
+ };
682
+
683
+ const isValidLedgerHttpPayload = (payload) => isRecord(payload) && payload.ok === true;
684
+
685
+ const isValidLedgerFailureHttpPayload = (payload) =>
686
+ isRecord(payload)
687
+ && payload.ok === false
688
+ && (
689
+ payload.errorCode === "retrieval_ledger_write_failed"
690
+ || payload.errorCode === "retrieval_ledger_delivery_write_failed"
691
+ )
692
+ && typeof payload.reason === "string";
693
+
694
+ const isViewerDbMismatchPayload = (payload) =>
695
+ isRecord(payload)
696
+ && isRecord(payload.error)
697
+ && payload.error.code === "viewer_db_mismatch";
698
+
699
+ const isViewerIdentityMismatchPayload = (payload) =>
700
+ isRecord(payload)
701
+ && isRecord(payload.error)
702
+ && payload.error.code === "viewer_identity_mismatch";
703
+
704
+ const isViewerContractUnsupportedPayload = (payload) =>
705
+ isRecord(payload)
706
+ && isRecord(payload.error)
707
+ && payload.error.code === "viewer_contract_unsupported";
708
+
709
+ const isViewerInvalidRequestPayload = (payload) =>
710
+ isRecord(payload)
711
+ && isRecord(payload.error)
712
+ && payload.error.code === "invalid_request"
713
+ && typeof payload.error.message === "string";
714
+
715
+ const classifyViewerHttpFailure = ({
716
+ operation,
717
+ status = null,
718
+ error = null,
719
+ malformed = false,
720
+ body = null,
721
+ }) => {
722
+ if (malformed) {
723
+ return { cause: `${operation} returned malformed success`, retryable: true };
724
+ }
725
+ if (
726
+ isViewerDbMismatchPayload(body)
727
+ || isViewerIdentityMismatchPayload(body)
728
+ || isViewerContractUnsupportedPayload(body)
729
+ ) {
730
+ return { cause: `${operation} viewer profile mismatch`, retryable: true };
731
+ }
732
+ if (operation === "prompt-pack-ledger" && isValidLedgerFailureHttpPayload(body)) {
733
+ return { cause: `${operation} request rejected (${status})`, retryable: false };
734
+ }
735
+ if (isViewerInvalidRequestPayload(body) && !operation.endsWith(" profile")) {
736
+ return { cause: `${operation} request rejected (${status})`, retryable: false };
737
+ }
738
+ if (status === 404 || status === 405) {
739
+ return { cause: `${operation} endpoint unavailable (${status})`, retryable: true };
740
+ }
741
+ if (Number.isInteger(status) && status >= 500) {
742
+ return { cause: `${operation} server failure (${status})`, retryable: true };
743
+ }
744
+ if (Number.isInteger(status)) {
745
+ return { cause: `${operation} unexpected response (${status})`, retryable: true };
746
+ }
747
+ const diagnostic = [
748
+ error?.name,
749
+ error?.code,
750
+ error?.cause?.code,
751
+ error?.message,
752
+ error,
753
+ ].filter(Boolean).join(" ");
754
+ const timedOut = /AbortError|TimeoutError|timeout|timed out|ETIMEDOUT/i.test(diagnostic);
755
+ return {
756
+ cause: timedOut ? `${operation} request timeout` : `${operation} connection failed`,
757
+ retryable: true,
758
+ };
759
+ };
760
+
761
+ const buildPackHttpBody = ({
762
+ query,
763
+ filesModified,
764
+ injectLimit,
765
+ injectTokenBudget,
766
+ projectName,
767
+ cwd,
768
+ dbPath,
769
+ identityTarget,
770
+ attempt,
771
+ }) => ({
772
+ context: query,
773
+ limit: injectLimit !== null && Number.isFinite(injectLimit) && injectLimit > 0
774
+ ? Math.trunc(injectLimit)
775
+ : 10,
776
+ token_budget:
777
+ injectTokenBudget !== null
778
+ && Number.isFinite(injectTokenBudget)
779
+ && injectTokenBudget > 0
780
+ ? Math.trunc(injectTokenBudget)
781
+ : null,
782
+ ...(projectName ? { project: projectName } : {}),
783
+ ...(cwd ? { cwd } : {}),
784
+ db_path: dbPath,
785
+ identity_target: identityTarget,
786
+ working_set_files: Array.from(filesModified || [])
787
+ .slice(-8)
788
+ .map((value) => String(value || "").trim())
789
+ .filter((value) => value && value.length <= MAX_WORKING_SET_PATH_CHARS),
790
+ attempt,
791
+ });
792
+
448
793
  const canonicalJson = (value) => {
449
794
  if (Array.isArray(value)) {
450
795
  return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
@@ -491,7 +836,7 @@ const applyInjectedContextToOutput = async ({
491
836
  // lastPromptText hadn't yet been captured by the time
492
837
  // experimental.chat.system.transform fired) would silently re-serve
493
838
  // the first turn's pack. Recompute on every call instead — the chat
494
- // path tolerates the runCli round trip, and correctness beats the
839
+ // path tolerates the transport round trip, and correctness beats the
495
840
  // O(1) hit when the cache key isn't tied to the prompt that produced
496
841
  // the pack.
497
842
  const query = resolveInjectQuery();
@@ -1264,7 +1609,7 @@ const buildRunnerArgs = ({ runner, runnerFrom, runnerFromExplicit }) => {
1264
1609
  return runnerFromExplicit ? [runnerFrom] : [];
1265
1610
  };
1266
1611
 
1267
- export const OpencodeMemPlugin = async ({
1612
+ export const CodememPlugin = async ({
1268
1613
  project,
1269
1614
  client,
1270
1615
  directory,
@@ -1323,6 +1668,35 @@ export const OpencodeMemPlugin = async ({
1323
1668
  const viewerHost = process.env.CODEMEM_VIEWER_HOST || "127.0.0.1";
1324
1669
  const viewerPort = process.env.CODEMEM_VIEWER_PORT || "38888";
1325
1670
  const viewerDbPath = process.env.CODEMEM_DB || "";
1671
+ const expandedViewerDbPath = viewerDbPath.startsWith("~/")
1672
+ ? join(process.env.HOME?.trim() || homedir(), viewerDbPath.slice(2))
1673
+ : viewerDbPath;
1674
+ const promptPackDbPath = resolve(
1675
+ cwd,
1676
+ expandedViewerDbPath || join(homedir(), ".codemem", "mem.sqlite"),
1677
+ );
1678
+ const normalizeIdentityPath = (value) => {
1679
+ const trimmed = String(value || "").trim();
1680
+ if (!trimmed) return null;
1681
+ const expanded = trimmed.startsWith("~/")
1682
+ ? join(process.env.HOME?.trim() || homedir(), trimmed.slice(2))
1683
+ : trimmed;
1684
+ return resolve(cwd, expanded);
1685
+ };
1686
+ const promptPackIdentityTarget = {
1687
+ device_id: process.env.CODEMEM_DEVICE_ID?.trim() || null,
1688
+ actor_id_present: Object.hasOwn(process.env, "CODEMEM_ACTOR_ID"),
1689
+ actor_id: process.env.CODEMEM_ACTOR_ID?.trim() || null,
1690
+ config_path: normalizeIdentityPath(process.env.CODEMEM_CONFIG),
1691
+ runtime_root: normalizeIdentityPath(process.env.CODEMEM_RUNTIME_ROOT),
1692
+ workspace_id: process.env.CODEMEM_WORKSPACE_ID?.trim() || null,
1693
+ home_dir: normalizeIdentityPath(process.env.HOME || homedir()),
1694
+ pack_compression: process.env.CODEMEM_PACK_COMPRESSION?.trim() || null,
1695
+ embedding_disabled: ["1", "true", "yes"].includes(
1696
+ String(process.env.CODEMEM_EMBEDDING_DISABLED || "").toLowerCase(),
1697
+ ),
1698
+ embedding_model: process.env.CODEMEM_EMBEDDING_MODEL || "Xenova/bge-small-en-v1.5",
1699
+ };
1326
1700
  const viewerConfigPath = process.env.CODEMEM_CONFIG || "";
1327
1701
  // A malformed value (e.g. "abc", "", "-1") falls back to the 20s default
1328
1702
  // instead of NaN, which would silently disable the timeout. An explicit "0"
@@ -1332,6 +1706,8 @@ export const OpencodeMemPlugin = async ({
1332
1706
  const rawCommandTimeout = String(process.env.CODEMEM_PLUGIN_CMD_TIMEOUT ?? "").trim();
1333
1707
  const commandTimeout =
1334
1708
  rawCommandTimeout === "0" ? 0 : parsePositiveInt(rawCommandTimeout, 20000);
1709
+ const promptPackHttpTimeout =
1710
+ parsePositiveInt(process.env.CODEMEM_INJECT_HTTP_MAX_TIME_S || "2", 2) * 1000;
1335
1711
  const backendUpdatePolicy = parseBackendUpdatePolicy(
1336
1712
  process.env.CODEMEM_BACKEND_UPDATE_POLICY || "notify"
1337
1713
  );
@@ -1359,6 +1735,8 @@ export const OpencodeMemPlugin = async ({
1359
1735
  let sessionStartedAt = null;
1360
1736
  let activeSessionID = null;
1361
1737
  let viewerStarted = false;
1738
+ let viewerStartInFlight = false;
1739
+ let compatibilityAutoUpdateAttempted = false;
1362
1740
  let promptCounter = 0;
1363
1741
  let skippedAttemptCounter = 0;
1364
1742
  let lastPromptText = null;
@@ -1373,8 +1751,15 @@ export const OpencodeMemPlugin = async ({
1373
1751
  const rawEventsEnabled = envNotDisabled(
1374
1752
  process.env.CODEMEM_RAW_EVENTS || "1"
1375
1753
  );
1376
- const rawEventsUrl = `http://${viewerHost}:${viewerPort}/api/raw-events`;
1377
- const rawEventsStatusUrl = `http://${viewerHost}:${viewerPort}/api/raw-events/status?limit=1`;
1754
+ const viewerUrlHost = viewerHost.includes(":") && !viewerHost.startsWith("[")
1755
+ ? `[${viewerHost}]`
1756
+ : viewerHost;
1757
+ const rawEventsUrl = `http://${viewerUrlHost}:${viewerPort}/api/raw-events`;
1758
+ const rawEventsStatusUrl = `http://${viewerUrlHost}:${viewerPort}/api/raw-events/status?limit=1`;
1759
+ const packUrl = `http://${viewerUrlHost}:${viewerPort}/api/pack`;
1760
+ const promptPackProfileUrl = `http://${viewerUrlHost}:${viewerPort}/api/prompt-pack-profile`;
1761
+ const promptPackLedgerUrl = `http://${viewerUrlHost}:${viewerPort}/api/prompt-pack-ledger`;
1762
+ const viewerHealthUrl = `http://${viewerUrlHost}:${viewerPort}/api/health`;
1378
1763
  const rawEventsBackoffMs = parseNumber(
1379
1764
  process.env.CODEMEM_RAW_EVENTS_BACKOFF_MS || "10000",
1380
1765
  10000
@@ -1392,14 +1777,8 @@ export const OpencodeMemPlugin = async ({
1392
1777
  let fallbackFailureNoted = false;
1393
1778
  let lastStatusCheckAt = 0;
1394
1779
  let lastStatusAvailable = true;
1780
+ let promptPackTransportUnavailableUntil = 0;
1395
1781
 
1396
- // Viewer health-check state
1397
- const HEALTH_CHECK_INTERVAL_MS = 60_000;
1398
- const HEALTH_CONSECUTIVE_FAILURES_BEFORE_RESTART = 3;
1399
- const HEALTH_RESTART_COOLDOWN_MS = 5 * 60_000;
1400
- let healthCheckTimer = null;
1401
- let healthConsecutiveFailures = 0;
1402
- let healthLastRestartAttempt = 0;
1403
1782
  const nextEventId = () => {
1404
1783
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
1405
1784
  return crypto.randomUUID();
@@ -1525,8 +1904,10 @@ export const OpencodeMemPlugin = async ({
1525
1904
  }
1526
1905
  try {
1527
1906
  if (now - lastStatusCheckAt >= Math.max(1000, rawEventsStatusCheckMs)) {
1528
- const statusResp = await fetch(rawEventsStatusUrl, { method: "GET" });
1907
+ const statusResp = await fetchRawEventsStatus(rawEventsStatusUrl);
1529
1908
  if (!statusResp.ok) {
1909
+ // Release the unread body before bailing into the backoff path.
1910
+ discardResponseBody(statusResp);
1530
1911
  throw new Error(`raw-events status failed (${statusResp.status})`);
1531
1912
  }
1532
1913
  const statusJson = await statusResp.json();
@@ -1881,12 +2262,28 @@ export const OpencodeMemPlugin = async ({
1881
2262
  return { usage, id: info.id };
1882
2263
  };
1883
2264
 
1884
- const startViewer = () => {
1885
- if (!viewerEnabled || !viewerAutoStart || viewerStarted) {
2265
+ const startViewer = async () => {
2266
+ if (!viewerEnabled || !viewerAutoStart || viewerStarted || viewerStartInFlight) {
1886
2267
  if (viewerStarted) logLine("viewer already started, skipping auto-start").catch(() => {});
1887
2268
  return;
1888
2269
  }
1889
- viewerStarted = true;
2270
+ viewerStartInFlight = true;
2271
+ let existingViewer = false;
2272
+ try {
2273
+ const existing = await fetch(viewerHealthUrl, {
2274
+ method: "GET",
2275
+ redirect: "manual",
2276
+ signal: AbortSignal.timeout(1_000),
2277
+ });
2278
+ existingViewer = existing.ok;
2279
+ } catch {
2280
+ // No live viewer responded; proceed with the plugin-owned start.
2281
+ }
2282
+ if (existingViewer) {
2283
+ viewerStartInFlight = false;
2284
+ logLine("viewer already running, skipping plugin-owned auto-start").catch(() => {});
2285
+ return;
2286
+ }
1890
2287
  const viewerArgs = buildViewerCliArgs("start");
1891
2288
  const cmd = [runner, ...runnerArgs, ...viewerArgs];
1892
2289
  logLine(`auto-starting viewer: ${cmd.join(" ")}`).catch(() => {});
@@ -1897,18 +2294,24 @@ export const OpencodeMemPlugin = async ({
1897
2294
  detached: true,
1898
2295
  stdio: "ignore",
1899
2296
  });
2297
+ child.once("spawn", () => {
2298
+ viewerStarted = true;
2299
+ viewerStartInFlight = false;
2300
+ startHealthCheck();
2301
+ });
1900
2302
  child.on("error", (err) => {
2303
+ viewerStartInFlight = false;
1901
2304
  logLine(`viewer spawn error: ${err.message}`).catch(() => {});
1902
2305
  });
1903
2306
  child.unref();
1904
2307
  } catch (err) {
2308
+ viewerStartInFlight = false;
1905
2309
  logLine(`viewer spawn failed: ${err}`).catch(() => {});
1906
2310
  }
1907
- startHealthCheck();
1908
2311
  };
1909
2312
 
1910
2313
  const runCommand = async (cmd, options = {}) => {
1911
- const { stdinText = null } = options;
2314
+ const { stdinText = null, timeoutMs = commandTimeout } = options;
1912
2315
  const [command, ...args] = cmd;
1913
2316
  return new Promise((resolve) => {
1914
2317
  const proc = nodeSpawn(command, args, {
@@ -1947,19 +2350,31 @@ export const OpencodeMemPlugin = async ({
1947
2350
  return;
1948
2351
  }
1949
2352
  let timer = null;
1950
- if (Number.isFinite(commandTimeout) && commandTimeout > 0) {
2353
+ let killTimer = null;
2354
+ let timedOut = false;
2355
+ let settled = false;
2356
+ const finish = (result) => {
2357
+ if (settled) return;
2358
+ settled = true;
2359
+ if (timer) clearTimeout(timer);
2360
+ if (killTimer) clearTimeout(killTimer);
2361
+ resolve(result);
2362
+ };
2363
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
1951
2364
  timer = setTimeout(() => {
1952
- try { proc.kill(); } catch { /* ignore */ }
1953
- resolve({ exitCode: null, stdout, stderr: "timeout" });
1954
- }, commandTimeout);
2365
+ timedOut = true;
2366
+ try { proc.kill("SIGTERM"); } catch { /* ignore */ }
2367
+ killTimer = setTimeout(() => {
2368
+ try { proc.kill("SIGKILL"); } catch { /* ignore */ }
2369
+ }, 5_000);
2370
+ if (killTimer.unref) killTimer.unref();
2371
+ }, timeoutMs);
1955
2372
  }
1956
2373
  proc.once("exit", (exitCode) => {
1957
- if (timer) clearTimeout(timer);
1958
- resolve({ exitCode, stdout, stderr });
2374
+ finish({ exitCode: timedOut ? null : exitCode, stdout, stderr: timedOut ? "timeout" : stderr });
1959
2375
  });
1960
2376
  proc.once("error", (err) => {
1961
- if (timer) clearTimeout(timer);
1962
- resolve({ exitCode: 1, stdout: "", stderr: String(err) });
2377
+ finish({ exitCode: 1, stdout: "", stderr: String(err) });
1963
2378
  });
1964
2379
  });
1965
2380
  };
@@ -1967,6 +2382,110 @@ export const OpencodeMemPlugin = async ({
1967
2382
  const runCli = async (args, options = {}) =>
1968
2383
  runCommand([runner, ...runnerArgs, ...args], options);
1969
2384
 
2385
+ const postViewerJson = async ({ url, operation, payload, validate }) => {
2386
+ if (!viewerEnabled) {
2387
+ return {
2388
+ ok: false,
2389
+ classification: {
2390
+ cause: `${operation} viewer transport disabled`,
2391
+ retryable: true,
2392
+ },
2393
+ };
2394
+ }
2395
+ if (Date.now() < promptPackTransportUnavailableUntil) {
2396
+ return {
2397
+ ok: false,
2398
+ classification: {
2399
+ cause: `${operation} viewer transport in backoff`,
2400
+ retryable: true,
2401
+ },
2402
+ };
2403
+ }
2404
+
2405
+ let response;
2406
+ try {
2407
+ const profileResponse = await fetch(promptPackProfileUrl, {
2408
+ method: "GET",
2409
+ redirect: "manual",
2410
+ signal: AbortSignal.timeout(promptPackHttpTimeout),
2411
+ });
2412
+ let profileBody;
2413
+ try {
2414
+ profileBody = await profileResponse.json();
2415
+ } catch {
2416
+ profileBody = null;
2417
+ }
2418
+ if (!profileResponse.ok) {
2419
+ const classification = classifyViewerHttpFailure({
2420
+ operation: `${operation} profile`,
2421
+ status: profileResponse.status,
2422
+ body: profileBody,
2423
+ });
2424
+ if (classification.retryable) {
2425
+ promptPackTransportUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
2426
+ }
2427
+ return { ok: false, classification };
2428
+ }
2429
+ if (
2430
+ !isRecord(profileBody)
2431
+ || profileBody.service !== "codemem-viewer"
2432
+ || profileBody.protocol_version !== 1
2433
+ || profileBody.db_path !== promptPackDbPath
2434
+ || canonicalJson(profileBody.identity_target) !== canonicalJson(promptPackIdentityTarget)
2435
+ ) {
2436
+ promptPackTransportUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
2437
+ return {
2438
+ ok: false,
2439
+ classification: {
2440
+ cause: `${operation} viewer profile handshake mismatch`,
2441
+ retryable: true,
2442
+ },
2443
+ };
2444
+ }
2445
+ response = await fetch(url, {
2446
+ method: "POST",
2447
+ redirect: "manual",
2448
+ headers: { "Content-Type": "application/json" },
2449
+ body: JSON.stringify(payload),
2450
+ signal: AbortSignal.timeout(promptPackHttpTimeout),
2451
+ });
2452
+ } catch (error) {
2453
+ const classification = classifyViewerHttpFailure({ operation, error });
2454
+ promptPackTransportUnavailableUntil =
2455
+ Date.now() + Math.max(1000, rawEventsBackoffMs);
2456
+ return { ok: false, classification };
2457
+ }
2458
+
2459
+ let body;
2460
+ try {
2461
+ body = await response.json();
2462
+ } catch {
2463
+ body = null;
2464
+ }
2465
+ if (!response.ok) {
2466
+ const classification = classifyViewerHttpFailure({
2467
+ operation,
2468
+ status: response.status,
2469
+ body,
2470
+ });
2471
+ if (classification.retryable) {
2472
+ promptPackTransportUnavailableUntil =
2473
+ Date.now() + Math.max(1000, rawEventsBackoffMs);
2474
+ }
2475
+ return { ok: false, classification };
2476
+ }
2477
+
2478
+ if (!validate(body)) {
2479
+ const classification = classifyViewerHttpFailure({ operation, malformed: true });
2480
+ promptPackTransportUnavailableUntil =
2481
+ Date.now() + Math.max(1000, rawEventsBackoffMs);
2482
+ return { ok: false, classification };
2483
+ }
2484
+
2485
+ promptPackTransportUnavailableUntil = 0;
2486
+ return { ok: true, body };
2487
+ };
2488
+
1970
2489
  const attemptMetadata = (identity, sessionID = null, promptNumber = promptCounter) => ({
1971
2490
  attempt_id: identity.attemptId,
1972
2491
  started_at: (() => {
@@ -1988,10 +2507,39 @@ export const OpencodeMemPlugin = async ({
1988
2507
  });
1989
2508
 
1990
2509
  const runPromptPackLedger = async (payload) => {
2510
+ const viewerPayload = {
2511
+ ...payload,
2512
+ db_path: promptPackDbPath,
2513
+ identity_target: promptPackIdentityTarget,
2514
+ };
2515
+ const httpResult = await postViewerJson({
2516
+ url: promptPackLedgerUrl,
2517
+ operation: "prompt-pack-ledger",
2518
+ payload: viewerPayload,
2519
+ validate: isValidLedgerHttpPayload,
2520
+ });
2521
+ if (httpResult.ok) {
2522
+ return {
2523
+ exitCode: 0,
2524
+ stdout: JSON.stringify(httpResult.body),
2525
+ stderr: "",
2526
+ transport: "viewer",
2527
+ };
2528
+ }
2529
+
2530
+ const { cause, retryable } = httpResult.classification;
2531
+ await logLine(
2532
+ `inject.ledger.http_error cause=${JSON.stringify(redactLog(cause, 200))} retryable=${retryable}`
2533
+ );
2534
+ if (!retryable) {
2535
+ return { exitCode: 1, stdout: "", stderr: cause, transport: "viewer" };
2536
+ }
2537
+
1991
2538
  try {
1992
- return await runCli(["prompt-pack-ledger"], {
2539
+ const result = await runCli(["prompt-pack-ledger"], {
1993
2540
  stdinText: JSON.stringify(payload),
1994
2541
  });
2542
+ return { ...result, transport: "cli" };
1995
2543
  } catch {
1996
2544
  return null;
1997
2545
  }
@@ -2077,7 +2625,7 @@ export const OpencodeMemPlugin = async ({
2077
2625
  if (!viewerEnabled || !viewerAutoStart || !viewerStarted) {
2078
2626
  return { attempted: false, ok: false };
2079
2627
  }
2080
- const restartResult = await runCli(buildViewerCliArgs("restart"));
2628
+ const restartResult = await runCli(buildViewerCliArgs("restart"), { timeoutMs: 60_000 });
2081
2629
  if (restartResult?.exitCode === 0) {
2082
2630
  await logLine("compat.auto_update_viewer_restart ok");
2083
2631
  return { attempted: true, ok: true };
@@ -2157,12 +2705,14 @@ export const OpencodeMemPlugin = async ({
2157
2705
  `compat.version_mismatch current=${currentVersion} required=${minVersion} mode=${guidance.mode} note=${redactLog(guidance.note)}`
2158
2706
  );
2159
2707
 
2160
- const autoPlan = resolveAutoUpdatePlan({ runner, runnerFrom });
2161
2708
  if (backendUpdatePolicy === "auto") {
2162
- if (autoPlan.allowed && Array.isArray(autoPlan.command) && autoPlan.command.length > 0) {
2163
- const commandText = autoPlan.commandText || autoPlan.command.join(" ");
2164
- await logLine(`compat.auto_update_start cmd=${redactLog(commandText)}`);
2165
- const updateResult = await runCommand(autoPlan.command);
2709
+ compatibilityAutoUpdateAttempted = true;
2710
+ await logLine("compat.auto_update_start cmd=codemem update install --json");
2711
+ const updateResult = await runCli(
2712
+ ["update", "install", "--json"],
2713
+ { timeoutMs: 420_000 }
2714
+ );
2715
+ if (updateResult?.exitCode === 0) {
2166
2716
  await logLine(
2167
2717
  `compat.auto_update_result exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog(
2168
2718
  (updateResult?.stderr || "").trim()
@@ -2172,8 +2722,7 @@ export const OpencodeMemPlugin = async ({
2172
2722
  const refreshedResult = await runCli(["version"]);
2173
2723
  const refreshedVersion = (refreshedResult?.stdout || "").trim();
2174
2724
  if (
2175
- updateResult?.exitCode === 0
2176
- && refreshedResult?.exitCode === 0
2725
+ refreshedResult?.exitCode === 0
2177
2726
  && isVersionAtLeast(refreshedVersion, minVersion)
2178
2727
  ) {
2179
2728
  writeCompatCheckCache(cacheKey, refreshedVersion);
@@ -2193,19 +2742,31 @@ export const OpencodeMemPlugin = async ({
2193
2742
  }
2194
2743
  return;
2195
2744
  }
2196
-
2745
+ await logLine(
2746
+ `compat.auto_update_verification_failed current=${redactLog(refreshedVersion || "unknown")} required=${redactLog(minVersion)}`
2747
+ );
2197
2748
  await showToast(
2198
- `${message}. Auto-update did not resolve it. Suggested action: ${guidance.action}`,
2749
+ `${message}. Auto-update completed, but the active CLI failed verification. Suggested action: ${guidance.action}`,
2199
2750
  "warning"
2200
2751
  );
2201
2752
  return;
2202
2753
  }
2203
-
2754
+ let installError = null;
2755
+ try {
2756
+ installError = JSON.parse((updateResult?.stdout || "").trim())?.error || null;
2757
+ } catch {
2758
+ // Non-JSON output is treated as an installation failure.
2759
+ }
2760
+ const failureReason = installError === "update_install_locked"
2761
+ ? "another update is already running"
2762
+ : installError === "update_install_refused"
2763
+ ? "not eligible"
2764
+ : "installation failed";
2204
2765
  await logLine(
2205
- `compat.auto_update_skipped reason=${autoPlan.reason || "not-eligible"}`
2766
+ `compat.auto_update_skipped reason=${installError || "update_install_failed"} exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog((updateResult?.stderr || "").trim())}`
2206
2767
  );
2207
2768
  await showToast(
2208
- `${message}. Auto-update skipped (${autoPlan.reason || "not eligible"}). Suggested action: ${guidance.action}`,
2769
+ `${message}. Auto-update skipped (${failureReason}). Suggested action: ${guidance.action}`,
2209
2770
  "warning"
2210
2771
  );
2211
2772
  return;
@@ -2214,6 +2775,53 @@ export const OpencodeMemPlugin = async ({
2214
2775
  await showToast(`${message}. Suggested action: ${guidance.action}`, "warning");
2215
2776
  };
2216
2777
 
2778
+ const checkForReleaseUpdate = async () => {
2779
+ if (backendUpdatePolicy === "off") return;
2780
+ const notification = parseReleaseNotification(
2781
+ await runCli(["update", "check", "--json"])
2782
+ );
2783
+ if (
2784
+ !notification
2785
+ || notifiedReleaseVersions.has(notification.latestVersion)
2786
+ ) {
2787
+ return;
2788
+ }
2789
+ notifiedReleaseVersions.add(notification.latestVersion);
2790
+ if (
2791
+ backendUpdatePolicy === "auto"
2792
+ && notification.autoUpdateEligible
2793
+ && !compatibilityAutoUpdateAttempted
2794
+ ) {
2795
+ const autoPlan = resolveAutoUpdatePlan({ runner, runnerFrom, runnerFromExplicit });
2796
+ if (autoPlan.allowed) {
2797
+ const installation = await runCli(
2798
+ ["update", "install", "--json"],
2799
+ { timeoutMs: 420_000 }
2800
+ );
2801
+ if (installation?.exitCode === 0) {
2802
+ const viewerRestart = await restartViewerAfterAutoUpdate();
2803
+ await showToast(`Updated codemem to ${notification.latestVersion}.`, "success");
2804
+ if (viewerRestart.attempted && !viewerRestart.ok) {
2805
+ await showToast(
2806
+ "Backend updated, but viewer restart failed. Run `codemem serve restart`.",
2807
+ "warning"
2808
+ );
2809
+ }
2810
+ return;
2811
+ }
2812
+ await logLine(
2813
+ `release.auto_update_failed exit=${installation?.exitCode ?? "unknown"} stderr=${redactLog(
2814
+ (installation?.stderr || "").trim()
2815
+ )}`
2816
+ );
2817
+ }
2818
+ }
2819
+ await showToast(
2820
+ `codemem ${notification.latestVersion} is available. ${notification.recommendedAction}`,
2821
+ "warning"
2822
+ );
2823
+ };
2824
+
2217
2825
  const resolveInjectQuery = (overrides = {}) => {
2218
2826
  const firstPrompt = hasOwn(overrides, "firstPrompt")
2219
2827
  ? overrides.firstPrompt
@@ -2317,12 +2925,51 @@ export const OpencodeMemPlugin = async ({
2317
2925
  injectTokenBudget,
2318
2926
  internalLedger: true,
2319
2927
  });
2928
+ const httpResult = await postViewerJson({
2929
+ url: packUrl,
2930
+ operation: "pack",
2931
+ payload: buildPackHttpBody({
2932
+ query,
2933
+ filesModified: sessionContext.filesModified,
2934
+ injectLimit,
2935
+ injectTokenBudget,
2936
+ projectName: normalizeProjectLabel(process.env.CODEMEM_PROJECT),
2937
+ cwd,
2938
+ dbPath: promptPackDbPath,
2939
+ identityTarget: promptPackIdentityTarget,
2940
+ attempt: metadata,
2941
+ }),
2942
+ validate: isValidPackHttpPayload,
2943
+ });
2944
+ if (httpResult.ok) {
2945
+ return {
2946
+ packArgs,
2947
+ result: {
2948
+ exitCode: 0,
2949
+ stdout: JSON.stringify(httpResult.body),
2950
+ stderr: "",
2951
+ transport: "viewer",
2952
+ },
2953
+ };
2954
+ }
2955
+
2956
+ const { cause, retryable } = httpResult.classification;
2957
+ await logLine(
2958
+ `inject.pack.http_error cause=${JSON.stringify(redactLog(cause, 200))} retryable=${retryable}`
2959
+ );
2960
+ if (!retryable) {
2961
+ return {
2962
+ packArgs,
2963
+ result: { exitCode: 1, stdout: "", stderr: cause, transport: "viewer" },
2964
+ };
2965
+ }
2966
+
2320
2967
  let result = await runCli(packArgs, { stdinText: JSON.stringify(metadata) });
2321
2968
  if (rejectsInternalLedgerFlag(result)) {
2322
2969
  packArgs = packArgs.filter((arg) => arg !== "--internal-ledger");
2323
2970
  result = await runCli(packArgs);
2324
2971
  }
2325
- return { packArgs, result };
2972
+ return { packArgs, result: { ...result, transport: "cli" } };
2326
2973
  };
2327
2974
  let { packArgs, result } = await runPack();
2328
2975
  let { packText, conflictPackText, metrics, itemCount, ledgerConflict } = parsePackOutput(result);
@@ -2333,7 +2980,7 @@ export const OpencodeMemPlugin = async ({
2333
2980
  let repairFallbackUsed = false;
2334
2981
  if (ledgerConflict) {
2335
2982
  // A restarted plugin has no artifact cache to predict this conflict. The
2336
- // CLI marker is authoritative: never attribute delivery to the stale
2983
+ // ledger marker is authoritative: never attribute delivery to the stale
2337
2984
  // identity, and retry once with a fresh deterministic identity.
2338
2985
  await log("warn", "codemem prompt-pack ledger conflict; retrying with fresh identity", {
2339
2986
  sessionID,
@@ -2606,62 +3253,15 @@ export const OpencodeMemPlugin = async ({
2606
3253
  await runCli(buildViewerCliArgs("stop"));
2607
3254
  };
2608
3255
 
2609
- const checkViewerHealth = async () => {
2610
- if (!viewerStarted || !viewerEnabled) return;
2611
- try {
2612
- const resp = await fetch(rawEventsStatusUrl, {
2613
- method: "GET",
2614
- signal: AbortSignal.timeout(5000),
2615
- });
2616
- if (resp.ok) {
2617
- if (healthConsecutiveFailures > 0) {
2618
- await logLine(`viewer.health recovered after ${healthConsecutiveFailures} failure(s)`);
2619
- }
2620
- healthConsecutiveFailures = 0;
2621
- return;
2622
- }
2623
- healthConsecutiveFailures++;
2624
- await logLine(`viewer.health check failed (status=${resp.status}, consecutive=${healthConsecutiveFailures})`);
2625
- } catch (err) {
2626
- healthConsecutiveFailures++;
2627
- await logLine(`viewer.health check error (consecutive=${healthConsecutiveFailures}): ${String(err).slice(0, 200)}`);
2628
- }
2629
-
2630
- if (
2631
- healthConsecutiveFailures >= HEALTH_CONSECUTIVE_FAILURES_BEFORE_RESTART &&
2632
- Date.now() - healthLastRestartAttempt >= HEALTH_RESTART_COOLDOWN_MS
2633
- ) {
2634
- healthLastRestartAttempt = Date.now();
2635
- await logLine(`viewer.health restarting viewer after ${healthConsecutiveFailures} consecutive failures`);
2636
- try {
2637
- const result = await runCli(buildViewerCliArgs("restart"));
2638
- const ok = result?.exitCode === 0;
2639
- await logLine(`viewer.health restart ${ok ? "succeeded" : "failed"} (exit=${result?.exitCode ?? "unknown"})`);
2640
- if (ok) {
2641
- healthConsecutiveFailures = 0;
2642
- }
2643
- } catch (restartErr) {
2644
- await logLine(`viewer.health restart error: ${String(restartErr).slice(0, 200)}`);
2645
- }
2646
- }
2647
- };
2648
-
2649
- const startHealthCheck = () => {
2650
- if (healthCheckTimer) return;
2651
- healthConsecutiveFailures = 0;
2652
- healthCheckTimer = setInterval(() => {
2653
- checkViewerHealth().catch(() => {});
2654
- }, HEALTH_CHECK_INTERVAL_MS);
2655
- if (healthCheckTimer.unref) healthCheckTimer.unref();
2656
- };
2657
-
2658
- const stopHealthCheck = () => {
2659
- if (healthCheckTimer) {
2660
- clearInterval(healthCheckTimer);
2661
- healthCheckTimer = null;
2662
- }
2663
- healthConsecutiveFailures = 0;
2664
- };
3256
+ const viewerHealthMonitor = createViewerHealthMonitor({
3257
+ viewerHealthUrl,
3258
+ legacyStatusUrl: rawEventsStatusUrl,
3259
+ isActive: () => viewerStarted && viewerEnabled,
3260
+ restartViewer: () => runCli(buildViewerCliArgs("restart")),
3261
+ logLine,
3262
+ });
3263
+ const startHealthCheck = viewerHealthMonitor.start;
3264
+ const stopHealthCheck = viewerHealthMonitor.stop;
2665
3265
 
2666
3266
  // Get version info (commit hash) for debugging
2667
3267
  let version = "unknown";
@@ -2680,15 +3280,26 @@ export const OpencodeMemPlugin = async ({
2680
3280
 
2681
3281
  await log("info", "codemem plugin initialized", { cwd, version });
2682
3282
  await logLine(`plugin initialized cwd=${cwd} version=${version}`);
2683
- startViewer();
2684
- const compatCheckTimer = setTimeout(() => {
2685
- void verifyCliCompatibility().catch(async (err) => {
2686
- await logLine(
2687
- `compat.version_check_error message=${String(err?.message || err || "unknown")}`
2688
- );
2689
- });
3283
+ void startViewer();
3284
+ const updateCheckTimer = setTimeout(() => {
3285
+ void (async () => {
3286
+ try {
3287
+ await verifyCliCompatibility();
3288
+ } catch (err) {
3289
+ await logLine(
3290
+ `compat.version_check_error message=${String(err?.message || err || "unknown")}`
3291
+ );
3292
+ }
3293
+ try {
3294
+ await checkForReleaseUpdate();
3295
+ } catch (err) {
3296
+ await logLine(
3297
+ `release.update_check_error message=${String(err?.message || err || "unknown")}`
3298
+ );
3299
+ }
3300
+ })();
2690
3301
  }, COMPAT_CHECK_DELAY_MS);
2691
- if (compatCheckTimer.unref) compatCheckTimer.unref();
3302
+ if (updateCheckTimer.unref) updateCheckTimer.unref();
2692
3303
 
2693
3304
  const truncate = (value) => {
2694
3305
  if (value === undefined || value === null) {
@@ -2937,9 +3548,9 @@ export const OpencodeMemPlugin = async ({
2937
3548
  } inject_enabled=${injectEnabled} tui_toast=${Boolean(client.tui?.showToast)} query=${JSON.stringify(safeQuery)} first_prompt_len=${firstPromptLen} last_prompt_len=${lastPromptLen} project=${JSON.stringify(projectName)} files_modified=${filesModifiedCount}`
2938
3549
  );
2939
3550
  }
2940
- // Without the old per-session cache, every transform call shells out
2941
- // through `runCli`. Swallow rejections here so a single failed pack
2942
- // build (CLI crash, sqlite locked, network blip on HTTP fallback)
3551
+ // Without the old per-session cache, every transform call rebuilds the
3552
+ // pack. Swallow rejections here so a single failed build (viewer failure,
3553
+ // CLI fallback crash, sqlite lock, or network blip)
2943
3554
  // can't take down the chat path.
2944
3555
  let applied = false;
2945
3556
  try {
@@ -3217,7 +3828,7 @@ export const OpencodeMemPlugin = async ({
3217
3828
  const stats = await runCli(["stats"]);
3218
3829
  const recent = await runCli(["recent", "--limit", "5"]);
3219
3830
  const lines = [
3220
- `viewer: http://${viewerHost}:${viewerPort}`,
3831
+ `viewer: http://${viewerUrlHost}:${viewerPort}`,
3221
3832
  `log: ${logPath || "disabled"}`,
3222
3833
  ];
3223
3834
  if (stats.exitCode === 0 && stats.stdout.trim()) {
@@ -3262,9 +3873,16 @@ export const OpencodeMemPlugin = async ({
3262
3873
  };
3263
3874
  };
3264
3875
 
3265
- export default OpencodeMemPlugin;
3876
+ export default CodememPlugin;
3877
+
3878
+ /**
3879
+ * @deprecated Use CodememPlugin.
3880
+ * Keep this reference-identical: OpenCode deduplicates plugin exports by identity.
3881
+ */
3882
+ export const OpencodeMemPlugin = CodememPlugin;
3266
3883
  export const __testUtils = {
3267
3884
  PINNED_BACKEND_VERSION,
3885
+ fetchRawEventsStatus,
3268
3886
  inferProjectFromCwd,
3269
3887
  normalizeProjectLabel,
3270
3888
  resolveProjectName,
@@ -3276,6 +3894,10 @@ export const __testUtils = {
3276
3894
  redactPackCommand,
3277
3895
  rejectsInternalLedgerFlag,
3278
3896
  classifyFallbackCommandResult,
3897
+ classifyViewerHttpFailure,
3898
+ isValidPackHttpPayload,
3899
+ isValidLedgerHttpPayload,
3900
+ buildPackHttpBody,
3279
3901
  parsePackText,
3280
3902
  parsePackMetrics,
3281
3903
  resolveInjectSurface,
@@ -3296,4 +3918,5 @@ export const __testUtils = {
3296
3918
  buildRawEventEnvelope,
3297
3919
  trimEventQueue,
3298
3920
  parsePositiveInt,
3921
+ createViewerHealthMonitor,
3299
3922
  };
package/README.md CHANGED
@@ -21,6 +21,13 @@ Manual config also works. Add the package name to your OpenCode config:
21
21
 
22
22
  OpenCode installs npm plugins automatically with Bun at startup.
23
23
 
24
+ ## Exports
25
+
26
+ `CodememPlugin` is the canonical named export and the package default export.
27
+ `OpencodeMemPlugin` remains available as a deprecated, reference-identical alias
28
+ for compatibility with integrations created before the Codemem rename. No removal
29
+ version is currently scheduled.
30
+
24
31
  ## Documentation
25
32
 
26
33
  - Repository: https://github.com/kunickiaj/codemem
package/index.js CHANGED
@@ -1,2 +1,5 @@
1
- export { OpencodeMemPlugin as default } from "./.opencode/plugins/codemem.js";
2
- export { OpencodeMemPlugin } from "./.opencode/plugins/codemem.js";
1
+ export {
2
+ CodememPlugin as default,
3
+ CodememPlugin,
4
+ OpencodeMemPlugin,
5
+ } from "./.opencode/plugins/codemem.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemem/opencode-plugin",
3
- "version": "0.40.1",
3
+ "version": "0.41.0",
4
4
  "description": "CodeMem plugin for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -18,7 +18,7 @@
18
18
  ".opencode/lib/"
19
19
  ],
20
20
  "dependencies": {
21
- "@opencode-ai/plugin": "1.18.12"
21
+ "@opencode-ai/plugin": "1.18.16"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",