@sdsrs/code-graph 0.102.0 → 0.103.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.103.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -10,7 +10,7 @@ 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
12
  const { clearCache: clearBinaryCache, globalNodeModulesCandidates, PLATFORM_PKG, detectLibc } = require('./find-binary');
13
- const { readBinaryVersion, isDevMode } = require('./version-utils');
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
 
@@ -292,12 +292,20 @@ function runDiagnostics() {
292
292
  .filter((p) => p.version);
293
293
  if (found.length) {
294
294
  const marker = !!readJson(GLOBAL_INSTALL_MARKER);
295
+ // Heal-exhausted is otherwise invisible: selfHealGlobalPkgs stops after
296
+ // 3 failed npm runs per target version and stays silent until the next
297
+ // release re-arms the counter — a drifted CLI shim just sits there.
298
+ const state = readJson(path.join(CACHE_DIR, 'update-state.json')) || {};
299
+ const healGaveUp = (state.globalPkgHealAttempts || 0) >= 3;
295
300
  results.push({
296
301
  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(' ')})`),
302
+ status: healGaveUp ? 'warn' : 'ok',
303
+ detail: found.map((p) => `${p.name}@${p.version}`).join(', ') + (healGaveUp
304
+ ? `self-heal gave up after ${state.globalPkgHealAttempts} failed npm runs targeting v${state.globalPkgHealVersion}; ` +
305
+ `your npm env likely can't install globally (EACCES/system node). Run manually: npm install -g ${found.map((p) => `${p.name}@${state.globalPkgHealVersion}`).join(' ')}`
306
+ : (marker
307
+ ? ' — plugin-installed; `node lifecycle.js uninstall` removes them'
308
+ : ` — no plugin-install marker; uninstall leaves them (remove: npm uninstall -g ${found.map((p) => p.name).join(' ')})`)),
301
309
  });
302
310
  }
303
311
  } 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`.
@@ -217,19 +202,29 @@ function isDevRepo(rootDir) {
217
202
  * nvm/standard setups), so a working `npm install -g @sdsrs/code-graph` can
218
203
  * still be invisible without the fallback.
219
204
  */
205
+ // Truncation gate for the npm platform-package tier ONLY: an interrupted npm
206
+ // install can leave a partial binary with the right name, and unlike the
207
+ // GitHub-download path (size + sha256 sidecar + version-exec before promote)
208
+ // nothing else checks this tier. Real release binaries are ~40MB; 1MB matches
209
+ // promoteVerifiedBinary's floor. Deliberately NOT inside isNativeBinary —
210
+ // dev builds, cargo installs, and test fixtures go through other tiers.
211
+ function isPlausibleReleaseBinary(candidate) {
212
+ try { return fs.statSync(candidate).size > 1_000_000; } catch { return false; }
213
+ }
214
+
220
215
  function platformBinaryCandidates() {
221
216
  const out = [];
222
217
  // Fast path: standard module resolution.
223
218
  try {
224
219
  const pkgPath = require.resolve(`${PLATFORM_PKG}/package.json`);
225
220
  const bin = path.join(path.dirname(pkgPath), BINARY_NAME);
226
- if (isNativeBinary(bin)) out.push(bin);
221
+ if (isNativeBinary(bin) && isPlausibleReleaseBinary(bin)) out.push(bin);
227
222
  } catch { /* not in node_modules walk-up */ }
228
223
 
229
224
  // Slow path: explicit global node_modules probe.
230
225
  for (const globalRoot of globalNodeModulesCandidates()) {
231
226
  const bin = path.join(globalRoot, '@sdsrs', `code-graph-${PLATFORM}-${ARCH}`, BINARY_NAME);
232
- if (isNativeBinary(bin)) out.push(bin);
227
+ if (isNativeBinary(bin) && isPlausibleReleaseBinary(bin)) out.push(bin);
233
228
  }
234
229
 
235
230
  return out;
@@ -372,7 +367,7 @@ function clearCache() {
372
367
 
373
368
  module.exports = {
374
369
  findBinary, findBinaryUncached, clearCache,
375
- globalNodeModulesCandidates, findPlatformBinary, createVersionGate,
370
+ globalNodeModulesCandidates, findPlatformBinary, platformBinaryCandidates, createVersionGate,
376
371
  getPackageVersion, compareVersions, isCachedBinaryFresh,
377
372
  detectLibc, unsupportedPlatformHint,
378
373
  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);
@@ -393,11 +409,20 @@ function removeHooksFromSettings(settings) {
393
409
 
394
410
  function buildSettingsHookEntries() {
395
411
  const root = PLUGIN_ROOT;
396
- const scriptCmd = (name, timeout) => ({
397
- type: 'command',
398
- command: `node "${path.join(root, 'scripts', name)}"`,
399
- timeout,
400
- });
412
+ const scriptCmd = (name, timeout) => {
413
+ const script = path.join(root, 'scripts', name);
414
+ // POSIX: existence-guarded. After `/plugin uninstall`, CC may delete the
415
+ // plugin-cache dir before our statusline teardown gets to strip these
416
+ // entries — in that window every Edit/Bash/Read/prompt errored on a dead
417
+ // path. The `if` form preserves node's own exit code (PreToolUse deny =
418
+ // exit 2); `&& … || exit 0` would swallow it. Windows keeps the bare
419
+ // command — the hook shell there is not reliably cmd, so `if exist`
420
+ // can't be assumed.
421
+ const command = process.platform === 'win32'
422
+ ? `node "${script}"`
423
+ : `if [ -f "${script}" ]; then node "${script}"; fi`;
424
+ return { type: 'command', command, timeout };
425
+ };
401
426
 
402
427
  return {
403
428
  PreToolUse: [
@@ -604,7 +629,7 @@ function verifyHooksFire({ hooks, env, timeoutMs = 4000, tmpBase } = {}) {
604
629
 
605
630
  // --- Install (idempotent) ---
606
631
 
607
- function install() {
632
+ function install({ reclaimStatusline = false } = {}) {
608
633
  const version = getPluginVersion();
609
634
  const manifest = readManifest();
610
635
  const settings = readJson(settingsPath()) || {};
@@ -620,14 +645,40 @@ function install() {
620
645
  // b. Register code-graph as a provider
621
646
  // c. Set statusLine to composite script
622
647
  if (!isOurComposite(settings)) {
623
- // Preserve existing statusline as first provider
624
- if (settings.statusLine && settings.statusLine.command) {
625
- registerStatuslineProvider('_previous', settings.statusLine.command, true);
648
+ // Displacement tracking: we held the slot before (manifest.config.statusLine)
649
+ // but a foreign command sits there now — either another slot-claiming plugin
650
+ // (whose own self-heal re-takes it just like ours would → statusline
651
+ // ping-pong every session) or the user's deliberate choice. Either way,
652
+ // silently re-claiming forever is wrong: after >2 observed displacements
653
+ // stand down — stay registered as a provider, leave the slot alone.
654
+ // Explicit `lifecycle.js install` (or CODE_GRAPH_FORCE_STATUSLINE=1)
655
+ // resets the counter and re-claims.
656
+ const currentCmd = settings.statusLine && settings.statusLine.command;
657
+ if (reclaimStatusline || process.env.CODE_GRAPH_FORCE_STATUSLINE === '1') {
658
+ manifest.config.statuslineDisplaced = 0;
659
+ } else if (manifest.config.statusLine === true && currentCmd) {
660
+ manifest.config.statuslineDisplaced = (manifest.config.statuslineDisplaced || 0) + 1;
661
+ }
662
+ if ((manifest.config.statuslineDisplaced || 0) > 2) {
663
+ if (manifest.config.statusLine === true) {
664
+ // Transition into stand-down exactly once: release claimed ownership
665
+ // (stops the counter) and leave a breadcrumb.
666
+ manifest.config.statusLine = false;
667
+ process.stderr.write(
668
+ '[code-graph] statusLine slot keeps being re-claimed by another provider — standing down.\n' +
669
+ ' Re-claim: CODE_GRAPH_FORCE_STATUSLINE=1 or `node lifecycle.js install`\n'
670
+ );
671
+ }
672
+ } else {
673
+ // Preserve existing statusline as first provider
674
+ if (currentCmd) {
675
+ registerStatuslineProvider('_previous', currentCmd, true);
676
+ }
677
+ // Set composite as the statusLine
678
+ settings.statusLine = { type: 'command', command: compositeCommand() };
679
+ settingsChanged = true;
680
+ manifest.config.statusLine = true;
626
681
  }
627
- // Set composite as the statusLine
628
- settings.statusLine = { type: 'command', command: compositeCommand() };
629
- settingsChanged = true;
630
- manifest.config.statusLine = true;
631
682
  } else {
632
683
  // Composite exists — ensure path is correct (may have been polluted by env leak)
633
684
  const cmd = compositeCommand();
@@ -635,6 +686,9 @@ function install() {
635
686
  settings.statusLine.command = cmd;
636
687
  settingsChanged = true;
637
688
  }
689
+ // We hold the slot — any displacement episode is over.
690
+ if (manifest.config.statuslineDisplaced) manifest.config.statuslineDisplaced = 0;
691
+ manifest.config.statusLine = true;
638
692
  }
639
693
 
640
694
  // Register code-graph provider
@@ -690,7 +744,7 @@ function defaultRunNpm(args) {
690
744
  } catch { return false; }
691
745
  }
692
746
 
693
- function uninstall({ purgeGlobal = false, runNpm = defaultRunNpm, scanGlobalPkgs = installedGlobalPkgs } = {}) {
747
+ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRunNpm, scanGlobalPkgs = installedGlobalPkgs } = {}) {
694
748
  const settings = readJson(settingsPath());
695
749
  let settingsChanged = false;
696
750
 
@@ -742,6 +796,27 @@ function uninstall({ purgeGlobal = false, runNpm = defaultRunNpm, scanGlobalPkgs
742
796
  const pluginInstalledGlobals = !!readJson(GLOBAL_INSTALL_MARKER);
743
797
  let adoptedProjects = [];
744
798
  try { adoptedProjects = require('./adopt').readAdoptedProjects(); } catch { /* POSIX-only helper — ok */ }
799
+
800
+ // 5.4. --unadopt-all: sweep every registered project's managed CLAUDE.md
801
+ // block + generated detail file (unadopt is marker-guarded, so user files
802
+ // are never touched; the .code-graph/ index dir is project DATA and stays —
803
+ // its removal is listed in the guidance instead of automated).
804
+ const unadopted = [];
805
+ if (unadoptAll && adoptedProjects.length) {
806
+ let unadoptFn = null;
807
+ try { unadoptFn = require('./adopt').unadopt; } catch { /* POSIX-only — skip */ }
808
+ if (unadoptFn) {
809
+ for (const project of adoptedProjects) {
810
+ try {
811
+ const r = unadoptFn({ cwd: project });
812
+ unadopted.push({ project, ok: !!(r && r.ok), cleaned: !!(r && (r.blockPruned || r.fileRemoved || r.claudeMdRemoved)) });
813
+ } catch (e) {
814
+ unadopted.push({ project, ok: false, error: (e && e.message) || String(e) });
815
+ }
816
+ }
817
+ try { adoptedProjects = require('./adopt').readAdoptedProjects(); } catch { /* ok */ }
818
+ }
819
+ }
745
820
  let globalPkgsRemoved = [];
746
821
  let globalPkgsRemaining = scanGlobalPkgs();
747
822
  if (globalPkgsRemaining.length && (pluginInstalledGlobals || purgeGlobal)) {
@@ -765,7 +840,7 @@ function uninstall({ purgeGlobal = false, runNpm = defaultRunNpm, scanGlobalPkgs
765
840
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
766
841
  }
767
842
 
768
- return { settingsChanged, pluginInstalledGlobals, globalPkgsRemoved, globalPkgsRemaining, adoptedProjects };
843
+ return { settingsChanged, pluginInstalledGlobals, globalPkgsRemoved, globalPkgsRemaining, adoptedProjects, unadopted };
769
844
  }
770
845
 
771
846
  // --- Update (refresh config points) ---
@@ -1020,11 +1095,23 @@ module.exports = {
1020
1095
  if (require.main === module) {
1021
1096
  const cmd = process.argv[2];
1022
1097
  if (cmd === 'install') {
1023
- const r = install();
1098
+ // Explicit CLI install = user intent: reset any statusline stand-down and re-claim.
1099
+ const r = install({ reclaimStatusline: true });
1024
1100
  console.log(`Installed v${r.version} | settings=${r.settingsChanged} | statusLine=${r.statusLineClaimed}`);
1025
1101
  } else if (cmd === 'uninstall') {
1026
- const r = uninstall({ purgeGlobal: process.argv.includes('--purge-global') });
1102
+ const r = uninstall({
1103
+ purgeGlobal: process.argv.includes('--purge-global'),
1104
+ unadoptAll: process.argv.includes('--unadopt-all'),
1105
+ });
1027
1106
  console.log(`Uninstalled | settings cleaned=${r.settingsChanged}`);
1107
+ if (r.unadopted.length) {
1108
+ const cleaned = r.unadopted.filter((u) => u.cleaned).length;
1109
+ console.log(` Unadopted ${cleaned}/${r.unadopted.length} registered project(s):`);
1110
+ for (const u of r.unadopted) {
1111
+ console.log(` ${u.ok ? (u.cleaned ? 'cleaned' : 'nothing-to-clean') : `FAILED (${u.error || 'unknown'})`} ${u.project}`);
1112
+ }
1113
+ console.log(' Their .code-graph/ index dirs are project data — remove per project with `rm -rf .code-graph` if unwanted.');
1114
+ }
1028
1115
  if (r.globalPkgsRemoved.length) {
1029
1116
  console.log(` Removed global npm package(s): ${r.globalPkgsRemoved.join(', ')}`);
1030
1117
  }
@@ -1038,7 +1125,7 @@ if (require.main === module) {
1038
1125
  if (r.adoptedProjects.length) {
1039
1126
  console.log(' Adopted project(s) still carrying a managed CLAUDE.md block + .code-graph/ index:');
1040
1127
  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.');
1128
+ console.log(' Clean all at once: re-run with --unadopt-all, or per project `code-graph-mcp unadopt` + `rm -rf .code-graph`.');
1042
1129
  }
1043
1130
  console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state.');
1044
1131
  } 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.103.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.103.0",
40
+ "@sdsrs/code-graph-linux-arm64": "0.103.0",
41
+ "@sdsrs/code-graph-darwin-x64": "0.103.0",
42
+ "@sdsrs/code-graph-darwin-arm64": "0.103.0",
43
+ "@sdsrs/code-graph-win32-x64": "0.103.0"
44
44
  }
45
45
  }