claude-mem-lite 6.25.2 → 6.26.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.
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}`;