@pcircle/memesh 4.1.2 → 4.1.3

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.
@@ -1,16 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { createRequire } from 'module';
4
+ import { spawn } from 'child_process';
4
5
  import { createHash } from 'crypto';
5
6
  import { homedir } from 'os';
6
7
  import { join, basename } from 'path';
7
- import { existsSync, unlinkSync, rmSync, appendFileSync, chmodSync } from 'fs';
8
+ import { pathToFileURL } from 'url';
9
+ import { existsSync, readFileSync, unlinkSync, rmSync, appendFileSync, chmodSync, openSync, closeSync } from 'fs';
8
10
  import {
9
11
  buildReferenceContext,
10
12
  ensurePrivateDir,
11
13
  getMemeshDir,
12
14
  isAgenticOrchestrationEnabled,
13
15
  isTrustedForAutoContext,
16
+ resolveAutoUpdatePolicy,
14
17
  resolvePluginRoot,
15
18
  resolveSessionLimit,
16
19
  writePrivateJson,
@@ -18,16 +21,621 @@ import {
18
21
 
19
22
  const require = createRequire(import.meta.url);
20
23
 
24
+ // Codex round 37: dist/core/install-channel.js is emitted as ESM
25
+ // (the project's tsconfig produces NodeNext modules). On Node 20.x
26
+ // `require()` against an ESM file throws ERR_REQUIRE_ESM, which
27
+ // silently downgraded all install-channel detection to 'unknown' on
28
+ // the supported floor. Pre-load the module via dynamic `import()`
29
+ // at hook startup using a top-level await — once at process init,
30
+ // not on every call. Falls back to null if the dist file is
31
+ // missing (source checkout pre-build) or fails to load.
32
+ let _installChannelMod = null;
33
+ try {
34
+ const _pluginRootForInit = resolvePluginRoot(import.meta.url);
35
+ const _modPath = join(_pluginRootForInit, 'dist/core/install-channel.js');
36
+ if (existsSync(_modPath)) {
37
+ _installChannelMod = await import(pathToFileURL(_modPath).href);
38
+ }
39
+ } catch { /* best-effort — fall through to 'unknown' channel */ }
40
+
21
41
  const dbPath = process.env.MEMESH_DB_PATH || join(homedir(), '.memesh', 'knowledge-graph.db');
22
42
  const memeshDir = getMemeshDir(process.env);
23
43
  const throttlePath = join(memeshDir, 'session-recalled-files.json');
24
44
  const nudgeFlagsDir = join(memeshDir, 'agent-nudge-flags');
25
45
 
46
+ /**
47
+ * Read the cached npm update check produced by core/version-check.ts.
48
+ * Hooks must not depend on dist/, so this duplicates the path constant
49
+ * (mirrored in src/core/version-check.ts:6) and parses defensively.
50
+ * Returns null on missing/corrupt cache rather than throwing — the
51
+ * deprecation warning is best-effort.
52
+ */
53
+ function readUpdateCheckCache(installedVersion) {
54
+ // Codex round 38: scope cache reads by installed version so a
55
+ // multi-install setup (global 4.1.3 + project-local 4.1.1)
56
+ // doesn't fight over a single cache slot. Each install reads
57
+ // its own per-version file. Test/integration envs can still
58
+ // pin a specific path via MEMESH_UPDATE_CHECK_PATH.
59
+ if (process.env.MEMESH_UPDATE_CHECK_PATH) {
60
+ try {
61
+ const overridePath = process.env.MEMESH_UPDATE_CHECK_PATH;
62
+ if (!existsSync(overridePath)) return null;
63
+ const parsed = JSON.parse(readFileSync(overridePath, 'utf8'));
64
+ if (!parsed || typeof parsed !== 'object') return null;
65
+ return parsed;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+ const versionTag = typeof installedVersion === 'string'
71
+ && /^[0-9A-Za-z.+-]+$/.test(installedVersion)
72
+ ? installedVersion
73
+ : 'unknown';
74
+ const cachePath = join(homedir(), '.memesh', `update-check.${versionTag}.json`);
75
+ try {
76
+ if (!existsSync(cachePath)) return null;
77
+ const parsed = JSON.parse(readFileSync(cachePath, 'utf8'));
78
+ if (!parsed || typeof parsed !== 'object') return null;
79
+ return parsed;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Build the strong deprecation warning lines to prepend to the
87
+ * session-start banner when the installed version has been flagged
88
+ * by maintainers (typically a security advisory). Returns an empty
89
+ * array when the cache says nothing to warn about.
90
+ */
91
+ function buildDeprecationBanner(currentVersion, cache) {
92
+ if (!cache || cache.currentVersion !== currentVersion) return [];
93
+ const msg = cache.currentVersionDeprecation;
94
+ if (typeof msg !== 'string' || msg.length === 0) {
95
+ // Partial-failure state ONLY: the version lookup answered but
96
+ // the deprecation sub-call did not. checkSucceeded stays true
97
+ // exactly in that case. A full registry failure (offline,
98
+ // blocked) leaves checkSucceeded=false with a generic lastError,
99
+ // and we must not surface that as a security-style warning —
100
+ // it's just regular "couldn't reach npm". Gate the banner
101
+ // strictly on checkSucceeded=true + lastError populated.
102
+ if (
103
+ cache.checkSucceeded === true
104
+ && typeof cache.lastError === 'string'
105
+ && cache.lastError.length > 0
106
+ ) {
107
+ return [
108
+ '',
109
+ `ℹ️ MeMesh deprecation status unknown for ${currentVersion}: ${cache.lastError}`,
110
+ ` Run: memesh status (retry the lookup once back online)`,
111
+ ];
112
+ }
113
+ return [];
114
+ }
115
+ const lines = [
116
+ '',
117
+ `⚠️ MeMesh ${currentVersion} is DEPRECATED by maintainers.`,
118
+ ` ${msg}`,
119
+ ];
120
+ // Codex round 36: emit a remediation line for EVERY deprecation
121
+ // banner — including the cases where the cached `latestVersion`
122
+ // is null, equal to current, or stale. The previous gate omitted
123
+ // the action line whenever the cache didn't yet show a strictly-
124
+ // newer version, leaving users with a security warning and no
125
+ // follow-up step. doctor / CLI status / dashboard already point
126
+ // at `memesh update` (or channel equivalents) in those uncertain
127
+ // cases, and the session-start banner should match — `npm`
128
+ // resolves @latest at install time, so the command works even
129
+ // when our local cache is uncertain.
130
+ const knownUpgradeTarget = Boolean(
131
+ cache.latestVersion && cache.latestVersion !== currentVersion,
132
+ );
133
+ // Codex round 39: the SessionStart hook reads ONLY cached cache
134
+ // data — there's no fresh lookup happening on this code path.
135
+ // That means `freshness === 'fresh'` (the strict rule the
136
+ // dashboard / `memesh status` use to authoritatively say
137
+ // "no upgrade target yet") can never apply here. Round 38 used a
138
+ // 24h-window heuristic to fire the no-target message anyway, but
139
+ // codex correctly flagged that as suppressing the upgrade hint
140
+ // exactly when a security-advisory fix could ship within the
141
+ // window. Conservative remediation: always recommend
142
+ // `memesh update` (which is a harmless no-op when there's truly
143
+ // no target, and immediately applies a freshly-published fix
144
+ // when there is one). The "no target yet" message remains
145
+ // available in `memesh status` (fresh lookup) and the dashboard
146
+ // (after a Check now click).
147
+ // Tailor the remediation hint to the install channel. `memesh
148
+ // update` and `autoUpdate` only work for npm-global installs;
149
+ // pointing source-checkout / project-local users at those
150
+ // commands is misleading (especially when the deprecation is a
151
+ // security advisory). Detect the channel and suggest the
152
+ // remediation that actually applies.
153
+ let channel = 'unknown';
154
+ try {
155
+ const pluginRoot = resolvePluginRoot(import.meta.url);
156
+ channel = detectInstallChannelHook(pluginRoot);
157
+ } catch { /* best-effort — fall through to generic guidance */ }
158
+
159
+ if (channel === 'npm-global') {
160
+ // v4.1.3: only `memesh update` actually applies the upgrade.
161
+ // The autoUpdate config field is recognised (and the policy
162
+ // resolution + decision matrix run) but the actual spawn is
163
+ // deferred to the v4.1.4 Stop hook, so suggesting users set
164
+ // autoUpdate would point them at a remediation that doesn't
165
+ // yet act. Restore the autoUpdate suggestion in v4.1.4 once
166
+ // the spawn lands.
167
+ lines.push(
168
+ knownUpgradeTarget
169
+ ? ` Run: memesh update`
170
+ : ` Run: memesh update (resolves @latest — works even if no upgrade target is cached yet)`,
171
+ );
172
+ } else if (channel === 'source-checkout') {
173
+ lines.push(` Source checkout: pull and rebuild (\`git pull && npm install && npm run build\`).`);
174
+ } else if (channel === 'npm-local') {
175
+ // Codex round 30: the cached `latestVersion` may itself be
176
+ // stale (cache TTL is 24h and we're already showing a stale
177
+ // banner). Pinning a specific version risks installing an
178
+ // already-superseded build that's part of the same security
179
+ // advisory. `@latest` always resolves to the registry's
180
+ // current dist-tag at install time, which is the right
181
+ // remediation for a deprecation/security-advisory banner.
182
+ lines.push(
183
+ knownUpgradeTarget
184
+ ? ` Project-local install: run \`npm install @pcircle/memesh@latest\` in this project (cached upgrade target was ${cache.latestVersion}).`
185
+ : ` Project-local install: run \`npm install @pcircle/memesh@latest\` in this project.`,
186
+ );
187
+ } else {
188
+ lines.push(
189
+ knownUpgradeTarget
190
+ ? ` Upgrade via the install path you used: fetch the latest @pcircle/memesh from npm (cached upgrade target was ${cache.latestVersion}).`
191
+ : ` Upgrade via the install path you used: fetch the latest @pcircle/memesh from npm.`,
192
+ );
193
+ }
194
+ return lines;
195
+ }
196
+
197
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].+)?$/;
198
+
199
+ function classifyBumpHook(from, to) {
200
+ const a = SEMVER_RE.exec((from || '').trim());
201
+ const b = SEMVER_RE.exec((to || '').trim());
202
+ if (!a || !b) return null;
203
+ const ai = a.slice(1, 4).map(Number);
204
+ const bi = b.slice(1, 4).map(Number);
205
+ if (bi[0] > ai[0]) return 'major';
206
+ if (bi[0] < ai[0]) return null;
207
+ if (bi[1] > ai[1]) return 'minor';
208
+ if (bi[1] < ai[1]) return null;
209
+ if (bi[2] > ai[2]) return 'patch';
210
+ return null;
211
+ }
212
+
213
+ const POLICY_RANK = { off: 0, patch: 1, minor: 2, major: 3 };
214
+ const BUMP_RANK = { patch: 1, minor: 2, major: 3 };
215
+
216
+ // Cache must be no older than 24 hours for auto-update to act on
217
+ // it. A stale cache could point at a target that is already
218
+ // superseded on npm — installing it would leave the user one step
219
+ // behind. When the cache is too old we skip the install and let the
220
+ // background refresh fetch fresh data for the next session.
221
+ const AUTO_UPDATE_CACHE_FRESHNESS_MS = 24 * 60 * 60 * 1000;
222
+
223
+ function decideAutoUpdateHook(currentVersion, cache, policy) {
224
+ if (!cache || cache.currentVersion !== currentVersion) return { run: false };
225
+ const latest = cache.latestVersion;
226
+ if (typeof latest !== 'string' || !latest) return { run: false };
227
+ const bump = classifyBumpHook(currentVersion, latest);
228
+ if (!bump) return { run: false };
229
+
230
+ // Stale-cache guard: refuse to auto-install a target that may
231
+ // have been superseded on npm since the last successful check.
232
+ const lastSuccessAt = cache.lastSuccessfulCheckAt;
233
+ const lastSuccessMs = typeof lastSuccessAt === 'string' ? Date.parse(lastSuccessAt) : NaN;
234
+ const cacheAgeMs = Number.isFinite(lastSuccessMs) ? Date.now() - lastSuccessMs : Infinity;
235
+ if (cacheAgeMs > AUTO_UPDATE_CACHE_FRESHNESS_MS) {
236
+ return { run: false, reason: 'stale-cache' };
237
+ }
238
+
239
+ const policyAllows = (POLICY_RANK[policy] ?? 0) >= BUMP_RANK[bump];
240
+ if (policyAllows) return { run: true, latest, bump, deprecationOverride: false };
241
+
242
+ // Deprecation security override: even with policy 'off', force a
243
+ // patch upgrade out of a deprecated version. Don't override beyond
244
+ // patch — minor / major can carry behaviour changes the user didn't
245
+ // agree to.
246
+ const deprecated = typeof cache.currentVersionDeprecation === 'string'
247
+ && cache.currentVersionDeprecation.length > 0;
248
+ if (deprecated && bump === 'patch') {
249
+ return { run: true, latest, bump, deprecationOverride: true };
250
+ }
251
+
252
+ return { run: false };
253
+ }
254
+
255
+ /**
256
+ * Append a one-line outcome to the auto-update audit log. Best-effort.
257
+ */
258
+ function logAutoUpdate(line) {
259
+ try {
260
+ const dir = getMemeshDir(process.env);
261
+ ensurePrivateDir(dir);
262
+ const path = join(dir, 'auto-update.log');
263
+ appendFileSync(path, `[${new Date().toISOString()}] ${line}\n`);
264
+ try { chmodSync(path, 0o600); } catch { /* non-POSIX */ }
265
+ } catch {
266
+ // Logging is best-effort.
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Detect the install channel of the running memesh binary by
272
+ * delegating to src/core/install-channel.ts via the dist build. The
273
+ * core helper resolves `npm root -g` so it correctly classifies:
274
+ * - POSIX globals at the default prefix (`/usr/local/lib/...`)
275
+ * - Windows globals (`%AppData%\npm\...`)
276
+ * - Globals at custom prefixes set via `npm config set prefix`
277
+ * - Project-local deps under any directory name (no false-positive
278
+ * 'npm-global' from a path that merely contains `lib`)
279
+ *
280
+ * Earlier hook-side regex heuristics agreed with the core logic on
281
+ * the common cases but disagreed on custom prefixes (false negative
282
+ * → auto-update silently broken) and on project-local deps living
283
+ * under `lib/node_modules/...` (false positive → spawning a global
284
+ * `npm install -g` while the active copy is the local one). Using
285
+ * the dist module here keeps the hook in lockstep with whatever
286
+ * `memesh status` says, paying the one-time `npm root -g` cost
287
+ * (~50-200ms) only on auto-update decision.
288
+ *
289
+ * Returns 'npm-global' | 'npm-local' | 'source-checkout' | 'unknown'.
290
+ * Synchronous + best-effort: any failure in the dist import or the
291
+ * underlying `npm root -g` call returns 'unknown', and the auto-
292
+ * update spawn refuses to fire on 'unknown' so we never run
293
+ * `npm install -g` when we can't confirm it would land where the
294
+ * user expects.
295
+ */
296
+ function detectInstallChannelHook(pluginRoot) {
297
+ if (!_installChannelMod) return 'unknown';
298
+ try {
299
+ return _installChannelMod.getCurrentInstallChannel({ packageRoot: pluginRoot });
300
+ } catch {
301
+ return 'unknown';
302
+ }
303
+ }
304
+
305
+ // Cross-process lock window. Two parallel Claude sessions starting
306
+ // in the same minute would otherwise both decide to auto-update from
307
+ // the same cache and each fire `npm install -g` — wasted work at
308
+ // best, install corruption at worst on slow networks. The lock
309
+ // holds for the upper-bound of an `npm install -g` (most finish in
310
+ // under 60s; we allow 10 min as a safety floor) and is reclaimed
311
+ // when stale.
312
+ const AUTO_UPDATE_LOCK_TTL_MS = 10 * 60 * 1000;
313
+
314
+ function tryAcquireAutoUpdateLock(version) {
315
+ try {
316
+ // Lock is MACHINE-GLOBAL because the npm install -g target is
317
+ // machine-global. Always use ~/.memesh/auto-update.lock,
318
+ // independent of MEMESH_DB_PATH — two sessions with different
319
+ // DB paths still serialize against the same global install.
320
+ const dir = join(homedir(), '.memesh');
321
+ ensurePrivateDir(dir);
322
+ const lockPath = join(dir, 'auto-update.lock');
323
+ const fs = require('fs');
324
+ const myToken = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
325
+ const payload = `${myToken}\n${process.pid}\n${Date.now()}\n${version}\n`;
326
+ // Fast path: O_EXCL atomic create. If we win, we own the lock.
327
+ try {
328
+ const fd = fs.openSync(lockPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
329
+ try { fs.writeFileSync(fd, payload); } finally { fs.closeSync(fd); }
330
+ return { acquired: true, lockPath };
331
+ } catch (err) {
332
+ if (err?.code !== 'EEXIST') throw err;
333
+ }
334
+ // Lock exists — check staleness.
335
+ let stat;
336
+ try { stat = fs.statSync(lockPath); } catch { return { acquired: false, lockPath }; }
337
+ if (Date.now() - stat.mtimeMs <= AUTO_UPDATE_LOCK_TTL_MS) {
338
+ return { acquired: false, lockPath };
339
+ }
340
+ // Stale-recovery: write our payload to a temp file, unlink the
341
+ // existing stale lock, then rename. unlink-before-rename keeps
342
+ // this Windows-safe (Windows fs.rename can fail when the
343
+ // destination exists, especially if another process briefly
344
+ // holds it open). On POSIX the unlink+rename pair is no slower
345
+ // than rename-replace. After the rename, read the lock back: if
346
+ // it carries OUR token, we won; if a peer's rename came after
347
+ // ours, we lost cleanly. No double-spawn on either platform.
348
+ const tempPath = `${lockPath}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
349
+ try {
350
+ fs.writeFileSync(tempPath, payload, { mode: 0o600 });
351
+ try { fs.unlinkSync(lockPath); } catch (err) {
352
+ // ENOENT (a peer already reclaimed) is fine; anything else
353
+ // means we can't replace the lock cleanly.
354
+ if (err?.code !== 'ENOENT') {
355
+ try { fs.unlinkSync(tempPath); } catch { /* best-effort */ }
356
+ return { acquired: false, lockPath };
357
+ }
358
+ }
359
+ fs.renameSync(tempPath, lockPath);
360
+ } catch {
361
+ try { fs.unlinkSync(tempPath); } catch { /* best-effort */ }
362
+ return { acquired: false, lockPath };
363
+ }
364
+ let recorded;
365
+ try { recorded = fs.readFileSync(lockPath, 'utf8'); } catch { return { acquired: false, lockPath }; }
366
+ const recordedToken = recorded.split('\n')[0];
367
+ if (recordedToken === myToken) {
368
+ return { acquired: true, lockPath };
369
+ }
370
+ return { acquired: false, lockPath };
371
+ } catch {
372
+ return { acquired: false, lockPath: null };
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Spawn `npm install -g @pcircle/memesh@<version>` detached so the
378
+ * upgrade can finish after this hook returns. We never block the
379
+ * session on the install — the running process keeps its current
380
+ * binary; the next session picks up the new one. Argv is an array
381
+ * (no shell interpolation), so the version string can never escape
382
+ * into a shell command.
383
+ *
384
+ * Two safety gates:
385
+ * 1. Install channel must be `npm-global`. Source checkouts and
386
+ * project-local installs would silently create a separate
387
+ * global install while the active copy stayed unchanged.
388
+ * 2. A filesystem lock at <memeshDir>/auto-update.lock serialises
389
+ * across parallel session-start processes. Without this, two
390
+ * Claude sessions starting at the same minute would each fire
391
+ * `npm install -g` concurrently.
392
+ */
393
+ /**
394
+ * Return shape:
395
+ * { state: 'spawned' } — we own the lock and spawned npm
396
+ * { state: 'in-progress' } — another session owns the lock; an
397
+ * auto-update is racing somewhere
398
+ * { state: 'channel' } — install channel doesn't support
399
+ * self-update; safe to refresh
400
+ * { state: 'failed' } — error before/during spawn
401
+ * Callers use 'in-progress' the same way they use 'spawned': skip
402
+ * the cache refresh, since dist/* is being rewritten by some
403
+ * process. Only 'channel' / no-decision is a clean idle state.
404
+ */
405
+ function spawnAutoUpdate(version, deprecationOverride) {
406
+ let lock = null;
407
+ try {
408
+ const pluginRoot = resolvePluginRoot(import.meta.url);
409
+ const channel = detectInstallChannelHook(pluginRoot);
410
+ if (channel !== 'npm-global') {
411
+ logAutoUpdate(
412
+ `auto-update SKIPPED: install channel '${channel}' does not support self-update via npm install -g`
413
+ );
414
+ return { state: 'channel' };
415
+ }
416
+ lock = tryAcquireAutoUpdateLock(version);
417
+ if (!lock.acquired) {
418
+ logAutoUpdate(
419
+ `auto-update SKIPPED: another session-start already holds ${lock.lockPath ?? 'auto-update.lock'} for this upgrade`
420
+ );
421
+ return { state: 'in-progress' };
422
+ }
423
+ const dir = getMemeshDir(process.env);
424
+ ensurePrivateDir(dir);
425
+ const logPath = join(dir, 'auto-update.log');
426
+ let fd = -1;
427
+ try { fd = openSync(logPath, 'a', 0o600); } catch { fd = -1; }
428
+ const stdio = fd >= 0 ? ['ignore', fd, fd] : 'ignore';
429
+ const child = spawn(
430
+ 'npm',
431
+ ['install', '-g', `@pcircle/memesh@${version}`],
432
+ // windowsHide avoids a flashing console window every time the
433
+ // hook spawns the upgrade on Windows; harmless on POSIX.
434
+ { detached: true, stdio, env: process.env, windowsHide: true },
435
+ );
436
+ child.unref();
437
+ if (fd >= 0) {
438
+ try { closeSync(fd); } catch { /* ignore */ }
439
+ }
440
+ logAutoUpdate(
441
+ `auto-update spawn: target=${version}${deprecationOverride ? ' (deprecation-override)' : ''} pid=${child.pid ?? 'unknown'} lock=${lock.lockPath}`
442
+ );
443
+ return { state: 'spawned' };
444
+ } catch (err) {
445
+ logAutoUpdate(`auto-update spawn FAILED: ${err?.message ?? err}`);
446
+ // Release the lock so the next session can retry. Without this,
447
+ // a transient PATH/permission failure would freeze auto-update
448
+ // for the full 10-minute TTL even after the user fixes the
449
+ // root cause.
450
+ if (lock?.acquired && lock.lockPath) {
451
+ try { require('fs').unlinkSync(lock.lockPath); } catch { /* best-effort */ }
452
+ }
453
+ return { state: 'failed' };
454
+ }
455
+ }
456
+
457
+ // Don't fire a fresh-check more often than this. Two parallel
458
+ // session-starts both spawning `memesh status` could otherwise race
459
+ // the cache: a later writer that hits a deprecation-only timeout
460
+ // would overwrite an earlier writer's successful deprecation flag,
461
+ // because each child reads `previous` from the cache *before* its
462
+ // own npm call. The TTL bounds concurrency to one refresh per
463
+ // window per machine, which is enough for the staleness window
464
+ // (24h) to stay accurate.
465
+ const FRESH_CHECK_THROTTLE_MS = 5 * 60 * 1000;
466
+
467
+ function spawnFreshUpdateCheck(installedVersion) {
468
+ try {
469
+ const pluginRoot = resolvePluginRoot(import.meta.url);
470
+ const cliPath = join(pluginRoot, 'dist/transports/cli/cli.js');
471
+ if (!existsSync(cliPath)) return false;
472
+ const fs = require('fs');
473
+ const dir = join(homedir(), '.memesh');
474
+ try { ensurePrivateDir(dir); } catch { /* best-effort */ }
475
+ // Codex round 37: scope the throttle marker to the installed
476
+ // version. The marker was machine-global, so a refresh started
477
+ // by a global 4.1.3 install would suppress refreshes for a
478
+ // sibling project-local 4.1.1 for the next 5 minutes — and the
479
+ // shared cache it wrote would carry version 4.1.3, so the
480
+ // 4.1.1 session would skip its banner because
481
+ // `cache.currentVersion !== currentVersion`. Per-version
482
+ // markers ensure each install gets its own refresh window.
483
+ // Sanitize version for filesystem (semver chars only, no path
484
+ // separators); fall back to 'unknown' if missing.
485
+ const versionTag = typeof installedVersion === 'string'
486
+ && /^[0-9A-Za-z.+-]+$/.test(installedVersion)
487
+ ? installedVersion
488
+ : 'unknown';
489
+ const markerPath = join(dir, `last-fresh-refresh.${versionTag}.lock`);
490
+ // Single-owner claim: O_EXCL atomic create. Codex round 27
491
+ // caught that the previous temp+rename+readback pattern was
492
+ // racy — both peers' renames are destructive, so each could
493
+ // read its own token back and both would spawn a refresh.
494
+ // O_EXCL is the standard POSIX/libuv primitive that lets at
495
+ // most one process succeed. Same pattern as
496
+ // tryAcquireAutoUpdateLock above.
497
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
498
+ const claim = () => {
499
+ try {
500
+ const fd = fs.openSync(
501
+ markerPath,
502
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
503
+ 0o600,
504
+ );
505
+ try { fs.writeFileSync(fd, token); } finally { fs.closeSync(fd); }
506
+ return 'won';
507
+ } catch (err) {
508
+ if (err?.code === 'EEXIST') return 'exists';
509
+ return 'error';
510
+ }
511
+ };
512
+ let result = claim();
513
+ if (result === 'exists') {
514
+ // Marker already there. Honor the throttle window: if it's
515
+ // fresh, a peer owns this slot. If it's stale, take it over
516
+ // by removing the marker and retrying ONCE — best-effort,
517
+ // multiple processes may race the unlink but only one can
518
+ // win the subsequent O_EXCL.
519
+ let stat;
520
+ try { stat = fs.statSync(markerPath); } catch { return false; }
521
+ if (Date.now() - stat.mtimeMs < FRESH_CHECK_THROTTLE_MS) {
522
+ return false;
523
+ }
524
+ try { fs.unlinkSync(markerPath); } catch { /* peer already removed */ }
525
+ result = claim();
526
+ }
527
+ if (result !== 'won') return false;
528
+ const child = spawn(
529
+ process.execPath,
530
+ [cliPath, 'status'],
531
+ // windowsHide prevents a console-window flash on every session
532
+ // start on Windows; harmless on POSIX.
533
+ {
534
+ detached: true,
535
+ stdio: 'ignore',
536
+ env: { ...process.env, MEMESH_UPDATE_REFRESH: '1' },
537
+ windowsHide: true,
538
+ },
539
+ );
540
+ child.unref();
541
+ return true;
542
+ } catch {
543
+ return false;
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Run the post-banner update tasks: spawn auto-update if policy + cache
549
+ * permit, and always refresh the cache for the next session.
550
+ *
551
+ * Known one-session delay (documented):
552
+ * The very first session after install (or after the cache file is
553
+ * deleted) emits the recall summary BEFORE this finally clause
554
+ * runs, so a freshly-installed deprecated version sees no
555
+ * deprecation banner on session 1. The detached refresh below
556
+ * populates the cache for session 2, where the banner and the
557
+ * security override fire normally. We deliberately don't do an
558
+ * inline synchronous npm fetch here because that would block every
559
+ * cold-cache session-start by ~3s for users whose installed version
560
+ * is healthy — a worse trade for the common case.
561
+ *
562
+ * Idempotent guard: callers should not invoke twice for the same
563
+ * session — duplicated `npm install -g` spawns would race. We use a
564
+ * one-shot flag rather than a no-op-on-second-call lock so a coding
565
+ * mistake produces visible breakage during testing instead of silent
566
+ * over-spawning.
567
+ */
568
+ let __postBannerRan = false;
569
+ function runPostBannerUpdateTasks() {
570
+ if (__postBannerRan) return;
571
+ __postBannerRan = true;
572
+ try {
573
+ let installedVersion = null;
574
+ try {
575
+ const pluginRoot = resolvePluginRoot(import.meta.url);
576
+ const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
577
+ installedVersion = typeof pkg.version === 'string' ? pkg.version : null;
578
+ } catch { /* best-effort */ }
579
+ if (!installedVersion) return;
580
+ const cache = readUpdateCheckCache(installedVersion);
581
+ const policy = resolveAutoUpdatePolicy(process.env);
582
+ const decision = decideAutoUpdateHook(installedVersion, cache, policy);
583
+ if (decision.run) {
584
+ // v4.1.3: log a PENDING entry instead of spawning npm install
585
+ // -g, but only when the install channel actually supports
586
+ // self-update. For source-checkout / npm-local installs the
587
+ // PENDING line would point at remediation that will never
588
+ // apply (`memesh update` refuses those channels). Skip
589
+ // silently for those — the deprecation banner already gives
590
+ // them the channel-appropriate hint (git pull, project
591
+ // npm install).
592
+ try {
593
+ const pluginRoot = resolvePluginRoot(import.meta.url);
594
+ const channel = detectInstallChannelHook(pluginRoot);
595
+ if (channel === 'npm-global') {
596
+ logAutoUpdate(
597
+ `auto-update PENDING: policy='${policy}' bump='${decision.bump}' target=${decision.latest}` +
598
+ (decision.deprecationOverride ? ' (deprecation-override)' : '') +
599
+ ' — will run from Stop hook in v4.1.4. For now run `memesh update` manually.'
600
+ );
601
+ }
602
+ } catch {
603
+ // Best-effort: if channel detection fails, skip the log.
604
+ }
605
+ }
606
+ spawnFreshUpdateCheck(installedVersion);
607
+ } catch {
608
+ // Best-effort — never crash the hook on a network or fs hiccup.
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Build a "base message + optional deprecation banner" combined
614
+ * single-line systemMessage payload. Keeps stdout a single JSON
615
+ * object on every empty/no-DB exit path so Claude Code's hook
616
+ * contract holds.
617
+ */
618
+ function combineWithBanner(baseMessage) {
619
+ let lines = [];
620
+ try {
621
+ const pluginRoot = resolvePluginRoot(import.meta.url);
622
+ const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
623
+ const installedVersion = typeof pkg.version === 'string' ? pkg.version : null;
624
+ const cache = readUpdateCheckCache(installedVersion);
625
+ lines = installedVersion ? buildDeprecationBanner(installedVersion, cache) : [];
626
+ } catch {
627
+ // Best-effort — fall through to base message only.
628
+ }
629
+ if (lines.length === 0) return baseMessage;
630
+ return [...lines.filter((l) => l.length > 0), '', baseMessage].join('\n');
631
+ }
632
+
26
633
  let input = '';
27
634
  process.stdin.setEncoding('utf8');
28
635
  process.stdin.on('data', (chunk) => { input += chunk; });
29
636
  process.stdin.on('end', async () => {
30
637
  try {
638
+ try {
31
639
  const data = JSON.parse(input);
32
640
  const projectName = basename(data.cwd || process.cwd());
33
641
 
@@ -48,7 +656,10 @@ process.stdin.on('end', async () => {
48
656
  }
49
657
 
50
658
  if (!existsSync(dbPath)) {
51
- output('MeMesh: No database found. Memories will be created as you work.');
659
+ // Combine deprecation banner (if any) into the same
660
+ // systemMessage so stdout stays a single JSON document. Outer
661
+ // finally runs runPostBannerUpdateTasks().
662
+ output(combineWithBanner('MeMesh: No database found. Memories will be created as you work.'));
52
663
  return;
53
664
  }
54
665
 
@@ -62,7 +673,7 @@ process.stdin.on('end', async () => {
62
673
  "SELECT name FROM sqlite_master WHERE type='table' AND name='entities'"
63
674
  ).get();
64
675
  if (!tableCheck) {
65
- output('MeMesh: Database exists but no memories stored yet.');
676
+ output(combineWithBanner('MeMesh: Database exists but no memories stored yet.'));
66
677
  return;
67
678
  }
68
679
 
@@ -157,8 +768,15 @@ process.stdin.on('end', async () => {
157
768
  }
158
769
  }
159
770
 
160
- // No memories at all — output nothing (don't clutter session)
771
+ // No memories at all — surface only the deprecation banner if
772
+ // active (so a flagged install still warns the user) and skip
773
+ // the rest of the recall-summary work. The outer finally still
774
+ // runs runPostBannerUpdateTasks().
161
775
  if (lines.length === 0) {
776
+ const bannerOnly = combineWithBanner('');
777
+ if (bannerOnly && bannerOnly.trim().length > 0) {
778
+ output(bannerOnly.trim());
779
+ }
162
780
  return;
163
781
  }
164
782
 
@@ -294,11 +912,32 @@ process.stdin.on('end', async () => {
294
912
  // Non-critical — don't break session start
295
913
  }
296
914
 
915
+ // Deprecation-aware banner. Reads the cache produced by the
916
+ // last `getUpdateCheck` (CLI or background refresh). When the
917
+ // installed version was flagged by maintainers (typically a
918
+ // security advisory), prepend a strong warning so the user sees
919
+ // it on every session start until they upgrade.
920
+ let installedVersion = null;
921
+ try {
922
+ const pluginRoot = resolvePluginRoot(import.meta.url);
923
+ const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
924
+ installedVersion = typeof pkg.version === 'string' ? pkg.version : null;
925
+ } catch {
926
+ // Best-effort — without the version we can't compare to cache.
927
+ }
928
+ const updateCache = readUpdateCheckCache(installedVersion);
929
+ const deprecationLines = installedVersion
930
+ ? buildDeprecationBanner(installedVersion, updateCache)
931
+ : [];
932
+ const memorySummaryWithBanner = deprecationLines.length > 0
933
+ ? [...deprecationLines, '', ...memorySummary.split('\n')].join('\n')
934
+ : memorySummary;
935
+
297
936
  const hookOutput = {
298
937
  suppressOutput: true,
299
938
  hookSpecificOutput: {
300
939
  hookEventName: 'SessionStart',
301
- additionalContext: buildReferenceContext(memorySummary.split('\n')),
940
+ additionalContext: buildReferenceContext(memorySummaryWithBanner.split('\n')),
302
941
  },
303
942
  };
304
943
  console.log(JSON.stringify(hookOutput));
@@ -323,9 +962,21 @@ process.stdin.on('end', async () => {
323
962
  } catch {
324
963
  // Non-critical — noise compression failed, will retry next session
325
964
  }
326
- } catch (err) {
327
- // Hooks must never crash Claude Code — but report honestly
328
- console.log(JSON.stringify({ systemMessage: `MeMesh: Session start failed (${err?.message || 'unknown error'}). Memories not loaded.` }));
965
+
966
+ } catch (err) {
967
+ // Hooks must never crash Claude Code but report honestly.
968
+ // Inner catch so the outer finally can still run the post-
969
+ // banner update tasks even when the recall flow blew up.
970
+ console.log(JSON.stringify({ systemMessage: `MeMesh: Session start failed (${err?.message || 'unknown error'}). Memories not loaded.` }));
971
+ }
972
+ } finally {
973
+ // ── Auto-update + cache refresh ──────────────────────────────
974
+ // Outer finally guarantees this runs on every exit path — no-DB
975
+ // short-circuit, empty-DB return, no-memories return, recall
976
+ // happy path, or even a thrown error caught above. The
977
+ // function is single-shot per process so the duplicated
978
+ // late-path call from older versions is now idempotent.
979
+ runPostBannerUpdateTasks();
329
980
  }
330
981
  });
331
982