claude-mem-lite 6.25.2 → 6.25.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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.25.2",
12
+ "version": "6.25.3",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.2",
3
+ "version": "6.25.3",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/hook-update.mjs CHANGED
@@ -43,7 +43,11 @@ import {
43
43
  // because checkForUpdate is silent on network failure the plugin then reports
44
44
  // itself permanently up to date. Same tunnel the OpenRouter call site uses.
45
45
  import { httpConnectProxyFor, getViaConnectProxy, redactProxyUrl } from './lib/proxy-fetch.mjs';
46
- import { acquireLock } from './lib/proc-lock.mjs';
46
+ // A namespace, not a named import: a swap renames files one at a time, so for a moment this file can
47
+ // sit over the previous release's lib/proc-lock.mjs, and a named import of an export that release
48
+ // lacks is a SyntaxError that stops this module loading — and with it recoverInterruptedSwaps, which
49
+ // finishes that very swap (pre-ship delta review P2-1). takeLock is newer than v6.25.2: detected.
50
+ import * as procLock from './lib/proc-lock.mjs';
47
51
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
48
52
  import { verifyReleaseFiles, verifyManifestSignature } from './lib/release-digest.mjs';
49
53
  import { detectInstallShape } from './lib/install-shape.mjs';
@@ -262,7 +266,7 @@ function pluginOnlyInstall() {
262
266
  return Boolean(shape && !shape.managed && shape.activePluginVersion);
263
267
  }
264
268
 
265
- function isPluginMode() {
269
+ export function isPluginMode() {
266
270
  return Boolean(process.env.CLAUDE_PLUGIN_ROOT) || pluginOnlyInstall();
267
271
  }
268
272
 
@@ -964,6 +968,30 @@ const BACKUP_SEQ_RE = /^\.update-backup-s(\d+)-/;
964
968
  const swapSeqOf = (name) => Number(BACKUP_SEQ_RE.exec(name)?.[1] ?? 0);
965
969
  const nextSwapSeq = (names) => Math.max(0, ...names.map(swapSeqOf)) + 1;
966
970
 
971
+ /** Unresolved backup dir names in the order recovery undoes them: newest first, by seq, then name.
972
+ * cleanup --dry-run walks the same order to say what recovery would leave (D#307 F3). */
973
+ const unresolvedSwapOrder = (names) =>
974
+ [...names].sort((a, b) => swapSeqOf(b) - swapSeqOf(a) || b.localeCompare(a));
975
+
976
+ /**
977
+ * Which unresolved backup dirs recovery would leave in place because a newer one's journal cannot be
978
+ * read: recovery walks them newest first and stops there (recoverInterruptedSwaps). cleanup --dry-run
979
+ * reports from this instead of judging each dir alone (D#307 F3). It cannot foresee a file that
980
+ * fails to go back, which also stops the walk.
981
+ * @param {{name: string, readable: boolean}[]} swaps
982
+ * @returns {Map<string, string>} name → the newer unreadable dir that blocks it
983
+ */
984
+ export function swapsBlockedByUnreadable(swaps) {
985
+ const readable = new Map(swaps.map((s) => [s.name, s.readable]));
986
+ const blockedBy = new Map();
987
+ let newestUnreadable = null;
988
+ for (const name of unresolvedSwapOrder([...readable.keys()])) {
989
+ if (newestUnreadable) blockedBy.set(name, newestUnreadable);
990
+ else if (!readable.get(name)) newestUnreadable = name;
991
+ }
992
+ return blockedBy;
993
+ }
994
+
967
995
  // The `.update-backup-*` dirs in targetDir that carry no resolved marker.
968
996
  function unresolvedBackups(targetDir) {
969
997
  try {
@@ -1058,8 +1086,7 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
1058
1086
  // they are undone like a stack: newest first by the seq in the name, and none older while a newer
1059
1087
  // one is unfinished or cannot be read. An older journal replayed first puts back a file that a
1060
1088
  // newer journal, which installed that path, then deletes; with nothing left to restore it from.
1061
- pending.sort((a, b) => swapSeqOf(b) - swapSeqOf(a) || b.localeCompare(a));
1062
- for (const name of pending) {
1089
+ for (const name of unresolvedSwapOrder(pending)) {
1063
1090
  const dir = join(targetDir, name);
1064
1091
  // No journal: the swap is over and its journal is gone (or it was killed before its first
1065
1092
  // rename), so there is nothing to replay and the dir is residue. A journal that is there but
@@ -1241,12 +1268,18 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1241
1268
  // holding the lock means an install is already in flight — skip rather than
1242
1269
  // race. Shared path with install.mjs so direct install + repair + auto-update
1243
1270
  // are mutually exclusive.
1244
- const release = acquireLock(join(STATE_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
1271
+ const lockPath = join(STATE_DIR, 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
1272
+ const { release, error: lockError } = procLock.takeLock
1273
+ ? procLock.takeLock(lockPath)
1274
+ : { release: procLock.acquireLock(lockPath) };
1245
1275
  if (!release) {
1276
+ // doctor is the face that names a lock that cannot be taken (D#307); this log is debug-only.
1246
1277
  debugLog(
1247
1278
  'DEBUG',
1248
1279
  'hook-update',
1249
- 'installExtractedRelease: another install/update is in progress — skipping',
1280
+ lockError
1281
+ ? `installExtractedRelease: install.lock cannot be taken — ${lockError.dir} (${lockError.code}) — skipping`
1282
+ : 'installExtractedRelease: another install/update is in progress — skipping',
1250
1283
  );
1251
1284
  return false;
1252
1285
  }
package/install.mjs CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  lstatSync,
19
19
  constants as fsConstants,
20
20
  } from 'fs';
21
- import { join, resolve, dirname, basename, sep } from 'path';
21
+ import { join, resolve, dirname, basename, sep, relative, isAbsolute } from 'path';
22
22
  import { homedir, tmpdir } from 'os';
23
23
  import { fileURLToPath, pathToFileURL } from 'url';
24
24
  import { createRequire } from 'node:module';
@@ -93,11 +93,25 @@ import {
93
93
  ensureBetterSqlite3Working,
94
94
  nativeBindingRepairHint,
95
95
  isNativeBindingError,
96
+ probeBindingInFreshProcess,
97
+ flattenBindingError,
96
98
  } from './lib/binding-probe.mjs';
97
99
  import { detectInstallShape, probeRuntimeRoots, hasAnyManagedCode } from './lib/install-shape.mjs';
98
100
  import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
99
101
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
100
- import { acquireLock, lockHeld } from './lib/proc-lock.mjs';
102
+ // A namespace, not named imports: a swap renames files one at a time, so for a moment this file can
103
+ // sit over the previous release's lib/proc-lock.mjs, and a named import of an export that release
104
+ // lacks is a SyntaxError that stops install.mjs loading — and repair and cleanup are what finish that
105
+ // state (pre-ship delta review P2-1). Exports newer than v6.25.2 are detected.
106
+ import * as procLock from './lib/proc-lock.mjs';
107
+ const { lockHeld } = procLock;
108
+ const lockDirBlocked = procLock.lockDirBlocked ?? (() => null);
109
+ const takeLock =
110
+ procLock.takeLock ??
111
+ ((p) => {
112
+ const release = procLock.acquireLock(p);
113
+ return release ? { release } : {};
114
+ });
101
115
  import { atomicWriteFileSync, atomicCopyFileSync } from './lib/atomic-write.mjs';
102
116
  import { shellWord } from './cli-path.mjs';
103
117
  import { dirAccessError, walOpenBlocked, dataDirRemedy, WAL_BLOCKED_WHY } from './lib/wal-open-blocked.mjs';
@@ -1873,6 +1887,7 @@ async function status() {
1873
1887
  const unwritable = dataDirDenied
1874
1888
  ? ` — ${MEM_DATA_DIR} can be read but not written (${dataDirDenied}). Fix: ${dataDirAccessRemedy(dataDirDenied)}`
1875
1889
  : '';
1890
+ let statusProbe;
1876
1891
  if (dataDirUnreadable) {
1877
1892
  push(
1878
1893
  'fail',
@@ -1880,6 +1895,17 @@ async function status() {
1880
1895
  `Database: ${MEM_DATA_DIR} is not accessible (${dataDirUnreadable}) — Fix: ${dataDirAccessRemedy(dataDirUnreadable)}`,
1881
1896
  { exists: null, error: dataDirUnreadable },
1882
1897
  );
1898
+ } else if (existsSync(DB_PATH) && !(statusProbe = probeBindingInFreshProcess(PROJECT_DIR)).ok) {
1899
+ // Out of process first, as doctor does: the open below loads the addon IN this process, and a
1900
+ // truncated prebuild raises SIGBUS in dlopen rather than throwing, so status died with no
1901
+ // output (D#306). The human line says why it did not look; the machine face keeps the loader's
1902
+ // own error (review P3-10).
1903
+ push(
1904
+ dataDirDenied ? 'fail' : 'warn',
1905
+ 'database',
1906
+ `Database: exists, but not checked — ${BINDING_MISSING_WHY}${unwritable}`,
1907
+ { exists: true, error: flattenBindingError(statusProbe.error) },
1908
+ );
1883
1909
  } else if (existsSync(DB_PATH)) {
1884
1910
  try {
1885
1911
  const Database = (await import('better-sqlite3')).default;
@@ -2173,6 +2199,11 @@ async function doctor() {
2173
2199
  // process.
2174
2200
  const rootProbes = probeRuntimeRoots(shape.runtimeRoots);
2175
2201
  const brokenRoots = rootProbes.filter((r) => !r.ok);
2202
+ // Database and DB stats below open the store IN this process, through the better-sqlite3 the
2203
+ // running CLI's tree resolves. When the out-of-process probe of that tree has already failed,
2204
+ // loading it here is not just pointless: a truncated prebuild raises SIGBUS in dlopen, which
2205
+ // killed doctor (exit 135) before it printed the rest (D#306). They say "not checked" instead.
2206
+ const bindingBrokenHere = rootProbes.some((r) => !r.ok && sameDir(r.root, PROJECT_DIR));
2176
2207
  if (rootProbes.length === 0 && codeDirDenied) {
2177
2208
  // The code home is locked, so it may own a binding nobody could look at (D#284 review P3-4).
2178
2209
  notCheckedDenied('better-sqlite3', INSTALL_DIR);
@@ -2604,6 +2635,8 @@ async function doctor() {
2604
2635
  // Database
2605
2636
  if (dataDirUnreadable) {
2606
2637
  notCheckedDenied('Database');
2638
+ } else if (existsSync(DB_PATH) && bindingBrokenHere) {
2639
+ dwarn(`Database: not checked — ${BINDING_MISSING_WHY}`);
2607
2640
  } else if (existsSync(DB_PATH)) {
2608
2641
  try {
2609
2642
  const Database = (await import('better-sqlite3')).default;
@@ -3028,8 +3061,34 @@ async function doctor() {
3028
3061
  // install.lock is held, cleanup skips update residue (a running update's own staging and backup
3029
3062
  // dirs among it), so doctor does not count it either, and says it did not look (D#297). The holder
3030
3063
  // may be any installer: an update, install, repair, a binding rebuild. It asks without taking it.
3064
+ // A runtime dir install.lock cannot be created in (a sudo run left it root-owned, a read-only data
3065
+ // dir): every update entry skips on it as if a peer held the lock, silently, so this is the only
3066
+ // face that can say why updates stopped (D#307). Counted here unless the Data directory line above
3067
+ // already counts the directory it sits in.
3068
+ const installLockBlocked = lockDirBlocked(INSTALL_LOCK_PATH);
3069
+ if (installLockBlocked) {
3070
+ const line = `Install lock: ${lockBlockedWhy(installLockBlocked)} — every update, install, repair and binding rebuild skips until it can be taken`;
3071
+ // The same directory the Data directory line counts (or one above it), or the same read-only
3072
+ // mount: one fault, one ✗. A runtime dir inside a data dir that both need fixing is two.
3073
+ const relToData = relative(MEM_DATA_DIR, installLockBlocked.dir);
3074
+ const sameFault =
3075
+ dataDirDenied &&
3076
+ (relToData === '' ||
3077
+ relToData.startsWith('..') ||
3078
+ isAbsolute(relToData) ||
3079
+ (dataDirDenied === 'EROFS' && installLockBlocked.code === 'EROFS'));
3080
+ if (sameFault) dwarn(line);
3081
+ else {
3082
+ fail(line);
3083
+ issues++;
3084
+ }
3085
+ }
3031
3086
  try {
3032
- const lockHeldNow = lockHeld(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
3087
+ // Held and blocked are separate answers. A lock can be both: a sudo-run installer still running
3088
+ // in the root-owned runtime dir it made holds it there (pre-ship review). Held, the residue may
3089
+ // be that installer's own and is not counted; blocked and not held, an unfinished swap is a real
3090
+ // one and is counted (D#289), and every repair it names waits on the lock line's fix.
3091
+ const lockHeldNow = lockHeld(INSTALL_LOCK_PATH);
3033
3092
  const { stale, inFlight, unfinishedSwaps, unreadableJournals, notChecked } = scanStaleTempFiles({
3034
3093
  codeDir: INSTALL_DIR,
3035
3094
  runtimeDir: MEM_RUNTIME_DIR,
@@ -3037,14 +3096,16 @@ async function doctor() {
3037
3096
  installLockHeld: lockHeldNow,
3038
3097
  });
3039
3098
  const installer = `node ${shellWord(join(PROJECT_DIR, 'install.mjs'))}`;
3099
+ const onceLock =
3100
+ installLockBlocked && !lockHeldNow ? ' once install.lock can be taken (see Install lock)' : '';
3040
3101
  if (unfinishedSwaps > 0) {
3041
3102
  dwarn(
3042
- `Unfinished update: ${unfinishedSwaps} backup dir(s) in ${INSTALL_DIR} still hold a swap journal — an update was interrupted, or could not put back every file it moved aside (run: ${installer} cleanup — it finishes them newest first, and stops at one it cannot read or put back)`,
3103
+ `Unfinished update: ${unfinishedSwaps} backup dir(s) in ${INSTALL_DIR} still hold a swap journal — an update was interrupted, or could not put back every file it moved aside (run: ${installer} cleanup${onceLock} — it finishes them newest first, and stops at one it cannot read or put back)`,
3043
3104
  );
3044
3105
  }
3045
3106
  if (unreadableJournals > 0) {
3046
3107
  dwarn(
3047
- `Unfinished update: ${unreadableJournals} backup dir(s) in ${INSTALL_DIR} whose journal cannot be read, so nothing can finish that update; they hold the files it moved aside (run: ${installer} repair — it reinstalls over them; cleanup then removes them)`,
3108
+ `Unfinished update: ${unreadableJournals} backup dir(s) in ${INSTALL_DIR} whose journal cannot be read, so nothing can finish that update; they hold the files it moved aside (run: ${installer} repair${onceLock} — it reinstalls over them; cleanup then removes them)`,
3048
3109
  );
3049
3110
  }
3050
3111
  const skipped = lockHeldNow
@@ -3054,11 +3115,13 @@ async function doctor() {
3054
3115
  const where = notChecked.map((n) => `${n.dir} is not accessible (${n.code})`).join('; ');
3055
3116
  dwarn(
3056
3117
  `Stale temp files: not checked — ${where}` +
3057
- (stale > 0 ? `; ${stale} found elsewhere (run: ${installer} cleanup)` : '') +
3118
+ (stale > 0 ? `; ${stale} found elsewhere (run: ${installer} cleanup${onceLock})` : '') +
3058
3119
  (skipped ? `; ${skipped}` : ''),
3059
3120
  );
3060
3121
  } else if (stale > 0) {
3061
- dwarn(`Stale temp files: ${stale} found (run: ${installer} cleanup)${skipped ? `; ${skipped}` : ''}`);
3122
+ dwarn(
3123
+ `Stale temp files: ${stale} found (run: ${installer} cleanup${onceLock})${skipped ? `; ${skipped}` : ''}`,
3124
+ );
3062
3125
  } else if (skipped) {
3063
3126
  dwarn(`Stale temp files: ${skipped}`);
3064
3127
  } else {
@@ -3081,7 +3144,9 @@ async function doctor() {
3081
3144
  }
3082
3145
 
3083
3146
  // DB stats
3084
- if (existsSync(DB_PATH)) {
3147
+ if (existsSync(DB_PATH) && bindingBrokenHere) {
3148
+ dwarn(`DB stats: not checked — ${BINDING_MISSING_WHY}`);
3149
+ } else if (existsSync(DB_PATH)) {
3085
3150
  try {
3086
3151
  const dbSize = statSync(DB_PATH).size;
3087
3152
  const sizeMB = (dbSize / 1024 / 1024).toFixed(1);
@@ -3504,10 +3569,23 @@ async function cleanup() {
3504
3569
  let finished = 0;
3505
3570
  // --dry-run writes nothing, so it asks whether the lock is held instead of taking it: taking it
3506
3571
  // created the data dir, its runtime dir and the lock on a machine with nothing installed (D#297).
3507
- const lockPath = join(MEM_DATA_DIR, 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
3508
- const updateLock = dryRun ? (lockHeld(lockPath) ? null : () => {}) : acquireLock(lockPath);
3572
+ // A lock that cannot be taken at all is not one a peer holds (D#304): both runs name its directory.
3573
+ // The real run takes the answer from its own failed create (any errno); the dry run, which must not
3574
+ // create anything, predicts it from the directory (lockDirBlocked).
3575
+ let updateLock = null;
3576
+ let lockBlocked;
3577
+ if (dryRun) {
3578
+ lockBlocked = lockDirBlocked(INSTALL_LOCK_PATH);
3579
+ if (!lockBlocked && !lockHeld(INSTALL_LOCK_PATH)) updateLock = () => {};
3580
+ } else {
3581
+ const taken = takeLock(INSTALL_LOCK_PATH);
3582
+ updateLock = taken.release || null;
3583
+ lockBlocked = taken.error || null;
3584
+ }
3509
3585
  if (!updateLock) {
3510
- warn('Update residue skipped: install in progress (install.lock held)');
3586
+ warn(
3587
+ `Update residue skipped: ${lockBlocked ? lockBlockedWhy(lockBlocked) : 'install in progress (install.lock held)'}`,
3588
+ );
3511
3589
  } else {
3512
3590
  try {
3513
3591
  let found = [];
@@ -3517,8 +3595,29 @@ async function cleanup() {
3517
3595
  warn(`Update residue: could not list ${INSTALL_DIR} (${e.code || e.message})`);
3518
3596
  }
3519
3597
  if (dryRun) {
3598
+ // Recovery undoes unfinished swaps like a stack, newest first, and stops at the first whose
3599
+ // journal cannot be read, leaving it and every older one (D#293). Judged one dir at a time,
3600
+ // the dry run promised to finish an older swap the real run leaves (D#307 F3), so it walks
3601
+ // them in recovery's own order, taken from hook-update rather than restated here.
3602
+ const swaps = found
3603
+ .filter((r) => r.kind === 'unfinished-swap' || r.kind === 'unreadable-journal')
3604
+ .map((r) => ({ name: r.f, readable: r.kind === 'unfinished-swap' }));
3605
+ let blockedBy = new Map();
3606
+ let cannotLoad = null;
3607
+ if (swaps.some((s) => s.readable)) {
3608
+ try {
3609
+ const { swapsBlockedByUnreadable } = await import('./hook-update.mjs');
3610
+ blockedBy = swapsBlockedByUnreadable(swaps);
3611
+ } catch (e) {
3612
+ cannotLoad = e.message;
3613
+ }
3614
+ }
3520
3615
  for (const { f, kind } of found) {
3521
- if (kind === 'unfinished-swap') {
3616
+ if (kind === 'unfinished-swap' && cannotLoad) {
3617
+ warn(`Would leave in place: ${f} (the updater that finishes it cannot load: ${cannotLoad})`);
3618
+ } else if (kind === 'unfinished-swap' && blockedBy.has(f)) {
3619
+ warn(`Would leave in place: ${f} (a newer one, ${blockedBy.get(f)}, cannot be finished first)`);
3620
+ } else if (kind === 'unfinished-swap') {
3522
3621
  ok(`Would finish an interrupted update: ${f} (puts back the files its journal lists)`);
3523
3622
  finished++;
3524
3623
  } else if (kind === 'unreadable-journal') {
@@ -3547,21 +3646,29 @@ async function cleanup() {
3547
3646
  }
3548
3647
  // A relocated data dir that an updater older than v2.90.0 swapped into: its residue is stale,
3549
3648
  // whatever it holds, and is never replayed (lib/doctor-stale-temp.mjs, legacyDir).
3550
- if (!sameDir(INSTALL_DIR, MEM_DATA_DIR) && existsSync(MEM_DATA_DIR)) {
3551
- for (const f of readdirSync(MEM_DATA_DIR)) {
3552
- if (!isUpdateResidue(f)) continue;
3553
- if (dryRun) {
3554
- ok(`Would remove: ${join(MEM_DATA_DIR, f)}`);
3555
- removed++;
3556
- continue;
3557
- }
3558
- try {
3559
- rmSync(join(MEM_DATA_DIR, f), { recursive: true, force: true });
3560
- ok(`Removed: ${join(MEM_DATA_DIR, f)}`);
3561
- removed++;
3562
- } catch (e) {
3563
- warn(`Failed to remove ${join(MEM_DATA_DIR, f)}: ${e.message}`);
3564
- }
3649
+ let legacyEntries = [];
3650
+ if (!sameDir(INSTALL_DIR, MEM_DATA_DIR)) {
3651
+ try {
3652
+ legacyEntries = readdirSync(MEM_DATA_DIR);
3653
+ } catch (e) {
3654
+ // Absent is nothing to clean; one it can enter but not list threw out of cleanup (review P3-6).
3655
+ if (e.code !== 'ENOENT')
3656
+ warn(`Update residue: could not list ${MEM_DATA_DIR} (${e.code || e.message})`);
3657
+ }
3658
+ }
3659
+ for (const f of legacyEntries) {
3660
+ if (!isUpdateResidue(f)) continue;
3661
+ if (dryRun) {
3662
+ ok(`Would remove: ${join(MEM_DATA_DIR, f)}`);
3663
+ removed++;
3664
+ continue;
3665
+ }
3666
+ try {
3667
+ rmSync(join(MEM_DATA_DIR, f), { recursive: true, force: true });
3668
+ ok(`Removed: ${join(MEM_DATA_DIR, f)}`);
3669
+ removed++;
3670
+ } catch (e) {
3671
+ warn(`Failed to remove ${join(MEM_DATA_DIR, f)}: ${e.message}`);
3565
3672
  }
3566
3673
  }
3567
3674
  } finally {
@@ -3582,10 +3689,18 @@ async function cleanup() {
3582
3689
  // fixture sweep below, whose comment already states the principle: a MANUAL cleanup is
3583
3690
  // the conservative one.
3584
3691
  const runtimeDir = MEM_RUNTIME_DIR;
3585
- if (existsSync(runtimeDir)) {
3692
+ let runtimeEntries = [];
3693
+ try {
3694
+ runtimeEntries = readdirSync(runtimeDir);
3695
+ } catch (e) {
3696
+ // Absent is nothing to clean. A runtime dir that cannot be listed (a sudo run left it 0700 and
3697
+ // root-owned) threw out of cleanup uncaught, dry run included (D#304).
3698
+ if (e.code !== 'ENOENT') warn(`Runtime scratch: could not list ${runtimeDir} (${e.code || e.message})`);
3699
+ }
3700
+ if (runtimeEntries.length > 0) {
3586
3701
  const now = Date.now();
3587
3702
  let inFlight = 0;
3588
- for (const f of readdirSync(runtimeDir)) {
3703
+ for (const f of runtimeEntries) {
3589
3704
  if (isEpisodeResidue(f)) {
3590
3705
  // The gate itself lives in lib/doctor-stale-temp.mjs, so doctor's count and this
3591
3706
  // deletion cannot disagree about which files are in flight (D#53).
@@ -3625,6 +3740,10 @@ async function cleanup() {
3625
3740
  if (removed > 0) done.push(`${removed} stale file(s) ${verb}`);
3626
3741
  if (finished > 0)
3627
3742
  done.push(`${finished} interrupted update(s) ${dryRun ? 'would be finished' : 'finished'}`);
3743
+ // Skipped update residue is not "nothing found" (review P3-4). Held, a peer is working on it; a
3744
+ // lock that cannot be taken is a failure no wait clears, so the exit says so, as install's does.
3745
+ if (!updateLock) done.push('update residue not checked (see above)');
3746
+ if (lockBlocked) process.exitCode = 1;
3628
3747
  console.log(`\n ${done.length === 0 ? 'No stale files found.' : `${done.join('; ')}.`}\n`);
3629
3748
  }
3630
3749
 
@@ -3635,7 +3754,8 @@ async function manualUpdate() {
3635
3754
  console.log('\nclaude-mem-lite self-update\n');
3636
3755
 
3637
3756
  // Force check by importing hook-update (bypasses throttle for manual use)
3638
- const { checkForUpdate, getCurrentVersion, updateCheckDisabledReason } = await import('./hook-update.mjs');
3757
+ const { checkForUpdate, getCurrentVersion, updateCheckDisabledReason, isPluginMode } =
3758
+ await import('./hook-update.mjs');
3639
3759
  // checkForUpdate returns null without looking in these two cases, and the branch at the
3640
3760
  // bottom would then report "Already up to date" for a check that never ran. (D#251)
3641
3761
  const disabled = updateCheckDisabledReason();
@@ -3651,6 +3771,16 @@ async function manualUpdate() {
3651
3771
  console.log('');
3652
3772
  return;
3653
3773
  }
3774
+ // An update installs under install.lock, and hook-update reads a lock it cannot take as "an install
3775
+ // is in progress", which this printed as "install failed" (review P3-5).
3776
+ // Plugin mode only checks, it never installs, so the lock is not its business (delta review P3-3).
3777
+ const lockBlocked = isPluginMode?.() ? null : lockDirBlocked(INSTALL_LOCK_PATH);
3778
+ if (lockBlocked) {
3779
+ warn(`Not checked: ${lockBlockedWhy(lockBlocked)}`);
3780
+ process.exitCode = 1;
3781
+ console.log('');
3782
+ return;
3783
+ }
3654
3784
  log('Checking for updates...');
3655
3785
  const result = await checkForUpdate({ force: true, allowInstall: true, reportFailure: true });
3656
3786
 
@@ -3687,6 +3817,15 @@ async function manualUpdate() {
3687
3817
  // buggy on disk.
3688
3818
  async function repair() {
3689
3819
  console.log('\nclaude-mem-lite repair — re-syncing from the latest SIGNED GitHub release\n');
3820
+ // The install this ends in takes install.lock. One that cannot be taken here fails that install
3821
+ // after a whole release was downloaded, and the manual fallback below fails the same way, so say
3822
+ // it first and stop (review P3-5). A peer holding it is left to the install's own message.
3823
+ const lockBlocked = lockDirBlocked(INSTALL_LOCK_PATH);
3824
+ if (lockBlocked) {
3825
+ fail(`Repair not started: ${lockBlockedWhy(lockBlocked)}`);
3826
+ process.exitCode = 1;
3827
+ return;
3828
+ }
3690
3829
  const stagingDir = mkdtempSync(join(tmpdir(), 'claude-mem-lite-repair-'));
3691
3830
  try {
3692
3831
  // Resolve the latest RELEASE (tag) and cryptographically VERIFY it before running any
@@ -3929,12 +4068,16 @@ function bindingHostDir() {
3929
4068
  // two concurrent rebuilds can clobber the .node mid-compile. A live peer → report
3930
4069
  // and exit 0 (it is doing this very work), never race it.
3931
4070
  async function rebuildBinding() {
3932
- const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
4071
+ const { release, error: blocked } = takeLock(INSTALL_LOCK_PATH);
3933
4072
  if (!release) {
3934
4073
  // NOT exit 0: skipping is not healing. Callers key their state on the exit
3935
4074
  // code — a false success would let the launcher drop its cooldown and the
3936
4075
  // CLI re-exec into the same broken binding.
3937
- console.error('[install] Another install/repair is in progress — it owns the rebuild; skipping.');
4076
+ console.error(
4077
+ blocked
4078
+ ? `[install] ${lockBlockedWhy(blocked)} — the binding was not rebuilt.`
4079
+ : '[install] Another install/repair is in progress — it owns the rebuild; skipping.',
4080
+ );
3938
4081
  process.exitCode = 1;
3939
4082
  return;
3940
4083
  }
@@ -4011,6 +4154,35 @@ function sameDir(a, b) {
4011
4154
  // The fix for a data dir this user cannot use, chosen by the error code (lib/wal-open-blocked.mjs).
4012
4155
  export const dataDirAccessRemedy = (code) => dataDirRemedy(code, MEM_DATA_DIR);
4013
4156
 
4157
+ // install.lock serialises every installer (install, repair, update, binding rebuild, cleanup's
4158
+ // update residue). One that cannot be created at all is not one a peer holds, and each face says
4159
+ // which (D#304, D#307): waiting does not clear it, the fix below does.
4160
+ const INSTALL_LOCK_PATH = join(MEM_DATA_DIR, 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
4161
+ const lockBlockedWhy = ({ dir, code }) =>
4162
+ `install.lock cannot be taken — ${dir} cannot be written (${code}). Fix: ${lockDirRemedy(code, dir)}`;
4163
+
4164
+ // The fix for the directory lockDirBlocked / takeLock name. One ABOVE the data dir (it does not exist
4165
+ // yet and cannot be created there, or cannot be reached) is not this program's to chmod or chown: on
4166
+ // a CLAUDE_MEM_DIR under /opt that advice would hand /opt to the user (review P3-3).
4167
+ function lockDirRemedy(code, dir) {
4168
+ const rel = relative(MEM_DATA_DIR, dir);
4169
+ // Creating the data dir fixes only a parent that can be entered but not written; one that cannot be
4170
+ // entered (the data dir may already be the user's), or a link to nowhere, leaves CLAUDE_MEM_DIR
4171
+ // (delta review P3-1).
4172
+ const above = rel.startsWith('..') || isAbsolute(rel);
4173
+ if (above && (code === 'ENOENT' || dirAccessError(dir, fsConstants.X_OK)))
4174
+ return `${shellWord(dir)} cannot be entered or does not exist — set CLAUDE_MEM_DIR to a directory your user can write`;
4175
+ if (above)
4176
+ return `make ${shellWord(MEM_DATA_DIR)} a directory your user owns (e.g. sudo mkdir -p ${shellWord(MEM_DATA_DIR)} && sudo chown "$USER" ${shellWord(MEM_DATA_DIR)}), or set CLAUDE_MEM_DIR to one`;
4177
+ if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return dataDirRemedy(code, dir);
4178
+ if (code === 'ENOSPC' || code === 'EDQUOT')
4179
+ return `free space on the file system holding ${shellWord(dir)}`;
4180
+ if (code === 'ENOTDIR') return `${shellWord(dir)} is a file, not a directory — move it aside`;
4181
+ if (code === 'ENOENT')
4182
+ return `${shellWord(dir)} is a link to a directory that does not exist — create its target, or remove the link`;
4183
+ return `check that your user can create files in ${shellWord(dir)}`;
4184
+ }
4185
+
4014
4186
  // This process cannot load better-sqlite3: none installed where it runs (a checkout, or a plugin
4015
4187
  // cache before its first launch installed node_modules), or one that will not load (a stale ABI, a
4016
4188
  // damaged prebuild). The better-sqlite3 line names and counts that; a line that would open the DB
@@ -4028,8 +4200,14 @@ async function runLockedInstall() {
4028
4200
  process.exitCode = 1;
4029
4201
  return;
4030
4202
  }
4031
- const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
4203
+ const { release, error: blocked } = takeLock(INSTALL_LOCK_PATH);
4032
4204
  if (!release) {
4205
+ if (blocked) {
4206
+ // Not a peer: nothing will ever release it, and exit 0 told the caller it installed.
4207
+ console.error(`[install] ${lockBlockedWhy(blocked)} — nothing was done.`);
4208
+ process.exitCode = 1;
4209
+ return;
4210
+ }
4033
4211
  console.log('[install] Another install/repair is in progress — skipping to avoid a torn write.');
4034
4212
  return;
4035
4213
  }
@@ -76,10 +76,16 @@ export function nativeBindingRepairHint(root) {
76
76
  // Set on a re-exec'd child so one failed heal cannot fork-bomb the CLI.
77
77
  export const BINDING_HEAL_GUARD_ENV = 'CLAUDE_MEM_BINDING_HEALED';
78
78
 
79
- // The native-binding fault family, in the four shapes it actually reaches callers:
79
+ // The native-binding fault family, in the shapes it actually reaches callers:
80
80
  // • ERR_DLOPEN_FAILED — Node's code for a failed dlopen (ABI mismatch)
81
81
  // • NODE_MODULE_VERSION N vs M — the ABI text itself (some throws carry no code)
82
82
  // • Could not locate the bindings file — build/Release missing or never compiled
83
+ // (better-sqlite3 ≤12, through the `bindings` package)
84
+ // • Cannot find module '…better_sqlite3.node' — the same fault on 13, which dropped
85
+ // `bindings`: with no prebuild for the platform and no source build, lib/binding.js
86
+ // requires build/Release/better_sqlite3.node and Node throws a plain MODULE_NOT_FOUND
87
+ // (D#306). Every first load on a platform 13 ships no prebuild for. Anchored on the
88
+ // addon's file name: a missing PACKAGE needs an install, not a rebuild.
83
89
  // • Module did not self-register — the .node was REPLACED under a process that
84
90
  // already dlopen'd the old one; only a fresh process recovers (hence the
85
91
  // re-exec in healAndReexec, not an in-process retry)
@@ -88,6 +94,8 @@ export const BINDING_HEAL_GUARD_ENV = 'CLAUDE_MEM_BINDING_HEALED';
88
94
  const NATIVE_BINDING_PATTERNS = [
89
95
  /NODE_MODULE_VERSION/,
90
96
  /Could not locate the bindings file/i,
97
+ // A quote may sit inside the path; the quoted path must end the line (Node's own shape).
98
+ /Cannot find module '[^\n]*[\\/]better_sqlite3\.node'(?:\r?\n|$)/,
91
99
  /did not self-register/i,
92
100
  /invalid ELF header/i,
93
101
  ];
@@ -135,9 +135,11 @@ export function formatHookError(err, event, { now = Date.now(), runtimeDir } = {
135
135
  !nativeBindingHintDue(runtimeDir, now, NATIVE_BINDING_HINT_COOLDOWN_MS, errKey(err.message))
136
136
  )
137
137
  return null;
138
+ // No guessed cause: better-sqlite3 13's addons are N-API and survive a Node upgrade, and on 13 the
139
+ // addon can also never have been there — no prebuild for the platform (D#306).
138
140
  return (
139
141
  `[claude-mem-lite] [${ts}] [WARN] ${event}: native DB binding can't load ` +
140
- `(likely a Node version change) — auto-heals at the next session start, or run now: ${CLI_REBUILD_BINDING}`
142
+ `— auto-heals at the next session start, or run now: ${CLI_REBUILD_BINDING}`
141
143
  );
142
144
  }
143
145
  return `[claude-mem-lite] [${ts}] [ERROR] ${event}: ${err && err.message}`;
package/lib/proc-lock.mjs CHANGED
@@ -16,7 +16,18 @@
16
16
  // already doing the write). Release unlinks the file. Crash-safe: a crashed holder's
17
17
  // pid is gone, so the next session reclaims its lock at once.
18
18
 
19
- import { writeFileSync, readFileSync, unlinkSync, mkdirSync, renameSync, linkSync, lstatSync } from 'node:fs';
19
+ import {
20
+ writeFileSync,
21
+ readFileSync,
22
+ unlinkSync,
23
+ mkdirSync,
24
+ renameSync,
25
+ linkSync,
26
+ lstatSync,
27
+ statSync,
28
+ accessSync,
29
+ constants as fsConstants,
30
+ } from 'node:fs';
20
31
  import { dirname } from 'node:path';
21
32
  import { workerDirGone } from './worker-data-dir.mjs';
22
33
 
@@ -75,13 +86,21 @@ function isStale(lockPath, staleMs, now) {
75
86
  * temp carries a `.lock` suffix so hook.mjs's sweeper reclaims it if we die in between.
76
87
  * Filesystems without hard links (some network / FAT mounts) fall back to the old form —
77
88
  * they keep the narrow window, which is still better than never acquiring a lock at all.
89
+ *
90
+ * A failure is returned as the errno, so a caller can say why the lock cannot be taken.
78
91
  */
79
92
  function createExclusive(lockPath, payload) {
80
93
  const tmp = `${lockPath}.new-${process.pid}-${Math.random().toString(36).slice(2)}.lock`;
81
94
  try {
82
95
  writeFileSync(tmp, payload, { flag: 'wx' });
83
- } catch {
84
- return 'error';
96
+ } catch (e) {
97
+ // open() may have created it before the write failed (a full disk): do not leave it behind.
98
+ try {
99
+ unlinkSync(tmp);
100
+ } catch {
101
+ /* never created */
102
+ }
103
+ return { error: e.code || 'EIO' };
85
104
  }
86
105
  try {
87
106
  linkSync(tmp, lockPath);
@@ -93,7 +112,7 @@ function createExclusive(lockPath, payload) {
93
112
  writeFileSync(lockPath, payload, { flag: 'wx' });
94
113
  return 'ok';
95
114
  } catch (e2) {
96
- return e2.code === 'EEXIST' ? 'exists' : 'error';
115
+ return e2.code === 'EEXIST' ? 'exists' : { error: e2.code || 'EIO' };
97
116
  }
98
117
  } finally {
99
118
  try {
@@ -131,10 +150,29 @@ function makeRelease(lockPath, payload) {
131
150
  * @returns {(() => void)|null} A release() fn, or null if a live peer holds it, the lock file
132
151
  * could not be created, or this is a background worker whose lock directory was removed.
133
152
  */
134
- export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.now } = {}) {
153
+ export function acquireLock(lockPath, opts) {
154
+ return takeLock(lockPath, opts).release || null;
155
+ }
156
+
157
+ /**
158
+ * acquireLock, saying why when it does not take the lock. acquireLock's null stands both for a
159
+ * live holder and for a lock file that cannot be created at all, and every installer read that
160
+ * null as "another install is in progress": with a runtime dir a `sudo` run left root-owned, a
161
+ * read-only data dir, a runtime link to nowhere or a full disk, every update skipped silently and
162
+ * install exited 0 having done nothing (D#304, D#307). An error is reported for every failed
163
+ * create: as lockDirBlocked names it when it sees the cause, otherwise with the create's own errno,
164
+ * which covers the causes its access() check cannot see (a Windows ACL, ENOSPC).
165
+ * @returns {{release: () => void} | {error: {dir: string, code: string}} | {}} {} when a live peer
166
+ * holds it, or this is a background worker whose lock directory was removed.
167
+ */
168
+ export function takeLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.now } = {}) {
169
+ // lockDirBlocked, when it sees the cause, names the directory that has to change and why: it walks
170
+ // to an ancestor that cannot be entered, a link to nowhere, a component that is a file. A cause it
171
+ // cannot see (a full disk, a Windows ACL access() does not read) keeps the create's own errno.
172
+ const failed = (code) => ({ error: lockDirBlocked(lockPath) || { dir: dirname(lockPath), code } });
135
173
  // A background worker (auto-maintain takes its lock in the runtime dir) does not re-create a
136
174
  // directory removed while it ran (D#265). Not taking the lock is the caller's no-op path.
137
- if (workerDirGone(dirname(lockPath))) return null;
175
+ if (workerDirGone(dirname(lockPath))) return {};
138
176
  try {
139
177
  mkdirSync(dirname(lockPath), { recursive: true });
140
178
  } catch {
@@ -150,8 +188,8 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
150
188
  nonce: Math.random().toString(36).slice(2),
151
189
  });
152
190
  const first = createExclusive(lockPath, payload);
153
- if (first === 'ok') return makeRelease(lockPath, payload);
154
- if (first === 'error') return null; // permission / fs error → fail closed
191
+ if (first === 'ok') return { release: makeRelease(lockPath, payload) };
192
+ if (first.error) return failed(first.error); // permission / fs error → fail closed, and say why
155
193
  {
156
194
  let sampled;
157
195
  try {
@@ -162,9 +200,11 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
162
200
  // it would move a peer's freshly created lock aside with nothing to compare it to,
163
201
  // which is the double-acquire this whole protocol exists to prevent. Just retry the
164
202
  // plain exclusive create once and take the answer.
165
- return createExclusive(lockPath, payload) === 'ok' ? makeRelease(lockPath, payload) : null;
203
+ const again = createExclusive(lockPath, payload);
204
+ if (again === 'ok') return { release: makeRelease(lockPath, payload) };
205
+ return again.error ? failed(again.error) : {};
166
206
  }
167
- if (!isStale(lockPath, staleMs, now)) return null; // live peer holds it
207
+ if (!isStale(lockPath, staleMs, now)) return {}; // live peer holds it
168
208
  // ── Stealing a stale lock (R10 P1-7) ──────────────────────────────────────────────
169
209
  // This used to be unlink-then-create, and the comment "lose the race → null" was only
170
210
  // true of the create. The losing interleave: A unlinks, A creates, B unlinks A's BRAND
@@ -182,7 +222,7 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
182
222
  try {
183
223
  renameSync(lockPath, tombstone);
184
224
  } catch {
185
- return null; // another stealer won the rename; it owns the re-create
225
+ return {}; // another stealer won the rename; it owns the re-create
186
226
  }
187
227
  let stolen = null;
188
228
  try {
@@ -201,14 +241,16 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
201
241
  } catch {
202
242
  /* best-effort */
203
243
  }
204
- return null;
244
+ return {};
205
245
  }
206
246
  try {
207
247
  unlinkSync(tombstone);
208
248
  } catch {
209
249
  /* best-effort — the .lock suffix keeps it sweepable either way */
210
250
  }
211
- return createExclusive(lockPath, payload) === 'ok' ? makeRelease(lockPath, payload) : null;
251
+ const retaken = createExclusive(lockPath, payload);
252
+ if (retaken === 'ok') return { release: makeRelease(lockPath, payload) };
253
+ return retaken.error ? failed(retaken.error) : {};
212
254
  }
213
255
  }
214
256
 
@@ -235,6 +277,87 @@ export function lockHeld(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.now
235
277
  return !isStale(lockPath, staleMs, now);
236
278
  }
237
279
 
280
+ /**
281
+ * Why acquireLock cannot create `lockPath` whoever holds it, or null when it can. acquireLock
282
+ * answers null both for a live holder and for a lock file it cannot create at all, and every
283
+ * caller read that null as "another install is in progress": with a runtime dir a `sudo` run left
284
+ * root-owned, or a read-only data dir, every update skipped silently, install exited 0 having done
285
+ * nothing, and cleanup named a lock that did not exist (D#304, D#307). This tells the two apart.
286
+ * Read-only, like lockHeld: it creates nothing.
287
+ *
288
+ * The directory asked about is the lock's own, or, when that does not exist yet, the nearest one
289
+ * that does: acquireLock creates the rest, so that is the one it has to write into.
290
+ * @returns {{dir: string, code: string}|null}
291
+ */
292
+ export function lockDirBlocked(lockPath) {
293
+ let dir = dirname(lockPath);
294
+ for (;;) {
295
+ // A path that is there but is a file, not a directory: access() reads the file's own mode bits
296
+ // (EACCES, or a pass for a 0777 file), so it is asked first.
297
+ try {
298
+ if (!statSync(dir).isDirectory()) return { dir, code: 'ENOTDIR' };
299
+ } catch {
300
+ /* absent, or not reachable: access() below says which */
301
+ }
302
+ try {
303
+ accessSync(dir, fsConstants.W_OK | fsConstants.X_OK);
304
+ return null;
305
+ } catch (e) {
306
+ // A link to nowhere reads as absent, but nothing can be created under it (review P2-1).
307
+ if (e.code === 'ENOENT' && isLink(dir)) return { dir, code: 'ENOENT' };
308
+ // A component that is a file, not a directory: name that file.
309
+ if (e.code === 'ENOTDIR') return { dir: firstNonDirectory(dir) ?? dir, code: 'ENOTDIR' };
310
+ // A directory under one that cannot be entered fails EACCES too: name the one to fix.
311
+ if (e.code !== 'ENOENT') return unenterableAncestor(dir) || { dir, code: e.code || 'EACCES' };
312
+ const up = dirname(dir);
313
+ if (up === dir) return null;
314
+ dir = up;
315
+ }
316
+ }
317
+ }
318
+
319
+ function isLink(p) {
320
+ try {
321
+ return lstatSync(p).isSymbolicLink();
322
+ } catch {
323
+ return false;
324
+ }
325
+ }
326
+
327
+ /** The component of `dir` nearest the root that exists and is not a directory, or null. */
328
+ function firstNonDirectory(dir) {
329
+ const chain = [];
330
+ for (let d = dir; ; d = dirname(d)) {
331
+ chain.unshift(d);
332
+ if (dirname(d) === d) break;
333
+ }
334
+ for (const d of chain) {
335
+ try {
336
+ if (!statSync(d).isDirectory()) return d;
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+ return null;
342
+ }
343
+
344
+ /** The ancestor of `dir` nearest the root that this user cannot enter, or null. */
345
+ function unenterableAncestor(dir) {
346
+ const chain = [];
347
+ for (let d = dirname(dir); ; d = dirname(d)) {
348
+ chain.unshift(d);
349
+ if (dirname(d) === d) break;
350
+ }
351
+ for (const d of chain) {
352
+ try {
353
+ accessSync(d, fsConstants.X_OK);
354
+ } catch (e) {
355
+ return e.code === 'ENOENT' ? null : { dir: d, code: e.code || 'EACCES' };
356
+ }
357
+ }
358
+ return null;
359
+ }
360
+
238
361
  /**
239
362
  * Run `fn` while holding the lock; release in a finally. No-op if not acquired.
240
363
  * @returns {{acquired: boolean, result?: any}}
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.2",
3
+ "version": "6.25.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.25.2",
9
+ "version": "6.25.3",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.2",
3
+ "version": "6.25.3",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -114,11 +114,14 @@ try {
114
114
  process.stderr.write(`[claude-mem-lite] binding probe: ${e.message}\n`);
115
115
  process.exit(1);
116
116
  }
117
- const release = helpers.acquireLock(lockPath);
117
+ const { release, error: blocked } = helpers.takeLock(lockPath);
118
118
  if (!release) {
119
+ // A lock that cannot be created at all is not an install in flight (D#307): name it, as doctor does.
119
120
  process.stderr.write(
120
121
  `[claude-mem-lite] binding probe: ${helpers.flattenBindingError(first.error)} ` +
121
- '(another install/repair in flight — deferring heal)\n',
122
+ (blocked
123
+ ? `(install.lock cannot be taken — ${blocked.dir} cannot be written (${blocked.code}); run doctor for the fix)\n`
124
+ : '(another install/repair in flight — deferring heal)\n'),
122
125
  );
123
126
  process.exit(1);
124
127
  }
@@ -497,9 +497,7 @@ function healNativeBindingIfBroken() {
497
497
  // (install.mjs::rebuildBinding). Clearing it here would mean a rebuild that
498
498
  // silently did nothing — lock contention, a no-op npm — still reads as
499
499
  // "healed", dropping the cooldown and re-spawning npm on every session.
500
- process.stderr.write(
501
- '[claude-mem-lite] native DB binding unusable (Node version change?) — rebuilding in the background\n',
502
- );
500
+ process.stderr.write('[claude-mem-lite] native DB binding unusable — rebuilding in the background\n');
503
501
  const child = spawn(process.execPath, [installer, 'rebuild-binding'], {
504
502
  detached: true,
505
503
  stdio: 'ignore',
@@ -116,11 +116,19 @@ try {
116
116
  // mid-compile. Take the lock for the rebuild-capable path; a live peer →
117
117
  // wait up to 10s, then degrade to a probe-only pass (healthy binding
118
118
  // proceeds; a broken one defers to the peer instead of racing it).
119
- const { acquireLock } = await import('../lib/proc-lock.mjs');
119
+ const { takeLock } = await import('../lib/proc-lock.mjs');
120
120
  const { resolveDataDir } = await import('../lib/resolve-data-dir.mjs');
121
121
  const lockPath = join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
122
+ // A lock that cannot be created at all (a runtime dir a sudo run left root-owned, a link to
123
+ // nowhere, a full disk) is not a peer to wait for: nothing ever releases it, and waiting cost
124
+ // every MCP launch 10 s (D#307). takeLock says which it is.
122
125
  let release = null;
123
- for (let i = 0; i < 20 && !(release = acquireLock(lockPath)); i++) {
126
+ let blocked = null;
127
+ for (let i = 0; i < 20; i++) {
128
+ const taken = takeLock(lockPath);
129
+ release = taken.release || null;
130
+ blocked = taken.error || null;
131
+ if (release || blocked) break;
124
132
  await new Promise((r) => setTimeout(r, 500));
125
133
  }
126
134
  let verify;
@@ -137,7 +145,9 @@ try {
137
145
  ? { ok: true, action: 'verified' }
138
146
  : {
139
147
  ok: false,
140
- error: `${probe.error} (another install/repair holds the lock — not rebuilding concurrently; reconnect with /mcp once it finishes)`,
148
+ error: blocked
149
+ ? `${probe.error} (install.lock cannot be taken — ${blocked.dir} cannot be written (${blocked.code}), so it was not rebuilt; \`doctor\` names the fix)`
150
+ : `${probe.error} (another install/repair holds the lock — not rebuilding concurrently; reconnect with /mcp once it finishes)`,
141
151
  };
142
152
  }
143
153
  } finally {