claude-mem-lite 6.25.0 → 6.25.2

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
@@ -16,7 +16,6 @@ import {
16
16
  readdirSync,
17
17
  statSync,
18
18
  lstatSync,
19
- accessSync,
20
19
  constants as fsConstants,
21
20
  } from 'fs';
22
21
  import { join, resolve, dirname, basename, sep } from 'path';
@@ -73,10 +72,12 @@ import { doctorDbModeHint } from './lib/doctor-modes.mjs';
73
72
  import { checkHookInterpreter } from './lib/doctor-hook-interpreter.mjs';
74
73
  import {
75
74
  classifyEpisodeFile,
75
+ classifyUpdateResidue,
76
76
  EPISODE_AGE_LABEL,
77
77
  isEpisodeResidue,
78
78
  isUpdateResidue,
79
79
  scanStaleTempFiles,
80
+ SWAP_RESOLVED_NAME,
80
81
  } from './lib/doctor-stale-temp.mjs';
81
82
  const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
82
83
 
@@ -96,9 +97,10 @@ import {
96
97
  import { detectInstallShape, probeRuntimeRoots, hasAnyManagedCode } from './lib/install-shape.mjs';
97
98
  import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
98
99
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
99
- import { acquireLock } from './lib/proc-lock.mjs';
100
+ import { acquireLock, lockHeld } from './lib/proc-lock.mjs';
100
101
  import { atomicWriteFileSync, atomicCopyFileSync } from './lib/atomic-write.mjs';
101
102
  import { shellWord } from './cli-path.mjs';
103
+ import { dirAccessError, walOpenBlocked, dataDirRemedy, WAL_BLOCKED_WHY } from './lib/wal-open-blocked.mjs';
102
104
  import {
103
105
  claudeConfigDir,
104
106
  claudeConfigDirFor,
@@ -1298,6 +1300,123 @@ function offerCleanOldVectorDb() {
1298
1300
  }
1299
1301
  }
1300
1302
 
1303
+ // A path, a dangling symlink included (existsSync follows the link).
1304
+ function pathPresent(p) {
1305
+ try {
1306
+ lstatSync(p);
1307
+ return true;
1308
+ } catch {
1309
+ return false;
1310
+ }
1311
+ }
1312
+
1313
+ // Update residue in the code dir, each entry with what it is (lib/doctor-stale-temp.mjs).
1314
+ function listUpdateResidue() {
1315
+ return readdirSync(INSTALL_DIR)
1316
+ .map((f) => ({ f, kind: classifyUpdateResidue(INSTALL_DIR, f) }))
1317
+ .filter((r) => r.kind);
1318
+ }
1319
+
1320
+ /**
1321
+ * Finish what interrupted updates left in the code dir, the way the next update entry would:
1322
+ * hook-update's recoverInterruptedSwaps replays each journal, then removes the residue. Reports
1323
+ * every entry by what became of it. The caller holds install.lock.
1324
+ *
1325
+ * Never throws: install calls it before writing the tree, and a recovery that threw would stop
1326
+ * the install it exists to protect. If hook-update cannot load, the residue of finished swaps is
1327
+ * still removed and an unfinished swap is left whole (for install, which retires it once the
1328
+ * tree is written, or for a later entry that can replay it).
1329
+ * @param {{f: string, kind: string}[]} found entries from listUpdateResidue
1330
+ * @returns {Promise<{finished: string[], removed: string[], kept: string[], deferred: string[],
1331
+ * unfinished: string[], left: string[]}>} kept: left on purpose because its journal cannot be
1332
+ * read; deferred: an unfinished swap left because hook-update could not load; unfinished: one the
1333
+ * replay did not finish, because a file could not be put back from it, or a later one that must be
1334
+ * finished first could not be finished or read (D#293); left: anything else
1335
+ */
1336
+ async function recoverUpdateResidue(found) {
1337
+ let replayed = true;
1338
+ try {
1339
+ const { recoverInterruptedSwaps } = await import('./hook-update.mjs');
1340
+ recoverInterruptedSwaps(INSTALL_DIR);
1341
+ } catch (e) {
1342
+ replayed = false;
1343
+ warn(`Could not finish an interrupted update here (${e.message}) — it is left in place`);
1344
+ }
1345
+ // Leftovers recovery did not take: a file or a symlink with the name (it replays only real
1346
+ // directories), or everything when hook-update could not load. Only 'stale' ones go.
1347
+ for (const { f, kind } of found) {
1348
+ if (kind !== 'stale' || !pathPresent(join(INSTALL_DIR, f))) continue;
1349
+ try {
1350
+ rmSync(join(INSTALL_DIR, f), { recursive: true, force: true });
1351
+ } catch {
1352
+ /* reported below as left */
1353
+ }
1354
+ }
1355
+ const out = { finished: [], removed: [], kept: [], deferred: [], unfinished: [], left: [] };
1356
+ for (const { f, kind } of found) {
1357
+ if (!pathPresent(join(INSTALL_DIR, f))) (kind === 'unfinished-swap' ? out.finished : out.removed).push(f);
1358
+ else if (kind === 'unreadable-journal') out.kept.push(f);
1359
+ else if (kind === 'unfinished-swap' && !replayed) out.deferred.push(f);
1360
+ else if (kind === 'unfinished-swap' && classifyUpdateResidue(INSTALL_DIR, f) === 'unfinished-swap')
1361
+ out.unfinished.push(f);
1362
+ else out.left.push(f);
1363
+ }
1364
+ return out;
1365
+ }
1366
+
1367
+ // A swap an updater was killed in the middle of is finished BEFORE this install writes the tree
1368
+ // (D#289). Its journal names an older release's paths; left for the next update entry, it was
1369
+ // replayed over the tree written here: it deleted the files the killed swap had added and put the
1370
+ // older ones back. Runs before the swap barrier, because the recovery arms and clears that marker.
1371
+ async function finishInterruptedSwaps() {
1372
+ let found;
1373
+ try {
1374
+ found = listUpdateResidue();
1375
+ } catch {
1376
+ return; // no code dir yet, so nothing to finish
1377
+ }
1378
+ if (!found.some((r) => r.kind === 'unfinished-swap')) return;
1379
+ const r = await recoverUpdateResidue(found);
1380
+ if (r.finished.length > 0)
1381
+ ok(`Finished ${r.finished.length} interrupted update(s) before installing: ${r.finished.join(', ')}`);
1382
+ // A swap left unfinished here (deferred, kept, unfinished) is retired once the tree is written.
1383
+ for (const f of r.unfinished)
1384
+ warn(
1385
+ `Could not finish the interrupted update ${f}: a file it moved aside could not be put back, or a later one that must be finished first could not be. This install writes over it and then retires it.`,
1386
+ );
1387
+ for (const f of r.left) warn(`Could not remove ${f} from ${INSTALL_DIR}`);
1388
+ }
1389
+
1390
+ const isUnresolvedSwap = (f) => {
1391
+ const kind = classifyUpdateResidue(INSTALL_DIR, f);
1392
+ return kind === 'unfinished-swap' || kind === 'unreadable-journal';
1393
+ };
1394
+
1395
+ // A journal still unresolved once deployCodeTree has written the code tree names paths of a
1396
+ // release this install has just replaced, and a later update entry would replay it over this tree:
1397
+ // delete the files the install wrote and put that release's back (v6.25.1 review; reached when
1398
+ // hook-update cannot load, or a journal cannot be read or removed). Marked resolved, it is never
1399
+ // replayed, and cleanup removes it as leftover. node_modules is written after this, by
1400
+ // installDependencies: if that fails, a node_modules the retired dir held is not put back (D#293).
1401
+ function retireLeftoverJournals() {
1402
+ let names;
1403
+ try {
1404
+ names = readdirSync(INSTALL_DIR).filter(isUnresolvedSwap);
1405
+ } catch {
1406
+ return;
1407
+ }
1408
+ for (const f of names) {
1409
+ try {
1410
+ writeFileSync(join(INSTALL_DIR, f, SWAP_RESOLVED_NAME), '');
1411
+ ok(`Retired ${f}: this install replaced the release it belongs to (cleanup removes it)`);
1412
+ } catch (e) {
1413
+ warn(
1414
+ `Could not retire ${f} (${e.code || e.message}): delete it, or the next update puts back the files it holds`,
1415
+ );
1416
+ }
1417
+ }
1418
+ }
1419
+
1301
1420
  async function install() {
1302
1421
  console.log('\nclaude-mem-lite installer\n');
1303
1422
  // Refuse an unparseable settings.json BEFORE the first side effect (throws
@@ -1310,12 +1429,14 @@ async function install() {
1310
1429
  const IS_DEV = flags.has('--dev');
1311
1430
 
1312
1431
  prepareInstallDirs();
1432
+ await finishInterruptedSwaps();
1313
1433
  // Both steps rewrite the tree hooks import — the code, then node_modules — so one swap
1314
1434
  // barrier spans them (D#223), for its first two minutes: the launcher ignores an older marker,
1315
1435
  // and npm install has no timeout. installDependencies may process.exit(1): the marker it leaves
1316
1436
  // names a dead pid, which the launcher ignores.
1317
1437
  await withSwapBarrier(async () => {
1318
1438
  deployCodeTree(IS_DEV);
1439
+ retireLeftoverJournals();
1319
1440
  await installDependencies(IS_DEV);
1320
1441
  });
1321
1442
  createCliSymlink();
@@ -1742,14 +1863,22 @@ async function status() {
1742
1863
  }
1743
1864
 
1744
1865
  // Database. A data dir this process cannot enter reads as an empty one (D#199), and
1745
- // `exists: false` would be a guess — null says nobody could look.
1866
+ // `exists: false` would be a guess — null says nobody could look. One it can read but not write
1867
+ // (the 755 a `sudo` run leaves owned by root) is read where SQLite can open the store there: its
1868
+ // counts are real, under a ✗ (D#284). A closed WAL store there cannot be opened; see the catch.
1746
1869
  const dataDirDenied = dataDirAccessError();
1747
- if (dataDirDenied) {
1870
+ const dataDirUnreadable = dataDirDenied
1871
+ ? dataDirAccessError(MEM_DATA_DIR, fsConstants.R_OK | fsConstants.X_OK)
1872
+ : null;
1873
+ const unwritable = dataDirDenied
1874
+ ? ` — ${MEM_DATA_DIR} can be read but not written (${dataDirDenied}). Fix: ${dataDirAccessRemedy(dataDirDenied)}`
1875
+ : '';
1876
+ if (dataDirUnreadable) {
1748
1877
  push(
1749
1878
  'fail',
1750
1879
  'database',
1751
- `Database: ${MEM_DATA_DIR} is not accessible (${dataDirDenied}) — Fix: ${dataDirAccessRemedy()}`,
1752
- { exists: null, error: dataDirDenied },
1880
+ `Database: ${MEM_DATA_DIR} is not accessible (${dataDirUnreadable}) — Fix: ${dataDirAccessRemedy(dataDirUnreadable)}`,
1881
+ { exists: null, error: dataDirUnreadable },
1753
1882
  );
1754
1883
  } else if (existsSync(DB_PATH)) {
1755
1884
  try {
@@ -1759,19 +1888,38 @@ async function status() {
1759
1888
  // DISTINCT, like stats: a session can own several summary rows (legacy duplicates).
1760
1889
  const sess = db.prepare('SELECT COUNT(DISTINCT memory_session_id) as c FROM session_summaries').get();
1761
1890
  db.close();
1762
- push('ok', 'database', `Database: ${obs.c} observations, ${sess.c} sessions`, {
1763
- exists: true,
1764
- observations: obs.c,
1765
- sessions: sess.c,
1766
- });
1891
+ push(
1892
+ dataDirDenied ? 'fail' : 'ok',
1893
+ 'database',
1894
+ `Database: ${obs.c} observations, ${sess.c} sessions${unwritable}`,
1895
+ {
1896
+ exists: true,
1897
+ observations: obs.c,
1898
+ sessions: sess.c,
1899
+ ...(dataDirDenied && { error: dataDirDenied }),
1900
+ },
1901
+ );
1767
1902
  } catch (e) {
1768
- push('warn', 'database', 'Database: exists but check failed — ' + e.message, {
1903
+ // The store is WAL (schema.mjs), and even a read-only open needs its -wal and -shm files, which
1904
+ // SQLite creates beside the DB: in a directory it cannot write, a closed store cannot be opened
1905
+ // in place (a copy elsewhere could be read) — 6.24.0 could not open it either. Say so rather than relay "attempt to write a readonly database".
1906
+ // The errno stands in for SQLite's message only where the directory is the cause.
1907
+ const walBlocked = walOpenBlocked(e.code, dataDirDenied, DB_PATH);
1908
+ const why = walBlocked
1909
+ ? `exists, but ${WAL_BLOCKED_WHY}`
1910
+ : bindingMissing(e)
1911
+ ? `exists, but not checked — ${BINDING_MISSING_WHY}`
1912
+ : 'exists but check failed — ' + e.message;
1913
+ push(dataDirDenied ? 'fail' : 'warn', 'database', `Database: ${why}${unwritable}`, {
1769
1914
  exists: true,
1770
- error: e.message,
1915
+ error: walBlocked ? dataDirDenied : e.message,
1771
1916
  });
1772
1917
  }
1773
1918
  } else {
1774
- push('warn', 'database', 'Database: not found', { exists: false });
1919
+ push(dataDirDenied ? 'fail' : 'warn', 'database', 'Database: not found' + unwritable, {
1920
+ exists: false,
1921
+ ...(dataDirDenied && { error: dataDirDenied }),
1922
+ });
1775
1923
  }
1776
1924
 
1777
1925
  // CLI.
@@ -1990,12 +2138,21 @@ async function doctor() {
1990
2138
  fail(
1991
2139
  `Data directory: ${MEM_DATA_DIR} is not accessible (${dataDirDenied}) — ` +
1992
2140
  (dataDirUnreadable ? 'checks that read it say "not checked"' : 'it can be read but not written') +
1993
- `. Fix: ${dataDirAccessRemedy()}`,
2141
+ `. Fix: ${dataDirAccessRemedy(dataDirDenied)}`,
1994
2142
  );
1995
2143
  issues++;
1996
2144
  }
1997
2145
  const notCheckedDenied = (what, dir = MEM_DATA_DIR) =>
1998
2146
  dwarn(`${what}: not checked — ${dir} is not accessible`);
2147
+ // A closed WAL store in a data dir that can be read but not written: even a read-only open has to
2148
+ // create the -wal/-shm files beside it, so every check that opens the DB fails, for the fault the
2149
+ // Data directory line names and counts. Those checks say they did not look, in the words status
2150
+ // uses (D#287, the D#199 shape). Keyed on SQLite's code: its message is shared with other faults.
2151
+ const walBlocked = (code) => walOpenBlocked(code, dataDirDenied, DB_PATH);
2152
+ const notCheckedWal = (what, scope = '') =>
2153
+ dwarn(
2154
+ `${what}: not checked${scope} — ${WAL_BLOCKED_WHY}, and ${MEM_DATA_DIR} cannot be written (see Data directory above)`,
2155
+ );
1999
2156
  // The code dir is the data dir in the default shape; under CLAUDE_MEM_DIR it is checked apart.
2000
2157
  const codeIsDataDir = sameDir(INSTALL_DIR, MEM_DATA_DIR);
2001
2158
  const codeDirDenied = codeIsDataDir ? dataDirUnreadable : dataDirAccessError(INSTALL_DIR, readMode);
@@ -2016,7 +2173,10 @@ async function doctor() {
2016
2173
  // process.
2017
2174
  const rootProbes = probeRuntimeRoots(shape.runtimeRoots);
2018
2175
  const brokenRoots = rootProbes.filter((r) => !r.ok);
2019
- if (rootProbes.length === 0) {
2176
+ if (rootProbes.length === 0 && codeDirDenied) {
2177
+ // The code home is locked, so it may own a binding nobody could look at (D#284 review P3-4).
2178
+ notCheckedDenied('better-sqlite3', INSTALL_DIR);
2179
+ } else if (rootProbes.length === 0) {
2020
2180
  fail('better-sqlite3: no install on this machine owns a native binding — nothing here can open the DB');
2021
2181
  issues++;
2022
2182
  } else if (brokenRoots.length === 0) {
@@ -2064,6 +2224,8 @@ async function doctor() {
2064
2224
  notCheckedDenied('DB schema');
2065
2225
  } else if (!existsSync(DB_PATH)) {
2066
2226
  ok('DB schema: no database yet — nothing to compare');
2227
+ } else if (rootProbes.length === 0 && codeDirDenied) {
2228
+ notCheckedDenied('DB schema', INSTALL_DIR); // the locked code home may own one (D#284 review)
2067
2229
  } else if (rootProbes.length === 0) {
2068
2230
  // The fourth outcome the first cut had and did not print. The `fail` above already tells
2069
2231
  // the reader no install owns a binding, but a block whose stated design point is "three
@@ -2135,7 +2297,8 @@ async function doctor() {
2135
2297
  // printed as a green line is the defect the v6.2.0 round wrote and its pre-ship review
2136
2298
  // caught before the tag — a check that says "nothing to check" and "I could not look"
2137
2299
  // in the same voice ends the reader's search instead of directing it.
2138
- dwarn(`DB schema: could not determine compatibility for ${u.label} (${u.error})`);
2300
+ if (walBlocked(u.errorCode)) notCheckedWal('DB schema', ` for ${u.label}`);
2301
+ else dwarn(`DB schema: could not determine compatibility for ${u.label} (${u.error})`);
2139
2302
  }
2140
2303
  }
2141
2304
 
@@ -2220,7 +2383,11 @@ async function doctor() {
2220
2383
  // is 6h-rate-limited stderr nobody reads), the live probe says "is it broken
2221
2384
  // right now". A Node upgrade breaks every DB-touching path at once, so this is
2222
2385
  // the single highest-value line in doctor when it fires.
2223
- const breakage = readNativeBindingBreakage(MEM_RUNTIME_DIR);
2386
+ // The marker sits in the runtime dir: in a locked data dir it reads as absent (D#284).
2387
+ const breakageUnread = dataDirUnreadable && runtimeInDataDir;
2388
+ const breakage = breakageUnread ? null : readNativeBindingBreakage(MEM_RUNTIME_DIR);
2389
+ const breakageSeen = (b) =>
2390
+ `~${Math.round((Date.now() - (b.ts || 0)) / 3600000)}h ago (${b.reason || 'unknown'})`;
2224
2391
  // Reuses the per-root probes above — same trees, same question, and doctor
2225
2392
  // should not pay for another round of child spawns to ask it twice.
2226
2393
  if (brokenRoots.length > 0) {
@@ -2228,10 +2395,20 @@ async function doctor() {
2228
2395
  `Native DB binding: unusable in ${brokenRoots.map((b) => b.label).join(', ')} — run \`node ${shellWord(join(PROJECT_DIR, 'cli.mjs'))} rebuild-binding\` (repairs every broken install, not just this one)`,
2229
2396
  );
2230
2397
  issues++;
2398
+ } else if (rootProbes.length === 0) {
2399
+ // Nothing was probed: a ✓ here sat under the better-sqlite3 ✗ for the same machine. Said, not
2400
+ // left silent, like DB schema's fourth outcome above; a marker it could read still shows.
2401
+ const why = codeDirDenied
2402
+ ? `${INSTALL_DIR} is not accessible`
2403
+ : 'no install on this machine owns a native binding to probe';
2404
+ dwarn(
2405
+ `Native DB binding: not checked — ${why}${breakage ? `; a fire failed ${breakageSeen(breakage)}` : ''}`,
2406
+ );
2407
+ } else if (breakageUnread) {
2408
+ notCheckedDenied(`Native DB binding: loadable on Node ${process.version}, past failures`);
2231
2409
  } else if (breakage) {
2232
- const ageH = Math.round((Date.now() - (breakage.ts || 0)) / 3600000);
2233
2410
  dwarn(
2234
- `Native DB binding: healthy now, but a fire failed ~${ageH}h ago (${breakage.reason || 'unknown'}) — stale marker clears on the next successful rebuild-binding`,
2411
+ `Native DB binding: healthy now, but a fire failed ${breakageSeen(breakage)} — stale marker clears on the next successful rebuild-binding`,
2235
2412
  );
2236
2413
  } else {
2237
2414
  ok(`Native DB binding: loadable on Node ${process.version}`);
@@ -2334,9 +2511,17 @@ async function doctor() {
2334
2511
  // with require-error noise every session. README's Uninstall section warns
2335
2512
  // about the right ordering; this check flags the broken state so it surfaces
2336
2513
  // even when the user skipped the README.
2337
- const orphanPaths = settings === null ? null : collectOrphanHookPaths(settings);
2514
+ const unreadableHookPaths = [];
2515
+ const orphanPaths =
2516
+ settings === null ? null : collectOrphanHookPaths(settings, INSTALL_DIR, unreadableHookPaths);
2338
2517
  if (orphanPaths === null) {
2339
2518
  dwarn('Orphan hooks: not checked (settings.json unreadable)');
2519
+ } else if (orphanPaths.length === 0 && unreadableHookPaths.length > 0) {
2520
+ // A hook that points into a code home this user cannot enter is not an orphan: the file may
2521
+ // well be there, and the Entry points line names and counts the locked dir (D#297).
2522
+ dwarn(
2523
+ `Orphan hooks: not checked — ${unreadableHookPaths.length} hook target(s) cannot be read (${unreadableHookPaths[0]}${unreadableHookPaths.length > 1 ? ', …' : ''})`,
2524
+ );
2340
2525
  } else if (orphanPaths.length > 0) {
2341
2526
  fail(
2342
2527
  `Orphan hooks: ${orphanPaths.length} settings.json entr${orphanPaths.length === 1 ? 'y references a missing file' : 'ies reference missing files'}`,
@@ -2474,24 +2659,39 @@ async function doctor() {
2474
2659
  dwarn('FTS5 index: missing (will be created on server start)');
2475
2660
  }
2476
2661
  } catch (e) {
2477
- fail('Database: ' + e.message);
2478
- // Every other ✗ on this screen carries a remedy; this one used to be the exception,
2479
- // and a corrupt store is the failure a user is least able to diagnose unaided.
2480
- // dbCheckRemedy returns null rather than invent one for an error it cannot classify.
2481
- // Pre-ship review 2026-09-09: dbCheckRemedy became async and lazy (P1-1), which moved
2482
- // its failure mode from load time into THIS catch — and an unhandled rejection here
2483
- // aborts doctor exactly as the static import did, so `--json` still emitted zero bytes
2484
- // on the compound shape "a file is missing AND the database will not open". A remedy
2485
- // is an extra sentence on a check that has already failed; never let it take the run.
2486
- let remedy = null;
2487
- try {
2488
- remedy = await dbCheckRemedy(DB_PATH, e);
2489
- } catch (remedyErr) {
2490
- log(` (could not build a repair hint: ${remedyErr.message})`);
2662
+ if (walBlocked(e.code)) {
2663
+ notCheckedWal('Database');
2664
+ } else if (bindingMissing(e)) {
2665
+ dwarn(`Database: not checked — ${BINDING_MISSING_WHY}`);
2666
+ } else {
2667
+ fail('Database: ' + e.message);
2668
+ // Every other ✗ on this screen carries a remedy; this one used to be the exception,
2669
+ // and a corrupt store is the failure a user is least able to diagnose unaided.
2670
+ // dbCheckRemedy returns null rather than invent one for an error it cannot classify.
2671
+ // Pre-ship review 2026-09-09: dbCheckRemedy became async and lazy (P1-1), which moved
2672
+ // its failure mode from load time into THIS catch — and an unhandled rejection here
2673
+ // aborts doctor exactly as the static import did, so `--json` still emitted zero bytes
2674
+ // on the compound shape "a file is missing AND the database will not open". A remedy
2675
+ // is an extra sentence on a check that has already failed; never let it take the run.
2676
+ let remedy = null;
2677
+ try {
2678
+ remedy = await dbCheckRemedy(DB_PATH, e);
2679
+ } catch (remedyErr) {
2680
+ log(` (could not build a repair hint: ${remedyErr.message})`);
2681
+ }
2682
+ if (remedy) log(` ${remedy}`);
2683
+ issues++;
2491
2684
  }
2492
- if (remedy) log(` ${remedy}`);
2493
- issues++;
2494
2685
  }
2686
+ } else if (rootProbes.length === 0) {
2687
+ // "will be created" beside the better-sqlite3 line's "nothing here can open the DB" was a
2688
+ // promise this screen had just contradicted (D#287). With the code home locked the binding
2689
+ // was not looked for, so neither half is claimed.
2690
+ dwarn(
2691
+ codeDirDenied
2692
+ ? 'Database: not found'
2693
+ : 'Database: not found — and nothing here can create it until an install has a native binding (see better-sqlite3 above)',
2694
+ );
2495
2695
  } else {
2496
2696
  dwarn('Database: not found (will be created)');
2497
2697
  }
@@ -2822,18 +3022,45 @@ async function doctor() {
2822
3022
  // Stale temp files. The rules live in lib/doctor-stale-temp.mjs because this scanner and
2823
3023
  // cleanup's deleter are the same question asked twice and had drifted twice — see that
2824
3024
  // file. Counting is all that differs here; the classification is shared, so "what doctor
2825
- // calls stale" and "what cleanup removes" agree on the age gate, which is the axis they
2826
- // last diverged on. Not on every axis: cleanup skips update residue entirely while
2827
- // install.lock is held and the scanner has no such gate, so mid-self-update doctor still
2828
- // counts a file cleanup will decline. That one is milder than D#53 — cleanup SAYS it is
2829
- // skipping rather than answering "No stale files found" — and it predates this change.
3025
+ // calls stale" and "what cleanup removes" agree on the age gate and on the directory, the
3026
+ // axes they diverged on (the third time, D#289: both looked for update residue in the data
3027
+ // dir, which CLAUDE_MEM_DIR moves away from the code dir it lives in). And on the lock: while
3028
+ // install.lock is held, cleanup skips update residue (a running update's own staging and backup
3029
+ // dirs among it), so doctor does not count it either, and says it did not look (D#297). The holder
3030
+ // may be any installer: an update, install, repair, a binding rebuild. It asks without taking it.
2830
3031
  try {
2831
- const { stale, inFlight } = scanStaleTempFiles({
2832
- dataDir: MEM_DATA_DIR,
3032
+ const lockHeldNow = lockHeld(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
3033
+ const { stale, inFlight, unfinishedSwaps, unreadableJournals, notChecked } = scanStaleTempFiles({
3034
+ codeDir: INSTALL_DIR,
2833
3035
  runtimeDir: MEM_RUNTIME_DIR,
3036
+ legacyDir: codeIsDataDir ? undefined : MEM_DATA_DIR,
3037
+ installLockHeld: lockHeldNow,
2834
3038
  });
2835
- if (stale > 0) {
2836
- dwarn(`Stale temp files: ${stale} found (run: node install.mjs cleanup)`);
3039
+ const installer = `node ${shellWord(join(PROJECT_DIR, 'install.mjs'))}`;
3040
+ if (unfinishedSwaps > 0) {
3041
+ 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)`,
3043
+ );
3044
+ }
3045
+ if (unreadableJournals > 0) {
3046
+ 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)`,
3048
+ );
3049
+ }
3050
+ const skipped = lockHeldNow
3051
+ ? `update residue not checked — install.lock is held (by a running install, update, repair or binding rebuild, or as a lock file this user cannot read), and cleanup skips it too`
3052
+ : '';
3053
+ if (notChecked.length > 0) {
3054
+ const where = notChecked.map((n) => `${n.dir} is not accessible (${n.code})`).join('; ');
3055
+ dwarn(
3056
+ `Stale temp files: not checked — ${where}` +
3057
+ (stale > 0 ? `; ${stale} found elsewhere (run: ${installer} cleanup)` : '') +
3058
+ (skipped ? `; ${skipped}` : ''),
3059
+ );
3060
+ } else if (stale > 0) {
3061
+ dwarn(`Stale temp files: ${stale} found (run: ${installer} cleanup)${skipped ? `; ${skipped}` : ''}`);
3062
+ } else if (skipped) {
3063
+ dwarn(`Stale temp files: ${skipped}`);
2837
3064
  } else {
2838
3065
  ok('Stale temp files: none');
2839
3066
  }
@@ -2875,7 +3102,9 @@ async function doctor() {
2875
3102
  dwarn(`${stats} — but the install you are running cannot use this database (see DB schema above)`);
2876
3103
  else ok(stats);
2877
3104
  } catch (e) {
2878
- dwarn('DB stats: ' + e.message);
3105
+ if (walBlocked(e.code)) notCheckedWal('DB stats');
3106
+ else if (bindingMissing(e)) dwarn(`DB stats: not checked — ${BINDING_MISSING_WHY}`);
3107
+ else dwarn('DB stats: ' + e.message);
2879
3108
  }
2880
3109
  }
2881
3110
 
@@ -3000,9 +3229,31 @@ function looksLikeHookPath(p) {
3000
3229
  return HOOK_PATH_EXTS.some((ext) => p.endsWith(ext));
3001
3230
  }
3002
3231
 
3003
- export function collectOrphanHookPaths(settings, installDir = INSTALL_DIR) {
3232
+ // 'missing' when the path is not there, 'unreadable' when it cannot be looked at (a directory on the
3233
+ // way this user cannot enter). existsSync answers false to both, so hooks pointing into a locked code
3234
+ // home were orphans, a second ✗ beside the Entry points line that names the lock (D#297).
3235
+ function hookTargetState(p) {
3236
+ try {
3237
+ statSync(p);
3238
+ return 'present';
3239
+ } catch (e) {
3240
+ return e.code === 'ENOENT' || e.code === 'ENOTDIR' ? 'missing' : 'unreadable';
3241
+ }
3242
+ }
3243
+
3244
+ /**
3245
+ * @param {string[]|null} [unreadable] collects the targets that could not be looked at, which are
3246
+ * not returned as missing
3247
+ */
3248
+ export function collectOrphanHookPaths(settings, installDir = INSTALL_DIR, unreadable = null) {
3004
3249
  if (!settings?.hooks) return [];
3005
3250
  const out = [];
3251
+ const judge = (p) => {
3252
+ if (out.includes(p) || unreadable?.includes(p)) return;
3253
+ const state = hookTargetState(p);
3254
+ if (state === 'missing') out.push(p);
3255
+ else if (state === 'unreadable') unreadable?.push(p);
3256
+ };
3006
3257
  for (const configs of Object.values(settings.hooks)) {
3007
3258
  if (!Array.isArray(configs)) continue;
3008
3259
  for (const cfg of configs) {
@@ -3019,7 +3270,7 @@ export function collectOrphanHookPaths(settings, installDir = INSTALL_DIR) {
3019
3270
  // ERR_MODULE_NOT_FOUND — and doctor went back to printing "Orphan hooks: none".
3020
3271
  // Both files have to exist for the hook to run, so both are reportable.
3021
3272
  const entry = launcherEntryPath(cmd, installDir);
3022
- if (entry && !existsSync(entry) && !out.includes(entry)) out.push(entry);
3273
+ if (entry) judge(entry);
3023
3274
  // v2.80: scan ALL quoted tokens (was: only the first), prefer ones
3024
3275
  // that look like a hook path. Fixes a footgun where a wrapper command
3025
3276
  // like `bash -c "some inline" "/real/path.sh"` would pick "some inline"
@@ -3038,7 +3289,7 @@ export function collectOrphanHookPaths(settings, installDir = INSTALL_DIR) {
3038
3289
  path = parts.find((p) => looksLikeHookPath(p)) || null;
3039
3290
  }
3040
3291
  if (!path) continue;
3041
- if (!existsSync(path) && !out.includes(path)) out.push(path);
3292
+ judge(path);
3042
3293
  }
3043
3294
  }
3044
3295
  }
@@ -3229,7 +3480,7 @@ function writeSettings(settings) {
3229
3480
 
3230
3481
  // ─── Cleanup Stale Files ─────────────────────────────────────────────────────
3231
3482
 
3232
- function cleanup() {
3483
+ async function cleanup() {
3233
3484
  // Dogfood-7 addition: --dry-run lists which files would be removed without
3234
3485
  // touching disk. Useful before running cleanup on a remote/CI machine where
3235
3486
  // accidentally pruning the wrong file would be costly. Doctor reports stale
@@ -3238,39 +3489,87 @@ function cleanup() {
3238
3489
  console.log(`\nclaude-mem-lite cleanup${dryRun ? ' (--dry-run)' : ''}\n`);
3239
3490
  let removed = 0;
3240
3491
 
3241
- // Clean .update-staging-* / .update-backup-* — hook-update writes these under
3242
- // DB_DIR (= MEM_DATA_DIR, env-aware), so scan the data dir, not the homedir code dir.
3492
+ // Update residue: `.update-staging-*` / `.update-backup-*` in the CODE dir, where hook-update's
3493
+ // swap writes them. Under CLAUDE_MEM_DIR this scanned only the data dir, where nothing has written
3494
+ // them since v2.90.0; residue an older updater left there is still removed, below. A backup dir
3495
+ // that holds its journal and no resolved marker is a swap whose updater was killed, holding the
3496
+ // only copy of every file the swap had moved out: cleanup finishes it, by replaying the journal
3497
+ // the way the next update entry would, instead of deleting it (D#289).
3243
3498
  //
3244
- // R10 P2-10: take install.lock first. `.update-backup-*` is the ONLY rollback copy of an
3245
- // in-flight update and it holds the journal hook-update replays; `.update-staging-*` is
3246
- // the tree being swapped in. Deleting either mid-update leaves the install unrecoverable,
3247
- // and the window is long — it spans the source-compile fallback, up to five minutes —
3248
- // while doctor is actively telling the user to run cleanup. Non-blocking: if an installer
3249
- // holds the lock we skip only these two patterns, not the rest of cleanup.
3250
- const updateLock = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
3499
+ // R10 P2-10: take install.lock first. A backup dir of a swap running now holds a journal too,
3500
+ // and the staging dir is the tree being swapped in; touching either mid-update leaves the install
3501
+ // unrecoverable. The window is long (it spans the source-compile fallback, up to five minutes),
3502
+ // and doctor tells the user to run cleanup. Non-blocking: if an installer holds the lock we skip
3503
+ // only the update residue, not the rest of cleanup.
3504
+ let finished = 0;
3505
+ // --dry-run writes nothing, so it asks whether the lock is held instead of taking it: taking it
3506
+ // 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);
3251
3509
  if (!updateLock) {
3252
3510
  warn('Update residue skipped: install in progress (install.lock held)');
3253
- } else if (existsSync(MEM_DATA_DIR)) {
3254
- for (const f of readdirSync(MEM_DATA_DIR)) {
3255
- if (isUpdateResidue(f)) {
3256
- if (dryRun) {
3257
- ok(`Would remove: ${f}`);
3258
- removed++;
3259
- continue;
3511
+ } else {
3512
+ try {
3513
+ let found = [];
3514
+ try {
3515
+ found = existsSync(INSTALL_DIR) ? listUpdateResidue() : [];
3516
+ } catch (e) {
3517
+ warn(`Update residue: could not list ${INSTALL_DIR} (${e.code || e.message})`);
3518
+ }
3519
+ if (dryRun) {
3520
+ for (const { f, kind } of found) {
3521
+ if (kind === 'unfinished-swap') {
3522
+ ok(`Would finish an interrupted update: ${f} (puts back the files its journal lists)`);
3523
+ finished++;
3524
+ } else if (kind === 'unreadable-journal') {
3525
+ warn(`Would leave in place: ${f} (its journal cannot be read)`);
3526
+ } else {
3527
+ ok(`Would remove: ${f}`);
3528
+ removed++;
3529
+ }
3260
3530
  }
3261
- try {
3262
- rmSync(join(MEM_DATA_DIR, f), { recursive: true, force: true });
3263
- ok(`Removed: ${f}`);
3264
- removed++;
3265
- } catch (e) {
3266
- warn(`Failed to remove ${f}: ${e.message}`);
3531
+ } else if (found.length > 0) {
3532
+ const r = await recoverUpdateResidue(found);
3533
+ for (const f of r.finished)
3534
+ ok(`Finished an interrupted update: ${f} (put back the files it had moved out)`);
3535
+ for (const f of r.removed) ok(`Removed: ${f}`);
3536
+ for (const f of r.kept)
3537
+ warn(
3538
+ `Left in place: ${f} — its journal cannot be read, so nothing can finish that update, and it holds the files the update moved aside. repair reinstalls over it; cleanup removes it after that.`,
3539
+ );
3540
+ for (const f of r.unfinished)
3541
+ warn(
3542
+ `Could not finish the interrupted update ${f}: a file it moved aside could not be put back (held open, or in a directory that cannot be emptied), or a later one that must be finished first could not be. It stays in place holding what it moved aside; cleanup tries again, and repair reinstalls over it instead.`,
3543
+ );
3544
+ for (const f of r.left) warn(`Failed to remove ${f}`);
3545
+ finished += r.finished.length;
3546
+ removed += r.removed.length;
3547
+ }
3548
+ // A relocated data dir that an updater older than v2.90.0 swapped into: its residue is stale,
3549
+ // 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
+ }
3267
3565
  }
3268
3566
  }
3567
+ } finally {
3568
+ // Released here: the rest of cleanup touches runtime scratch that no installer owns, and
3569
+ // holding install.lock across it would block a self-heal for no reason.
3570
+ updateLock();
3269
3571
  }
3270
3572
  }
3271
- // Release immediately: the rest of cleanup touches runtime scratch that no installer owns,
3272
- // and holding install.lock across it would block a self-heal for no reason.
3273
- if (updateLock) updateLock();
3274
3573
 
3275
3574
  // Clean pending-* / ep-flush-* in runtime/ (env-aware, and honouring the runtime override).
3276
3575
  //
@@ -3322,7 +3621,11 @@ function cleanup() {
3322
3621
  // start of every run (tests/global-setup.mjs → lib/tmp-fixture-sweep.mjs).
3323
3622
 
3324
3623
  const verb = dryRun ? 'would be removed' : 'removed';
3325
- console.log(`\n ${removed === 0 ? 'No stale files found.' : `${removed} stale file(s) ${verb}.`}\n`);
3624
+ const done = [];
3625
+ if (removed > 0) done.push(`${removed} stale file(s) ${verb}`);
3626
+ if (finished > 0)
3627
+ done.push(`${finished} interrupted update(s) ${dryRun ? 'would be finished' : 'finished'}`);
3628
+ console.log(`\n ${done.length === 0 ? 'No stale files found.' : `${done.join('; ')}.`}\n`);
3326
3629
  }
3327
3630
 
3328
3631
  // ─── Manual Update ───────────────────────────────────────────────────────────
@@ -3690,12 +3993,7 @@ function dataDirAccessError(
3690
3993
  dir = MEM_DATA_DIR,
3691
3994
  mode = fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK,
3692
3995
  ) {
3693
- try {
3694
- accessSync(dir, mode);
3695
- return null;
3696
- } catch (e) {
3697
- return e.code === 'ENOENT' ? null : e.code || 'EACCES';
3698
- }
3996
+ return dirAccessError(dir, mode);
3699
3997
  }
3700
3998
 
3701
3999
  // Same directory however it is spelled (a trailing slash, a symlinked HOME). stat works on a
@@ -3710,14 +4008,22 @@ function sameDir(a, b) {
3710
4008
  }
3711
4009
  }
3712
4010
 
3713
- const dataDirAccessRemedy = () =>
3714
- `chmod u+rwx ${shellWord(MEM_DATA_DIR)} (or chown it back to your user if a sudo run created it)`;
4011
+ // The fix for a data dir this user cannot use, chosen by the error code (lib/wal-open-blocked.mjs).
4012
+ export const dataDirAccessRemedy = (code) => dataDirRemedy(code, MEM_DATA_DIR);
4013
+
4014
+ // This process cannot load better-sqlite3: none installed where it runs (a checkout, or a plugin
4015
+ // cache before its first launch installed node_modules), or one that will not load (a stale ABI, a
4016
+ // damaged prebuild). The better-sqlite3 line names and counts that; a line that would open the DB
4017
+ // says it did not look, rather than count the same fault again (D#297, pre-ship review P3-4).
4018
+ const bindingMissing = (e) =>
4019
+ (e?.code === 'ERR_MODULE_NOT_FOUND' && /'better-sqlite3'/.test(e.message)) || isNativeBindingError(e);
4020
+ const BINDING_MISSING_WHY = `better-sqlite3 cannot be loaded from ${PROJECT_DIR}`;
3715
4021
 
3716
4022
  async function runLockedInstall() {
3717
4023
  const denied = dataDirAccessError();
3718
4024
  if (denied) {
3719
4025
  console.error(
3720
- `[install] ${MEM_DATA_DIR} is not accessible (${denied}) — nothing was done. Fix: ${dataDirAccessRemedy()}`,
4026
+ `[install] ${MEM_DATA_DIR} is not accessible (${denied}) — nothing was done. Fix: ${dataDirAccessRemedy(denied)}`,
3721
4027
  );
3722
4028
  process.exitCode = 1;
3723
4029
  return;
@@ -3824,7 +4130,7 @@ async function dispatch(cmd) {
3824
4130
  await cleanupHooks();
3825
4131
  break;
3826
4132
  case 'cleanup':
3827
- cleanup();
4133
+ await cleanup();
3828
4134
  break;
3829
4135
  case 'self-update':
3830
4136
  case 'update':