@sdsrs/code-graph 0.102.0 → 0.104.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/bin/cli.js CHANGED
@@ -45,16 +45,21 @@ if (sub === "uninstall") {
45
45
  if (process.argv.slice(3).some((a) => a === "--help" || a === "-h")) {
46
46
  process.stdout.write(
47
47
  "code-graph-mcp uninstall — remove code-graph config + cache from this machine\n\n" +
48
- "USAGE:\n code-graph-mcp uninstall\n\n" +
48
+ "USAGE:\n code-graph-mcp uninstall [--unadopt-all] [--purge-global]\n\n" +
49
49
  "Restores your prior statusline, strips code-graph hooks from settings.json,\n" +
50
50
  "deletes ~/.cache/code-graph, and removes this project's CLAUDE.md adoption\n" +
51
- "block. Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its\n" +
52
- "UI, and `code-graph-mcp unadopt` in any OTHER adopted project.\n");
51
+ "block. --unadopt-all also removes the managed block + detail file from every\n" +
52
+ "registered adopted project; --purge-global removes the globally-installed\n" +
53
+ "@sdsrs npm packages even without the plugin-install marker. Also run\n" +
54
+ "`/plugin uninstall code-graph-mcp` in Claude Code to sync its UI.\n");
53
55
  process.exit(0);
54
56
  }
55
57
  const lifecycle = require("../claude-plugin/scripts/lifecycle");
56
58
  const { unadopt } = require("../claude-plugin/scripts/adopt");
57
- const r = lifecycle.uninstall({ purgeGlobal: process.argv.slice(3).includes("--purge-global") });
59
+ const r = lifecycle.uninstall({
60
+ purgeGlobal: process.argv.slice(3).includes("--purge-global"),
61
+ unadoptAll: process.argv.slice(3).includes("--unadopt-all"),
62
+ });
58
63
  let ua = { ok: false };
59
64
  try { ua = unadopt(); } catch { /* best-effort — settings/cache already cleaned */ }
60
65
  const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
@@ -69,9 +74,14 @@ if (sub === "uninstall") {
69
74
  ` Remove with: npm uninstall -g ${r.globalPkgsRemaining.join(" ")}` +
70
75
  (r.pluginInstalledGlobals ? "\n" : " (or re-run with --purge-global)\n");
71
76
  }
77
+ if (r.unadopted.length) {
78
+ const cleaned = r.unadopted.filter((u) => u.cleaned).length;
79
+ out += ` Unadopted ${cleaned}/${r.unadopted.length} registered project(s) (--unadopt-all).\n`;
80
+ }
72
81
  const otherAdopted = r.adoptedProjects.filter((p) => p !== process.cwd());
73
82
  if (otherAdopted.length) {
74
- out += " Other adopted project(s) — run `code-graph-mcp unadopt` + `rm -rf .code-graph` in each:\n" +
83
+ out += " Other adopted project(s) — re-run with --unadopt-all, or in each:" +
84
+ " `code-graph-mcp unadopt` + `rm -rf .code-graph`\n" +
75
85
  otherAdopted.map((p) => ` ${p}\n`).join("");
76
86
  }
77
87
  out += " Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its UI state.\n";
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.102.0",
7
+ "version": "0.104.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -9,8 +9,8 @@ const path = require('path');
9
9
  const os = require('os');
10
10
  const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
11
11
  const { claudeHome } = require('./claude-config');
12
- const { clearCache: clearBinaryCache, globalNodeModulesCandidates, PLATFORM_PKG, detectLibc } = require('./find-binary');
13
- const { readBinaryVersion, isDevMode } = require('./version-utils');
12
+ const { clearCache: clearBinaryCache, globalNodeModulesCandidates, nvmNodeModulesDirs, PLATFORM_PKG, detectLibc } = require('./find-binary');
13
+ const { readBinaryVersion, compareVersions, isDevMode } = require('./version-utils');
14
14
  const { cgTmpDir } = require('./tmp-dir');
15
15
  const { npmSpawnOpts } = require('./npm-exec');
16
16
  const { acquireLock } = require('./install-lock');
@@ -112,21 +112,9 @@ function shouldCheck(state, { force = false } = {}) {
112
112
  return elapsed >= interval;
113
113
  }
114
114
 
115
- // ── Version Comparison (semver) ────────────────────────────
116
-
117
- // Assumes plain numeric "M.m.p" releases (the project's tag scheme). A pre-release
118
- // tag (e.g. "1.2.4-rc1") is NOT semver-ordered: `Number("4-rc1")` is NaN → coerced
119
- // to 0, dropping that segment's number (so "1.2.4-rc1" wrongly sorts below "1.2.3").
120
- // Revisit with a real semver compare only if the release process adopts pre-releases.
121
- function compareVersions(a, b) {
122
- const pa = a.split('.').map(Number);
123
- const pb = b.split('.').map(Number);
124
- for (let i = 0; i < 3; i++) {
125
- if ((pa[i] || 0) > (pb[i] || 0)) return 1;
126
- if ((pa[i] || 0) < (pb[i] || 0)) return -1;
127
- }
128
- return 0;
129
- }
115
+ // ── Version Comparison ─────────────────────────────────────
116
+ // compareVersions is imported from version-utils.js (single canonical,
117
+ // pre-release-aware implementation) and re-exported below.
130
118
 
131
119
  // ── GitHub API ─────────────────────────────────────────────
132
120
 
@@ -609,6 +597,31 @@ function staleGlobalPkgs(latestVersion, roots = null) {
609
597
  return out;
610
598
  }
611
599
 
600
+ /**
601
+ * Global installs of ours stranded under a NON-active node version. nvm keeps a
602
+ * separate global prefix per node; switching the default node leaves the old
603
+ * prefix's `@sdsrs/code-graph` behind — invisible to selfHealGlobalPkgs (which
604
+ * only sees, and can only `npm install -g` into, the ACTIVE node's prefix) yet
605
+ * still able to seed stale settings.json hooks / shadow PATH shims (the
606
+ * v24.11.1@0.46.0 relic firing beside the active install — RCA 2026-07-24).
607
+ * Detection-only: returns each relic's package + version + node prefix so doctor
608
+ * can surface it with manual remediation. `dirs`/`activeDir` injectable for tests.
609
+ */
610
+ function inactiveNodeGlobalRelics({ dirs = null, activeDir = null } = {}) {
611
+ const active = path.resolve(activeDir
612
+ || path.join(path.dirname(process.execPath), '..', 'lib', 'node_modules'));
613
+ const roots = dirs || nvmNodeModulesDirs();
614
+ const out = [];
615
+ for (const dir of roots) {
616
+ if (path.resolve(dir) === active) continue; // active prefix → not a relic
617
+ for (const name of [SHELL_PKG, PLATFORM_PKG]) {
618
+ const version = globalPkgVersion(name, [dir]);
619
+ if (version) out.push({ name, version, nodeModulesDir: dir });
620
+ }
621
+ }
622
+ return out;
623
+ }
624
+
612
625
  /** One targeted `npm install -g` for the given specs. Resolves true on exit 0. */
613
626
  function npmInstallGlobal(specs) {
614
627
  return new Promise((resolve) => {
@@ -659,6 +672,21 @@ async function selfHealGlobalPkgs(latest, state, {
659
672
  };
660
673
  }
661
674
 
675
+ // Whether a THROTTLED checkForUpdate should still attempt the global-npm
676
+ // self-heal. The post-fetch heal below only runs on the non-throttle path, but
677
+ // the ONLY context that can SEE a user's nvm/global prefix is a CLI run under
678
+ // that node (globalNodeModulesCandidates is execPath-derived) — and such a run,
679
+ // once binary+shell are current, short-circuits at the throttle early-return and
680
+ // never reaches the heal. That gap stranded a global `code-graph-mcp` shim at
681
+ // 0.101.0 while the binary reached 0.103.0 (RCA 2026-07-24). Cheap local
682
+ // package.json read (readStale) gates the slow, lock-guarded npm path. Split out
683
+ // so the decision is unit-testable without the full checkForUpdate harness.
684
+ function shouldHealGlobalsOnThrottle(state, { readStale = staleGlobalPkgs } = {}) {
685
+ if (!state || !state.latestVersion) return false;
686
+ if (process.env.CODE_GRAPH_INSTALL_LOCK_HELD === '1') return false; // parent launcher holds it
687
+ return readStale(state.latestVersion).length > 0;
688
+ }
689
+
662
690
  async function checkForUpdate({ installMissing = false, force = false } = {}) {
663
691
  let installLock = null;
664
692
  try {
@@ -683,6 +711,16 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
683
711
  if (state.installedVersion !== installedVersion) {
684
712
  saveState({ ...state, installedVersion });
685
713
  }
714
+ // Global-npm shell/platform self-heal reaches the throttle window too (see
715
+ // shouldHealGlobalsOnThrottle). Cheap local check first; only the actually-
716
+ // stale case takes the slow, lock-guarded npm path.
717
+ if (shouldHealGlobalsOnThrottle(state)) {
718
+ installLock = acquireLock(path.join(CACHE_DIR, 'install.lock'));
719
+ if (installLock) {
720
+ const globalHeal = await selfHealGlobalPkgs({ version: state.latestVersion }, state);
721
+ saveState({ ...readState(), ...globalHeal });
722
+ }
723
+ }
686
724
  if (state.updateAvailable && state.latestVersion
687
725
  && compareVersions(state.latestVersion, installedVersion) > 0) {
688
726
  return { updateAvailable: true, from: installedVersion, to: state.latestVersion };
@@ -788,6 +826,7 @@ module.exports = {
788
826
  getPlatformAssetName,
789
827
  selfHealStaleBinary,
790
828
  selfHealGlobalPkgs, staleGlobalPkgs, globalPkgVersion, npmInstallGlobal,
829
+ shouldHealGlobalsOnThrottle, inactiveNodeGlobalRelics,
791
830
  downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
792
831
  };
793
832
 
@@ -285,19 +285,45 @@ function runDiagnostics() {
285
285
  // `lifecycle.js uninstall` removes them; without it they are treated as
286
286
  // user-installed and a plugin uninstall leaves them on PATH.
287
287
  try {
288
- const { globalPkgVersion } = require('./auto-update');
288
+ const { globalPkgVersion, inactiveNodeGlobalRelics } = require('./auto-update');
289
289
  const { PLATFORM_PKG } = require('./find-binary');
290
290
  const found = [SHELL_PKG, PLATFORM_PKG]
291
291
  .map((name) => ({ name, version: globalPkgVersion(name) }))
292
292
  .filter((p) => p.version);
293
+
294
+ // Relics stranded under a NON-active node version (nvm keeps a per-node
295
+ // global prefix). selfHealGlobalPkgs / the check above only see the active
296
+ // node, so these drift unseen for months and can seed stale settings.json
297
+ // hooks — the v24.11.1@0.46.0 relic behind the RCA. Report-only: `npm i -g`
298
+ // can't target another node's prefix, so hand the user the exact remediation.
299
+ const relics = inactiveNodeGlobalRelics();
300
+ if (relics.length) {
301
+ const home = require('os').homedir();
302
+ results.push({
303
+ name: 'Global npm relics',
304
+ status: 'warn',
305
+ detail: relics.map((r) => `${r.name}@${r.version} (${r.nodeModulesDir.replace(home, '~')})`).join('; ')
306
+ + ' — installed under a non-active node version; auto-heal cannot reach another node\'s prefix. '
307
+ + 'Remove each via `nvm use <that node> && npm rm -g <pkg>`, or uninstall the unused node (`nvm uninstall <ver>`).',
308
+ });
309
+ }
310
+
293
311
  if (found.length) {
294
312
  const marker = !!readJson(GLOBAL_INSTALL_MARKER);
313
+ // Heal-exhausted is otherwise invisible: selfHealGlobalPkgs stops after
314
+ // 3 failed npm runs per target version and stays silent until the next
315
+ // release re-arms the counter — a drifted CLI shim just sits there.
316
+ const state = readJson(path.join(CACHE_DIR, 'update-state.json')) || {};
317
+ const healGaveUp = (state.globalPkgHealAttempts || 0) >= 3;
295
318
  results.push({
296
319
  name: 'Global npm packages',
297
- status: 'ok',
298
- detail: found.map((p) => `${p.name}@${p.version}`).join(', ') + (marker
299
- ? 'plugin-installed; `node lifecycle.js uninstall` removes them'
300
- : ` no plugin-install marker; uninstall leaves them (remove: npm uninstall -g ${found.map((p) => p.name).join(' ')})`),
320
+ status: healGaveUp ? 'warn' : 'ok',
321
+ detail: found.map((p) => `${p.name}@${p.version}`).join(', ') + (healGaveUp
322
+ ? `self-heal gave up after ${state.globalPkgHealAttempts} failed npm runs targeting v${state.globalPkgHealVersion}; ` +
323
+ `your npm env likely can't install globally (EACCES/system node). Run manually: npm install -g ${found.map((p) => `${p.name}@${state.globalPkgHealVersion}`).join(' ')}`
324
+ : (marker
325
+ ? ' — plugin-installed; `node lifecycle.js uninstall` removes them'
326
+ : ` — no plugin-install marker; uninstall leaves them (remove: npm uninstall -g ${found.map((p) => p.name).join(' ')})`)),
301
327
  });
302
328
  }
303
329
  } catch { /* probe failed — skip */ }
@@ -4,7 +4,7 @@ const { execFileSync } = require('child_process');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
  const os = require('os');
7
- const { readBinaryVersion } = require('./version-utils');
7
+ const { readBinaryVersion, compareVersions } = require('./version-utils');
8
8
  const { npmSpawnOpts } = require('./npm-exec');
9
9
 
10
10
  const PLATFORM = os.platform();
@@ -66,23 +66,8 @@ function getPackageVersion() {
66
66
  catch { return null; }
67
67
  }
68
68
 
69
- /**
70
- * Compare semver-ish "M.m.p" strings; returns -1, 0, or 1. Non-numeric parts → 0.
71
- * Assumes plain numeric releases (the project's tag scheme); a pre-release tag
72
- * (e.g. "1.2.3-rc1") is NOT semver-ordered — `parseInt("3-rc1", 10)` keeps the
73
- * leading 3 and drops the suffix, so "1.2.3-rc1" compares EQUAL to "1.2.3".
74
- * Revisit only if releases adopt pre-release tags.
75
- */
76
- function compareVersions(a, b) {
77
- const pa = String(a).split('.').map(s => parseInt(s, 10));
78
- const pb = String(b).split('.').map(s => parseInt(s, 10));
79
- for (let i = 0; i < 3; i++) {
80
- const x = Number.isFinite(pa[i]) ? pa[i] : 0;
81
- const y = Number.isFinite(pb[i]) ? pb[i] : 0;
82
- if (x !== y) return x < y ? -1 : 1;
83
- }
84
- return 0;
85
- }
69
+ // compareVersions lives in version-utils.js (single canonical implementation,
70
+ // pre-release-aware); re-exported below for existing consumers.
86
71
 
87
72
  /**
88
73
  * Candidate paths for npm global `node_modules`.
@@ -134,6 +119,22 @@ function globalNodeModulesCandidates() {
134
119
  return [...new Set(out)];
135
120
  }
136
121
 
122
+ // Every nvm-managed node version's global node_modules dir (`~/.nvm/versions/
123
+ // node/*/lib/node_modules`). nvm keeps a SEPARATE global prefix per node
124
+ // version; switching the default node strands the previous version's globals —
125
+ // a global `@sdsrs/code-graph` there is invisible to globalNodeModulesCandidates
126
+ // (execPath-derived → only the ACTIVE node) yet still shadows PATH shims / seeds
127
+ // stale settings.json hooks (the v24.11.1@0.46.0 relic — RCA 2026-07-24). Used
128
+ // for detection/reporting only; `npm install -g` cannot target another node's
129
+ // prefix. `base` is injectable for hermetic tests (never the real ~/.nvm).
130
+ function nvmNodeModulesDirs(base = path.join(os.homedir(), '.nvm', 'versions', 'node')) {
131
+ let entries;
132
+ try { entries = fs.readdirSync(base); } catch { return []; }
133
+ return entries
134
+ .map((v) => path.join(base, v, 'lib', 'node_modules'))
135
+ .filter((d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } });
136
+ }
137
+
137
138
  function isNativeBinary(candidate) {
138
139
  if (!candidate) return false;
139
140
  try {
@@ -217,19 +218,29 @@ function isDevRepo(rootDir) {
217
218
  * nvm/standard setups), so a working `npm install -g @sdsrs/code-graph` can
218
219
  * still be invisible without the fallback.
219
220
  */
221
+ // Truncation gate for the npm platform-package tier ONLY: an interrupted npm
222
+ // install can leave a partial binary with the right name, and unlike the
223
+ // GitHub-download path (size + sha256 sidecar + version-exec before promote)
224
+ // nothing else checks this tier. Real release binaries are ~40MB; 1MB matches
225
+ // promoteVerifiedBinary's floor. Deliberately NOT inside isNativeBinary —
226
+ // dev builds, cargo installs, and test fixtures go through other tiers.
227
+ function isPlausibleReleaseBinary(candidate) {
228
+ try { return fs.statSync(candidate).size > 1_000_000; } catch { return false; }
229
+ }
230
+
220
231
  function platformBinaryCandidates() {
221
232
  const out = [];
222
233
  // Fast path: standard module resolution.
223
234
  try {
224
235
  const pkgPath = require.resolve(`${PLATFORM_PKG}/package.json`);
225
236
  const bin = path.join(path.dirname(pkgPath), BINARY_NAME);
226
- if (isNativeBinary(bin)) out.push(bin);
237
+ if (isNativeBinary(bin) && isPlausibleReleaseBinary(bin)) out.push(bin);
227
238
  } catch { /* not in node_modules walk-up */ }
228
239
 
229
240
  // Slow path: explicit global node_modules probe.
230
241
  for (const globalRoot of globalNodeModulesCandidates()) {
231
242
  const bin = path.join(globalRoot, '@sdsrs', `code-graph-${PLATFORM}-${ARCH}`, BINARY_NAME);
232
- if (isNativeBinary(bin)) out.push(bin);
243
+ if (isNativeBinary(bin) && isPlausibleReleaseBinary(bin)) out.push(bin);
233
244
  }
234
245
 
235
246
  return out;
@@ -372,7 +383,7 @@ function clearCache() {
372
383
 
373
384
  module.exports = {
374
385
  findBinary, findBinaryUncached, clearCache,
375
- globalNodeModulesCandidates, findPlatformBinary, createVersionGate,
386
+ globalNodeModulesCandidates, nvmNodeModulesDirs, findPlatformBinary, platformBinaryCandidates, createVersionGate,
376
387
  getPackageVersion, compareVersions, isCachedBinaryFresh,
377
388
  detectLibc, unsupportedPlatformHint,
378
389
  CACHE_FILE, BINARY_NAME, PLATFORM_PKG,
@@ -197,25 +197,41 @@ function isPluginInactive(settings = readJson(settingsPath()) || {}) {
197
197
  return !hasInstalledPluginRecord();
198
198
  }
199
199
 
200
- function detachStatuslineIntegration(settings) {
200
+ function detachStatuslineIntegration(settings, { compositeDoomed = true } = {}) {
201
201
  let settingsChanged = false;
202
202
 
203
203
  unregisterStatuslineProvider('code-graph');
204
- const previous = readRegistry().find(p => p.id === '_previous' && p.command);
204
+ const registry = readRegistry();
205
+ const previous = registry.find(p => p.id === '_previous' && p.command);
206
+ // Third-party providers registered through our registry (e.g. gsd). They
207
+ // must not be silently orphaned: with the composite gone from settings
208
+ // their segments stop rendering while the registry entries dangle.
209
+ const thirdParty = registry.filter(p => p.id !== '_previous' && p.id !== 'code-graph' && p.command);
205
210
 
206
211
  // If our composite is still configured while the plugin is disabled/uninstalled,
207
- // prefer restoring the prior statusline (or removing ours entirely) so the plugin
208
- // truly stops affecting Claude Code.
212
+ // stop affecting Claude Code but keep surviving third parties rendering.
209
213
  if (isOurComposite(settings)) {
210
- if (previous) {
214
+ if (thirdParty.length > 0 && !compositeDoomed) {
215
+ // Temporary disable: the composite script survives on disk and keeps
216
+ // rendering the remaining providers — only our segment was unregistered.
217
+ } else if (thirdParty.length > 0) {
218
+ // Genuine uninstall: our composite runner dies with the plugin cache.
219
+ // Hand the slot to the first surviving third-party provider; the rest
220
+ // stay listed in the registry backup for manual re-wiring.
221
+ settings.statusLine = { type: 'command', command: thirdParty[0].command };
222
+ settingsChanged = true;
223
+ } else if (previous) {
211
224
  settings.statusLine = { type: 'command', command: previous.command };
225
+ settingsChanged = true;
212
226
  } else {
213
227
  delete settings.statusLine;
228
+ settingsChanged = true;
214
229
  }
215
- settingsChanged = true;
216
230
  }
217
231
 
218
- unregisterStatuslineProvider('_previous');
232
+ // _previous only becomes removable once no third party still relies on the
233
+ // registry file (writeRegistry unlinks primary+backup when emptied).
234
+ if (thirdParty.length === 0) unregisterStatuslineProvider('_previous');
219
235
  return settingsChanged;
220
236
  }
221
237
 
@@ -229,7 +245,7 @@ function cleanupDisabledStatusline() {
229
245
  // registry markers detachStatuslineIntegration is about to remove.
230
246
  const uninstalled = isPluginUninstalled(settings);
231
247
 
232
- let settingsChanged = detachStatuslineIntegration(settings);
248
+ let settingsChanged = detachStatuslineIntegration(settings, { compositeDoomed: uninstalled });
233
249
  if (removeHooksFromSettings(settings)) settingsChanged = true;
234
250
  if (settingsChanged) {
235
251
  writeJsonAtomic(settingsPath(), settings);
@@ -359,13 +375,17 @@ function isOurHookEntry(entry) {
359
375
  if (!entry || !entry.hooks) return false;
360
376
  // Primary: match by description (immune to path pollution).
361
377
  if (entry.description && OUR_DESCRIPTIONS.includes(entry.description)) return true;
362
- // Fallback: script name + MARKETPLACE_NAME in path. v0.32.1: tightened from
363
- // bare 'code-graph' (which would claim a user's own ~/code-graph/foo.js) to
364
- // the actual marketplace dir name 'code-graph-mcp' — Requirement 3 says
365
- // foreign-entry strip is unacceptable, so be conservative.
378
+ // Fallback: script basename + a delivery-surface marker in the path. TWO
379
+ // surfaces ship these scripts: the marketplace plugin-cache (dir
380
+ // 'code-graph-mcp') AND the global npm package (dir '@sdsrs/code-graph' — note
381
+ // NO '-mcp' suffix). v0.32.1 tightened from bare 'code-graph' (which would
382
+ // claim a user's own ~/code-graph/foo.js) to MARKETPLACE_NAME, but that alone
383
+ // missed the npm-global surface, so `npm i -g`-delivered hooks were never
384
+ // evicted and orphan-accumulated across node/version switches (RCA 2026-07-24).
385
+ // Both markers are specific enough not to claim a user's unrelated file.
366
386
  return entry.hooks.some(h =>
367
387
  h.command && OUR_HOOK_SCRIPTS.some(s => h.command.includes(s)) &&
368
- h.command.includes(MARKETPLACE_NAME)
388
+ (h.command.includes(MARKETPLACE_NAME) || h.command.includes(SHELL_PKG))
369
389
  );
370
390
  }
371
391
 
@@ -393,11 +413,20 @@ function removeHooksFromSettings(settings) {
393
413
 
394
414
  function buildSettingsHookEntries() {
395
415
  const root = PLUGIN_ROOT;
396
- const scriptCmd = (name, timeout) => ({
397
- type: 'command',
398
- command: `node "${path.join(root, 'scripts', name)}"`,
399
- timeout,
400
- });
416
+ const scriptCmd = (name, timeout) => {
417
+ const script = path.join(root, 'scripts', name);
418
+ // POSIX: existence-guarded. After `/plugin uninstall`, CC may delete the
419
+ // plugin-cache dir before our statusline teardown gets to strip these
420
+ // entries — in that window every Edit/Bash/Read/prompt errored on a dead
421
+ // path. The `if` form preserves node's own exit code (PreToolUse deny =
422
+ // exit 2); `&& … || exit 0` would swallow it. Windows keeps the bare
423
+ // command — the hook shell there is not reliably cmd, so `if exist`
424
+ // can't be assumed.
425
+ const command = process.platform === 'win32'
426
+ ? `node "${script}"`
427
+ : `if [ -f "${script}" ]; then node "${script}"; fi`;
428
+ return { type: 'command', command, timeout };
429
+ };
401
430
 
402
431
  return {
403
432
  PreToolUse: [
@@ -423,6 +452,23 @@ function buildSettingsHookEntries() {
423
452
  // re-write it to settings.json.
424
453
  function registerHooksToSettings(settings) {
425
454
  settings.hooks = settings.hooks || {};
455
+
456
+ // Idempotent across delivery surfaces: if every desired (event,matcher) is
457
+ // already present exactly once, pointing at a current, existing script
458
+ // (plugin-cache OR global-npm), do nothing. Stops the settings.json ping-pong
459
+ // where the cache session-init and the npm-global CLI doctor each rewrote the
460
+ // other's valid entry every run (RCA 2026-07-24). Any missing/stale/dead entry
461
+ // — or a duplicate ( oursCount > expected) — still triggers evict+rewrite.
462
+ const survey = surveyHookCoverage(settings);
463
+ let oursCount = 0;
464
+ for (const entries of Object.values(settings.hooks)) {
465
+ if (Array.isArray(entries)) oursCount += entries.filter(isOurHookEntry).length;
466
+ }
467
+ if (survey.missing.length === 0 && survey.stale.length === 0
468
+ && oursCount === survey.expected.length) {
469
+ return false;
470
+ }
471
+
426
472
  const before = JSON.stringify(settings.hooks);
427
473
 
428
474
  // Pass 1: evict our entries across every event.
@@ -442,6 +488,22 @@ function registerHooksToSettings(settings) {
442
488
  return before !== JSON.stringify(settings.hooks);
443
489
  }
444
490
 
491
+ // Extract the .js script path a hook command invokes — bare (`node "…"`) or
492
+ // existence-guarded (`if [ -f "…" ]; then node "…"; fi`).
493
+ function hookCmdScript(cmd) {
494
+ const m = (cmd || '').match(/node "([^"]+\.js)"/) || (cmd || '').match(/"([^"]+\.js)"/);
495
+ return m ? m[1] : null;
496
+ }
497
+
498
+ // Version encoded in a plugin-cache path (.../code-graph-mcp/code-graph-mcp/<ver>/scripts/…).
499
+ // Null for in-place installs (global npm), whose path never carries a version
500
+ // dir — npm overwrites the same path on upgrade, so such a path never goes
501
+ // version-stale (only dead-path-stale, caught separately by fs.existsSync).
502
+ function cacheDirVersion(scriptPath) {
503
+ const m = (scriptPath || '').match(/\/code-graph-mcp\/code-graph-mcp\/(\d+\.\d+\.\d+[^/]*)\//);
504
+ return m ? m[1] : null;
505
+ }
506
+
445
507
  // Inventory of (event, matcher) tuples we expect to find in settings.json after
446
508
  // install. Consumed by doctor (report + fix) and session-init (self-heal):
447
509
  // `missing` = entry absent; `stale` = present but the registered command no
@@ -479,10 +541,26 @@ function surveyHookCoverage(settings) {
479
541
  }
480
542
  }
481
543
 
544
+ const { compareVersions } = require('./version-utils');
482
545
  const missing = expected.filter(k => !present.has(k));
483
- const stale = expected.filter(k =>
484
- present.has(k) && desiredCmd[k] && presentCmd[k] && presentCmd[k] !== desiredCmd[k]
485
- );
546
+ // Version/surface-tolerant staleness. Was an exact command-string compare,
547
+ // which made two registration authorities (plugin-cache session-init vs
548
+ // global-npm CLI doctor — different absolute paths) each flag the other's
549
+ // VALID CURRENT entry stale and rewrite it → settings.json ping-pong on every
550
+ // alternating run (RCA 2026-07-24). An entry is stale only when its script is
551
+ // a dead path OR resolves to an OLDER plugin-cache version dir than we'd write
552
+ // now. A present entry on a different but valid, current surface (npm in-place
553
+ // install: file exists, no version in path) is NOT stale.
554
+ const stale = expected.filter(k => {
555
+ if (!present.has(k) || !presentCmd[k]) return false;
556
+ const pScript = hookCmdScript(presentCmd[k]);
557
+ if (!pScript) return false;
558
+ if (!fs.existsSync(pScript)) return true; // dead path
559
+ const pv = cacheDirVersion(pScript);
560
+ const dv = cacheDirVersion(hookCmdScript(desiredCmd[k]));
561
+ if (pv && dv) return compareVersions(pv, dv) < 0; // older cache version dir
562
+ return false; // in-place/current surface
563
+ });
486
564
  return { expected, present: [...present], missing, stale };
487
565
  }
488
566
 
@@ -604,7 +682,7 @@ function verifyHooksFire({ hooks, env, timeoutMs = 4000, tmpBase } = {}) {
604
682
 
605
683
  // --- Install (idempotent) ---
606
684
 
607
- function install() {
685
+ function install({ reclaimStatusline = false } = {}) {
608
686
  const version = getPluginVersion();
609
687
  const manifest = readManifest();
610
688
  const settings = readJson(settingsPath()) || {};
@@ -620,14 +698,40 @@ function install() {
620
698
  // b. Register code-graph as a provider
621
699
  // c. Set statusLine to composite script
622
700
  if (!isOurComposite(settings)) {
623
- // Preserve existing statusline as first provider
624
- if (settings.statusLine && settings.statusLine.command) {
625
- registerStatuslineProvider('_previous', settings.statusLine.command, true);
701
+ // Displacement tracking: we held the slot before (manifest.config.statusLine)
702
+ // but a foreign command sits there now — either another slot-claiming plugin
703
+ // (whose own self-heal re-takes it just like ours would → statusline
704
+ // ping-pong every session) or the user's deliberate choice. Either way,
705
+ // silently re-claiming forever is wrong: after >2 observed displacements
706
+ // stand down — stay registered as a provider, leave the slot alone.
707
+ // Explicit `lifecycle.js install` (or CODE_GRAPH_FORCE_STATUSLINE=1)
708
+ // resets the counter and re-claims.
709
+ const currentCmd = settings.statusLine && settings.statusLine.command;
710
+ if (reclaimStatusline || process.env.CODE_GRAPH_FORCE_STATUSLINE === '1') {
711
+ manifest.config.statuslineDisplaced = 0;
712
+ } else if (manifest.config.statusLine === true && currentCmd) {
713
+ manifest.config.statuslineDisplaced = (manifest.config.statuslineDisplaced || 0) + 1;
714
+ }
715
+ if ((manifest.config.statuslineDisplaced || 0) > 2) {
716
+ if (manifest.config.statusLine === true) {
717
+ // Transition into stand-down exactly once: release claimed ownership
718
+ // (stops the counter) and leave a breadcrumb.
719
+ manifest.config.statusLine = false;
720
+ process.stderr.write(
721
+ '[code-graph] statusLine slot keeps being re-claimed by another provider — standing down.\n' +
722
+ ' Re-claim: CODE_GRAPH_FORCE_STATUSLINE=1 or `node lifecycle.js install`\n'
723
+ );
724
+ }
725
+ } else {
726
+ // Preserve existing statusline as first provider
727
+ if (currentCmd) {
728
+ registerStatuslineProvider('_previous', currentCmd, true);
729
+ }
730
+ // Set composite as the statusLine
731
+ settings.statusLine = { type: 'command', command: compositeCommand() };
732
+ settingsChanged = true;
733
+ manifest.config.statusLine = true;
626
734
  }
627
- // Set composite as the statusLine
628
- settings.statusLine = { type: 'command', command: compositeCommand() };
629
- settingsChanged = true;
630
- manifest.config.statusLine = true;
631
735
  } else {
632
736
  // Composite exists — ensure path is correct (may have been polluted by env leak)
633
737
  const cmd = compositeCommand();
@@ -635,6 +739,9 @@ function install() {
635
739
  settings.statusLine.command = cmd;
636
740
  settingsChanged = true;
637
741
  }
742
+ // We hold the slot — any displacement episode is over.
743
+ if (manifest.config.statuslineDisplaced) manifest.config.statuslineDisplaced = 0;
744
+ manifest.config.statusLine = true;
638
745
  }
639
746
 
640
747
  // Register code-graph provider
@@ -690,7 +797,7 @@ function defaultRunNpm(args) {
690
797
  } catch { return false; }
691
798
  }
692
799
 
693
- function uninstall({ purgeGlobal = false, runNpm = defaultRunNpm, scanGlobalPkgs = installedGlobalPkgs } = {}) {
800
+ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRunNpm, scanGlobalPkgs = installedGlobalPkgs } = {}) {
694
801
  const settings = readJson(settingsPath());
695
802
  let settingsChanged = false;
696
803
 
@@ -742,6 +849,27 @@ function uninstall({ purgeGlobal = false, runNpm = defaultRunNpm, scanGlobalPkgs
742
849
  const pluginInstalledGlobals = !!readJson(GLOBAL_INSTALL_MARKER);
743
850
  let adoptedProjects = [];
744
851
  try { adoptedProjects = require('./adopt').readAdoptedProjects(); } catch { /* POSIX-only helper — ok */ }
852
+
853
+ // 5.4. --unadopt-all: sweep every registered project's managed CLAUDE.md
854
+ // block + generated detail file (unadopt is marker-guarded, so user files
855
+ // are never touched; the .code-graph/ index dir is project DATA and stays —
856
+ // its removal is listed in the guidance instead of automated).
857
+ const unadopted = [];
858
+ if (unadoptAll && adoptedProjects.length) {
859
+ let unadoptFn = null;
860
+ try { unadoptFn = require('./adopt').unadopt; } catch { /* POSIX-only — skip */ }
861
+ if (unadoptFn) {
862
+ for (const project of adoptedProjects) {
863
+ try {
864
+ const r = unadoptFn({ cwd: project });
865
+ unadopted.push({ project, ok: !!(r && r.ok), cleaned: !!(r && (r.blockPruned || r.fileRemoved || r.claudeMdRemoved)) });
866
+ } catch (e) {
867
+ unadopted.push({ project, ok: false, error: (e && e.message) || String(e) });
868
+ }
869
+ }
870
+ try { adoptedProjects = require('./adopt').readAdoptedProjects(); } catch { /* ok */ }
871
+ }
872
+ }
745
873
  let globalPkgsRemoved = [];
746
874
  let globalPkgsRemaining = scanGlobalPkgs();
747
875
  if (globalPkgsRemaining.length && (pluginInstalledGlobals || purgeGlobal)) {
@@ -765,7 +893,7 @@ function uninstall({ purgeGlobal = false, runNpm = defaultRunNpm, scanGlobalPkgs
765
893
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
766
894
  }
767
895
 
768
- return { settingsChanged, pluginInstalledGlobals, globalPkgsRemoved, globalPkgsRemaining, adoptedProjects };
896
+ return { settingsChanged, pluginInstalledGlobals, globalPkgsRemoved, globalPkgsRemaining, adoptedProjects, unadopted };
769
897
  }
770
898
 
771
899
  // --- Update (refresh config points) ---
@@ -1020,11 +1148,23 @@ module.exports = {
1020
1148
  if (require.main === module) {
1021
1149
  const cmd = process.argv[2];
1022
1150
  if (cmd === 'install') {
1023
- const r = install();
1151
+ // Explicit CLI install = user intent: reset any statusline stand-down and re-claim.
1152
+ const r = install({ reclaimStatusline: true });
1024
1153
  console.log(`Installed v${r.version} | settings=${r.settingsChanged} | statusLine=${r.statusLineClaimed}`);
1025
1154
  } else if (cmd === 'uninstall') {
1026
- const r = uninstall({ purgeGlobal: process.argv.includes('--purge-global') });
1155
+ const r = uninstall({
1156
+ purgeGlobal: process.argv.includes('--purge-global'),
1157
+ unadoptAll: process.argv.includes('--unadopt-all'),
1158
+ });
1027
1159
  console.log(`Uninstalled | settings cleaned=${r.settingsChanged}`);
1160
+ if (r.unadopted.length) {
1161
+ const cleaned = r.unadopted.filter((u) => u.cleaned).length;
1162
+ console.log(` Unadopted ${cleaned}/${r.unadopted.length} registered project(s):`);
1163
+ for (const u of r.unadopted) {
1164
+ console.log(` ${u.ok ? (u.cleaned ? 'cleaned' : 'nothing-to-clean') : `FAILED (${u.error || 'unknown'})`} ${u.project}`);
1165
+ }
1166
+ console.log(' Their .code-graph/ index dirs are project data — remove per project with `rm -rf .code-graph` if unwanted.');
1167
+ }
1028
1168
  if (r.globalPkgsRemoved.length) {
1029
1169
  console.log(` Removed global npm package(s): ${r.globalPkgsRemoved.join(', ')}`);
1030
1170
  }
@@ -1038,7 +1178,7 @@ if (require.main === module) {
1038
1178
  if (r.adoptedProjects.length) {
1039
1179
  console.log(' Adopted project(s) still carrying a managed CLAUDE.md block + .code-graph/ index:');
1040
1180
  for (const p of r.adoptedProjects) console.log(` ${p}`);
1041
- console.log(' In each: run `code-graph-mcp unadopt` and `rm -rf .code-graph` to clean up.');
1181
+ console.log(' Clean all at once: re-run with --unadopt-all, or per project `code-graph-mcp unadopt` + `rm -rf .code-graph`.');
1042
1182
  }
1043
1183
  console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state.');
1044
1184
  } else if (cmd === 'update') {
@@ -150,6 +150,10 @@ if (!binary) {
150
150
 
151
151
  const stub = serveEmptyMcpStub({
152
152
  upgrade: {
153
+ // Each probe is a full discovery walk (incl. `npm root -g`, up to 2s);
154
+ // offline the binary never appears, so back the poll off toward 60s.
155
+ // The install chain's onInstalled nudge below still upgrades instantly.
156
+ backoff: true,
153
157
  shouldUpgrade: () => !!findBinary(),
154
158
  spawnReal: () => {
155
159
  const bin = findBinary();
@@ -52,6 +52,8 @@ function serveEmptyMcpStub(opts = {}) {
52
52
  const queuedForChild = []; // client lines seen after spawn, before child is ready
53
53
  let poller = null;
54
54
  let upgradeFailures = 0; // consecutive failed upgrade attempts (see noteUpgradeFailure)
55
+ let backoffTicks = 0; // poll ticks to skip before the next probe (upgrade.backoff)
56
+ let backoffNext = 1;
55
57
 
56
58
  function writeCc(obj) { output.write(JSON.stringify(obj) + '\n'); }
57
59
 
@@ -133,7 +135,20 @@ function serveEmptyMcpStub(opts = {}) {
133
135
 
134
136
  function attemptUpgrade() {
135
137
  if (child || !upgrade) return;
136
- if (!upgrade.shouldUpgrade()) return; // not a project yet — not a failure
138
+ if (!upgrade.shouldUpgrade()) {
139
+ // Not upgradable yet — not a failure. But when the probe itself is
140
+ // expensive (missing-binary: full discovery walk incl. `npm root -g`,
141
+ // up to 2s), a flat 4s cadence for a whole offline session is pure
142
+ // subprocess churn. With { backoff:true } skip a doubling number of
143
+ // ticks between probes, capped near 60s; a manual attemptUpgrade()
144
+ // nudge (install chain's onInstalled) still probes immediately.
145
+ if (upgrade.backoff) {
146
+ const pollMs = upgrade.pollMs || DEFAULT_POLL_MS;
147
+ backoffTicks = backoffNext;
148
+ backoffNext = Math.min(backoffNext * 2, Math.max(1, Math.floor(60000 / pollMs) - 1));
149
+ }
150
+ return;
151
+ }
137
152
  const spawned = upgrade.spawnReal();
138
153
  if (!spawned) { noteUpgradeFailure('binary-unresolved'); return; }
139
154
  if (poller) { clearIv(poller); poller = null; }
@@ -141,6 +156,13 @@ function serveEmptyMcpStub(opts = {}) {
141
156
  beginProxy();
142
157
  }
143
158
 
159
+ // Poll-timer tick: honors the backoff skip counter; the exported
160
+ // attemptUpgrade stays direct so external nudges are never delayed.
161
+ function pollTick() {
162
+ if (backoffTicks > 0) { backoffTicks--; return; }
163
+ attemptUpgrade();
164
+ }
165
+
144
166
  function fallBackToStub(reason) {
145
167
  // Child spawned but died/errored before it was ready: answer anything the
146
168
  // client queued (so it doesn't hang on those ids), resume polling, and count
@@ -153,7 +175,7 @@ function serveEmptyMcpStub(opts = {}) {
153
175
  try { const req = JSON.parse(line); if (req && typeof req.method === 'string') answerAsStub(req); }
154
176
  catch { /* ignore */ }
155
177
  }
156
- if (upgrade && !poller) poller = setIv(attemptUpgrade, upgrade.pollMs || DEFAULT_POLL_MS);
178
+ if (upgrade && !poller) poller = setIv(pollTick, upgrade.pollMs || DEFAULT_POLL_MS);
157
179
  noteUpgradeFailure(reason);
158
180
  }
159
181
 
@@ -206,7 +228,7 @@ function serveEmptyMcpStub(opts = {}) {
206
228
  });
207
229
  }
208
230
 
209
- if (upgrade) poller = setIv(attemptUpgrade, upgrade.pollMs || DEFAULT_POLL_MS);
231
+ if (upgrade) poller = setIv(pollTick, upgrade.pollMs || DEFAULT_POLL_MS);
210
232
 
211
233
  return { attemptUpgrade, _state: () => ({ hasChild: !!child, childReady }) };
212
234
  }
@@ -553,9 +553,15 @@ function runSessionInit({ source } = {}) {
553
553
  }
554
554
  if (autoAdopt.attempted && autoAdopt.result && autoAdopt.result.ok) {
555
555
  if (autoAdopt.reason === 'refreshed') {
556
+ // Name BOTH refreshed surfaces: the drift-refresh fully overwrites the
557
+ // generated detail doc, so a user who hand-edited it must learn why
558
+ // their edits vanished and how to lock the file.
559
+ const detailNote = autoAdopt.result.detailWritten
560
+ ? ' + .claude/plugin_code_graph_mcp.md (manual edits to that generated file are overwritten)'
561
+ : '';
556
562
  process.stderr.write(
557
- '[code-graph] Refreshed CLAUDE.md decision block to latest shipped version.\n' +
558
- ' Lock file: CODE_GRAPH_NO_TEMPLATE_REFRESH=1 in ~/.claude/settings.json env\n'
563
+ `[code-graph] Refreshed CLAUDE.md decision block to latest shipped version${detailNote}.\n` +
564
+ ' Lock files: CODE_GRAPH_NO_TEMPLATE_REFRESH=1 in ~/.claude/settings.json env\n'
559
565
  );
560
566
  } else {
561
567
  process.stderr.write(
@@ -26,6 +26,37 @@ function readBinaryVersion(binaryPath) {
26
26
  }
27
27
  }
28
28
 
29
+ /**
30
+ * Compare semver-ish version strings; returns -1, 0, or 1. Numeric triple
31
+ * compared ordinally (missing/non-numeric parts → 0); a pre-release suffix
32
+ * sorts BELOW its release ("1.2.3-rc1" < "1.2.3"), two pre-releases compare
33
+ * as plain strings. Single canonical implementation — auto-update.js and
34
+ * find-binary.js each carried a divergent copy whose pre-release semantics
35
+ * disagreed (Number("4-rc1")→NaN→0 vs parseInt("3-rc1")→3), a silent
36
+ * mis-ordering trap if release tags ever adopt "-rc" suffixes.
37
+ */
38
+ function compareVersions(a, b) {
39
+ const parse = (v) => {
40
+ const s = String(v);
41
+ const dash = s.indexOf('-');
42
+ const core = dash === -1 ? s : s.slice(0, dash);
43
+ return {
44
+ nums: core.split('.').map((x) => parseInt(x, 10)),
45
+ pre: dash === -1 ? null : s.slice(dash + 1),
46
+ };
47
+ };
48
+ const pa = parse(a), pb = parse(b);
49
+ for (let i = 0; i < 3; i++) {
50
+ const x = Number.isFinite(pa.nums[i]) ? pa.nums[i] : 0;
51
+ const y = Number.isFinite(pb.nums[i]) ? pb.nums[i] : 0;
52
+ if (x !== y) return x < y ? -1 : 1;
53
+ }
54
+ if (pa.pre && !pb.pre) return -1;
55
+ if (!pa.pre && pb.pre) return 1;
56
+ if (pa.pre && pb.pre && pa.pre !== pb.pre) return pa.pre < pb.pre ? -1 : 1;
57
+ return 0;
58
+ }
59
+
29
60
  function isDevMode(pluginRoot = path.resolve(__dirname, '..')) {
30
61
  // Explicit opt-in always wins (also lets users force dev mode in any layout)
31
62
  if (process.env.CODE_GRAPH_DEV === '1') return true;
@@ -63,4 +94,4 @@ function getNewestMtime(dir, ext = '.rs') {
63
94
  return newest;
64
95
  }
65
96
 
66
- module.exports = { readBinaryVersion, isDevMode, getNewestMtime, VERSION_OUTPUT_RE };
97
+ module.exports = { readBinaryVersion, compareVersions, isDevMode, getNewestMtime, VERSION_OUTPUT_RE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.102.0",
3
+ "version": "0.104.0",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,10 +36,10 @@
36
36
  "node": ">=16"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@sdsrs/code-graph-linux-x64": "0.102.0",
40
- "@sdsrs/code-graph-linux-arm64": "0.102.0",
41
- "@sdsrs/code-graph-darwin-x64": "0.102.0",
42
- "@sdsrs/code-graph-darwin-arm64": "0.102.0",
43
- "@sdsrs/code-graph-win32-x64": "0.102.0"
39
+ "@sdsrs/code-graph-linux-x64": "0.104.0",
40
+ "@sdsrs/code-graph-linux-arm64": "0.104.0",
41
+ "@sdsrs/code-graph-darwin-x64": "0.104.0",
42
+ "@sdsrs/code-graph-darwin-arm64": "0.104.0",
43
+ "@sdsrs/code-graph-win32-x64": "0.104.0"
44
44
  }
45
45
  }