@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 +15 -5
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/auto-update.js +56 -17
- package/claude-plugin/scripts/doctor.js +31 -5
- package/claude-plugin/scripts/find-binary.js +32 -21
- package/claude-plugin/scripts/lifecycle.js +174 -34
- package/claude-plugin/scripts/mcp-launcher.js +4 -0
- package/claude-plugin/scripts/mcp-stub.js +25 -3
- package/claude-plugin/scripts/session-init.js +8 -2
- package/claude-plugin/scripts/version-utils.js +32 -1
- package/package.json +6 -6
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.
|
|
52
|
-
"
|
|
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({
|
|
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
|
|
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";
|
|
@@ -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
|
|
116
|
-
|
|
117
|
-
//
|
|
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(', ') + (
|
|
299
|
-
?
|
|
300
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
208
|
-
// truly stops affecting Claude Code.
|
|
212
|
+
// stop affecting Claude Code — but keep surviving third parties rendering.
|
|
209
213
|
if (isOurComposite(settings)) {
|
|
210
|
-
if (
|
|
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
|
-
|
|
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
|
|
363
|
-
//
|
|
364
|
-
// the
|
|
365
|
-
//
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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({
|
|
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('
|
|
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())
|
|
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(
|
|
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(
|
|
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
|
-
|
|
558
|
-
' Lock
|
|
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.
|
|
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.
|
|
40
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
42
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
43
|
-
"@sdsrs/code-graph-win32-x64": "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
|
}
|