@sdsrs/code-graph 0.101.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.
@@ -18,6 +18,13 @@ const CACHE_DIR = path.join(os.homedir(), '.cache', 'code-graph');
18
18
  const PLUGIN_ROOT = path.resolve(__dirname, '..');
19
19
  const MANIFEST_FILE = path.join(CACHE_DIR, 'install-manifest.json');
20
20
  const REGISTRY_FILE = path.join(CACHE_DIR, 'statusline-registry.json');
21
+ // Written by the launcher's background install when ITS `npm install -g` step
22
+ // introduced the global shell + platform packages. Uninstall only removes
23
+ // global packages it can prove the plugin installed (marker present) or when
24
+ // the user passes --purge-global — a deliberate user install is never yanked.
25
+ const GLOBAL_INSTALL_MARKER = path.join(CACHE_DIR, 'global-install-marker.json');
26
+ const INSTALL_LOCK_FILE = path.join(CACHE_DIR, 'install.lock');
27
+ const SHELL_PKG = '@sdsrs/code-graph';
21
28
 
22
29
  // Lazy resolvers — Claude Code's config dir can be overridden by CLAUDE_CONFIG_DIR
23
30
  // (multi-account isolation). Re-read every call so test subprocesses with a
@@ -190,25 +197,41 @@ function isPluginInactive(settings = readJson(settingsPath()) || {}) {
190
197
  return !hasInstalledPluginRecord();
191
198
  }
192
199
 
193
- function detachStatuslineIntegration(settings) {
200
+ function detachStatuslineIntegration(settings, { compositeDoomed = true } = {}) {
194
201
  let settingsChanged = false;
195
202
 
196
203
  unregisterStatuslineProvider('code-graph');
197
- 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);
198
210
 
199
211
  // If our composite is still configured while the plugin is disabled/uninstalled,
200
- // prefer restoring the prior statusline (or removing ours entirely) so the plugin
201
- // truly stops affecting Claude Code.
212
+ // stop affecting Claude Code but keep surviving third parties rendering.
202
213
  if (isOurComposite(settings)) {
203
- 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) {
204
224
  settings.statusLine = { type: 'command', command: previous.command };
225
+ settingsChanged = true;
205
226
  } else {
206
227
  delete settings.statusLine;
228
+ settingsChanged = true;
207
229
  }
208
- settingsChanged = true;
209
230
  }
210
231
 
211
- 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');
212
235
  return settingsChanged;
213
236
  }
214
237
 
@@ -218,13 +241,25 @@ function cleanupDisabledStatusline() {
218
241
  return { cleaned: false, settingsChanged: false };
219
242
  }
220
243
 
221
- let settingsChanged = detachStatuslineIntegration(settings);
244
+ // Decide BEFORE mutating: isPluginUninstalled reads the same composite/
245
+ // registry markers detachStatuslineIntegration is about to remove.
246
+ const uninstalled = isPluginUninstalled(settings);
247
+
248
+ let settingsChanged = detachStatuslineIntegration(settings, { compositeDoomed: uninstalled });
222
249
  if (removeHooksFromSettings(settings)) settingsChanged = true;
223
250
  if (settingsChanged) {
224
251
  writeJsonAtomic(settingsPath(), settings);
225
252
  }
226
253
 
227
- return { cleaned: true, settingsChanged };
254
+ // Genuine uninstall (not a temporary disable): reclaim ~/.cache/code-graph
255
+ // too. This statusline-render path is the ONLY plugin code guaranteed to
256
+ // still run after `/plugin uninstall` — Claude Code stops loading the
257
+ // plugin's hooks.json, so the SessionStart teardown in session-init.js never
258
+ // fires post-uninstall. Without this, the ~40MB cached binary leaked forever.
259
+ let cacheRemoved = false;
260
+ if (uninstalled) cacheRemoved = removeCacheResidue();
261
+
262
+ return { cleaned: true, settingsChanged, cacheRemoved };
228
263
  }
229
264
 
230
265
  // --- Scope Conflict Detection ---
@@ -374,11 +409,20 @@ function removeHooksFromSettings(settings) {
374
409
 
375
410
  function buildSettingsHookEntries() {
376
411
  const root = PLUGIN_ROOT;
377
- const scriptCmd = (name, timeout) => ({
378
- type: 'command',
379
- command: `node "${path.join(root, 'scripts', name)}"`,
380
- timeout,
381
- });
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
+ };
382
426
 
383
427
  return {
384
428
  PreToolUse: [
@@ -585,7 +629,7 @@ function verifyHooksFire({ hooks, env, timeoutMs = 4000, tmpBase } = {}) {
585
629
 
586
630
  // --- Install (idempotent) ---
587
631
 
588
- function install() {
632
+ function install({ reclaimStatusline = false } = {}) {
589
633
  const version = getPluginVersion();
590
634
  const manifest = readManifest();
591
635
  const settings = readJson(settingsPath()) || {};
@@ -601,14 +645,40 @@ function install() {
601
645
  // b. Register code-graph as a provider
602
646
  // c. Set statusLine to composite script
603
647
  if (!isOurComposite(settings)) {
604
- // Preserve existing statusline as first provider
605
- if (settings.statusLine && settings.statusLine.command) {
606
- 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;
607
681
  }
608
- // Set composite as the statusLine
609
- settings.statusLine = { type: 'command', command: compositeCommand() };
610
- settingsChanged = true;
611
- manifest.config.statusLine = true;
612
682
  } else {
613
683
  // Composite exists — ensure path is correct (may have been polluted by env leak)
614
684
  const cmd = compositeCommand();
@@ -616,6 +686,9 @@ function install() {
616
686
  settings.statusLine.command = cmd;
617
687
  settingsChanged = true;
618
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;
619
692
  }
620
693
 
621
694
  // Register code-graph provider
@@ -648,7 +721,30 @@ function install() {
648
721
 
649
722
  // --- Uninstall (clean all config) ---
650
723
 
651
- function uninstall() {
724
+ /** Which of our npm packages exist at a global top level right now. */
725
+ function installedGlobalPkgs() {
726
+ const { globalNodeModulesCandidates, PLATFORM_PKG } = require('./find-binary');
727
+ const found = [];
728
+ for (const name of [SHELL_PKG, PLATFORM_PKG]) {
729
+ for (const root of globalNodeModulesCandidates()) {
730
+ if (fs.existsSync(path.join(root, name, 'package.json'))) { found.push(name); break; }
731
+ }
732
+ }
733
+ return found;
734
+ }
735
+
736
+ function defaultRunNpm(args) {
737
+ const { spawnSync } = require('child_process');
738
+ const { npmSpawnOpts } = require('./npm-exec');
739
+ try {
740
+ const r = spawnSync('npm', args, npmSpawnOpts({
741
+ timeout: 120000, stdio: 'pipe', encoding: 'utf8',
742
+ }));
743
+ return !r.error && r.status === 0;
744
+ } catch { return false; }
745
+ }
746
+
747
+ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRunNpm, scanGlobalPkgs = installedGlobalPkgs } = {}) {
652
748
  const settings = readJson(settingsPath());
653
749
  let settingsChanged = false;
654
750
 
@@ -692,6 +788,44 @@ function uninstall() {
692
788
  if (ipChanged) writeJsonAtomic(installedPluginsPath(), installedPlugins);
693
789
  }
694
790
 
791
+ // 5.5. Global npm packages + adoption inventory — read BEFORE step 6 wipes
792
+ // CACHE_DIR (both the install marker and the adopted-projects registry live
793
+ // there). The launcher's background install runs `npm install -g` on the
794
+ // user's behalf; nothing on the Claude Code uninstall path ever removes those
795
+ // packages (~40MB platform binary + CLI shim left on PATH forever).
796
+ const pluginInstalledGlobals = !!readJson(GLOBAL_INSTALL_MARKER);
797
+ let adoptedProjects = [];
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
+ }
820
+ let globalPkgsRemoved = [];
821
+ let globalPkgsRemaining = scanGlobalPkgs();
822
+ if (globalPkgsRemaining.length && (pluginInstalledGlobals || purgeGlobal)) {
823
+ if (runNpm(['uninstall', '-g', ...globalPkgsRemaining])) {
824
+ globalPkgsRemoved = globalPkgsRemaining;
825
+ globalPkgsRemaining = scanGlobalPkgs(); // re-scan: report only what actually survived
826
+ }
827
+ }
828
+
695
829
  // 6. Remove cache directory
696
830
  try { fs.rmSync(CACHE_DIR, { recursive: true, force: true }); } catch { /* ok */ }
697
831
 
@@ -706,7 +840,7 @@ function uninstall() {
706
840
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
707
841
  }
708
842
 
709
- return { settingsChanged };
843
+ return { settingsChanged, pluginInstalledGlobals, globalPkgsRemoved, globalPkgsRemaining, adoptedProjects, unadopted };
710
844
  }
711
845
 
712
846
  // --- Update (refresh config points) ---
@@ -952,6 +1086,7 @@ module.exports = {
952
1086
  SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
953
1087
  PLUGIN_ROOT, // v0.32.1 — for tests / consumers
954
1088
  registerStatuslineProvider, unregisterStatuslineProvider,
1089
+ installedGlobalPkgs, GLOBAL_INSTALL_MARKER, INSTALL_LOCK_FILE, SHELL_PKG, // uninstall residue
955
1090
  PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
956
1091
  settingsPath, installedPluginsPath, providersBackupFile, pluginsCacheDir,
957
1092
  };
@@ -960,13 +1095,39 @@ module.exports = {
960
1095
  if (require.main === module) {
961
1096
  const cmd = process.argv[2];
962
1097
  if (cmd === 'install') {
963
- const r = install();
1098
+ // Explicit CLI install = user intent: reset any statusline stand-down and re-claim.
1099
+ const r = install({ reclaimStatusline: true });
964
1100
  console.log(`Installed v${r.version} | settings=${r.settingsChanged} | statusLine=${r.statusLineClaimed}`);
965
1101
  } else if (cmd === 'uninstall') {
966
- const r = uninstall();
1102
+ const r = uninstall({
1103
+ purgeGlobal: process.argv.includes('--purge-global'),
1104
+ unadoptAll: process.argv.includes('--unadopt-all'),
1105
+ });
967
1106
  console.log(`Uninstalled | settings cleaned=${r.settingsChanged}`);
968
- console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state,');
969
- console.log(' and `code-graph-mcp unadopt` in each adopted project to remove its CLAUDE.md block.');
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
+ }
1115
+ if (r.globalPkgsRemoved.length) {
1116
+ console.log(` Removed global npm package(s): ${r.globalPkgsRemoved.join(', ')}`);
1117
+ }
1118
+ if (r.globalPkgsRemaining.length) {
1119
+ console.log(` Global npm package(s) still installed: ${r.globalPkgsRemaining.join(', ')}`);
1120
+ console.log(` Remove with: npm uninstall -g ${r.globalPkgsRemaining.join(' ')}`);
1121
+ if (!r.pluginInstalledGlobals) {
1122
+ console.log(' (left in place: no plugin-install marker, so they may be your own install; --purge-global forces removal)');
1123
+ }
1124
+ }
1125
+ if (r.adoptedProjects.length) {
1126
+ console.log(' Adopted project(s) still carrying a managed CLAUDE.md block + .code-graph/ index:');
1127
+ for (const p of r.adoptedProjects) console.log(` ${p}`);
1128
+ console.log(' Clean all at once: re-run with --unadopt-all, or per project `code-graph-mcp unadopt` + `rm -rf .code-graph`.');
1129
+ }
1130
+ console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state.');
970
1131
  } else if (cmd === 'update') {
971
1132
  const r = update();
972
1133
  console.log(`Updated ${r.oldVersion} → ${r.version} | settings=${r.settingsChanged}`);
@@ -7,7 +7,7 @@
7
7
  * Used by .mcp.json so the plugin controls binary discovery instead of
8
8
  * relying on the binary being in PATH.
9
9
  */
10
- const { spawn, spawnSync } = require('child_process');
10
+ const { spawn } = require('child_process');
11
11
  const path = require('path');
12
12
  const fs = require('fs');
13
13
  const { isNonProjectCwd } = require('./project-detect');
@@ -86,66 +86,15 @@ if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && isNonProjectCwd(process.c
86
86
  }
87
87
 
88
88
  const { findBinary, clearCache, unsupportedPlatformHint } = require('./find-binary');
89
+ const { installBinaryInBackground } = require('./launcher-install');
89
90
 
90
- let binary = findBinary();
91
+ const binary = findBinary();
91
92
 
92
- // Auto-install binary if not found (first-time install)
93
- if (!binary) {
94
- let version = 'latest';
95
- try {
96
- const pj = path.join(__dirname, '..', '.claude-plugin', 'plugin.json');
97
- version = JSON.parse(fs.readFileSync(pj, 'utf8')).version || 'latest';
98
- } catch { /* use latest */ }
99
-
100
- process.stderr.write(`[code-graph] Binary not found, installing @sdsrs/code-graph@${version}...\n`);
101
- const npmResult = spawnSync('npm', ['install', '-g', `@sdsrs/code-graph@${version}`], {
102
- timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
103
- });
104
- if (npmResult.error || npmResult.status !== 0) {
105
- process.stderr.write('[code-graph] npm install failed.\n');
106
- if (npmResult.stderr) {
107
- process.stderr.write(npmResult.stderr.trim().split('\n').map(l => `[code-graph][npm] ${l}\n`).join(''));
108
- }
109
- } else {
110
- clearCache();
111
- binary = findBinary();
112
- if (binary) {
113
- process.stderr.write(`[code-graph] Installed at ${binary}\n`);
114
- }
115
- }
116
- }
117
-
118
- // Fallback: npm install may have succeeded but optionalDependencies for the
119
- // platform binary can fail silently (npm tolerates OS-mismatch + flaky
120
- // registry). Pull the platform binary directly from the GitHub release.
121
- //
122
- // --install-missing bypasses auto-update.js's isDevMode() short-circuit. The
123
- // marketplace ships the full repo (including Cargo.toml at the workspace root),
124
- // so dev-mode heuristics that look for Cargo.toml were misclassifying every
125
- // marketplace install as dev mode and skipping this fallback (issue #12).
126
- if (!binary) {
127
- process.stderr.write('[code-graph] Falling back to GitHub release download...\n');
128
- const result = spawnSync(
129
- process.execPath,
130
- [path.join(__dirname, 'auto-update.js'), '--silent', '--install-missing'],
131
- { timeout: 90000, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }
132
- );
133
- if (result.stderr && result.stderr.trim()) {
134
- process.stderr.write(result.stderr.trim().split('\n').map(l => `[code-graph][auto-update] ${l}\n`).join(''));
135
- }
136
- if (result.error) {
137
- process.stderr.write(`[code-graph] auto-update spawn failed: ${result.error.message}\n`);
138
- } else if (result.status !== 0) {
139
- process.stderr.write(`[code-graph] auto-update exited with status ${result.status}\n`);
140
- }
141
- clearCache();
142
- binary = findBinary();
143
- if (binary) {
144
- process.stderr.write(`[code-graph] Installed at ${binary}\n`);
145
- }
146
- }
147
-
148
- if (!binary) {
93
+ // Manual-install guidance, printed when the background install chain exhausts
94
+ // both steps without producing a binary. Unlike the old sync path this does NOT
95
+ // exit: the upgradeable stub stays connected (0 tools), so a manual
96
+ // `npm install -g` mid-session still upgrades the live connection.
97
+ function printManualInstallHints() {
149
98
  const installedViaMarketplace = fs.existsSync(
150
99
  path.join(__dirname, '..', '.claude-plugin', 'plugin.json')
151
100
  );
@@ -155,9 +104,9 @@ if (!binary) {
155
104
  // npm package does not exist, so the generic "npm install @sdsrs/code-graph-<plat>-<arch>"
156
105
  // suggestion below would point at a nonexistent package. Show the source/emulation hint.
157
106
  process.stderr.write('[code-graph] Binary not found.\n' + platformHint + '\n');
158
- process.exit(1);
107
+ return;
159
108
  }
160
- process.stderr.write('[code-graph] Binary not found. Install manually:\n');
109
+ process.stderr.write('[code-graph] Binary install failed. Install manually:\n');
161
110
  if (installedViaMarketplace) {
162
111
  process.stderr.write(
163
112
  ' # Re-install the plugin via Claude Code marketplace:\n' +
@@ -171,7 +120,71 @@ if (!binary) {
171
120
  ' npm install -g @sdsrs/code-graph\n' +
172
121
  ' npm install -g @sdsrs/code-graph-' + process.platform + '-' + process.arch + '\n'
173
122
  );
174
- process.exit(1);
123
+ }
124
+
125
+ // --- Missing binary: answer the handshake NOW, install in the background ----
126
+ // The old chain ran `npm install -g` (60s timeout) and the GitHub-release
127
+ // fallback (90s) SYNCHRONOUSLY before answering any MCP JSON-RPC. Claude
128
+ // Code's connect timeout is 30s, so a cold install always presented as
129
+ // "MCP server connection timed out after 30000ms" and the tools only appeared
130
+ // on a later reconnect. Serve the upgradeable 0-tool stub first (initialize is
131
+ // answered instantly), run the same install chain in the background, and hand
132
+ // the live connection to the real binary via the same upgrade mechanism the
133
+ // non-project gate uses — no reconnect, no restart.
134
+ //
135
+ // --install-missing bypasses auto-update.js's isDevMode() short-circuit. The
136
+ // marketplace ships the full repo (including Cargo.toml at the workspace root),
137
+ // so dev-mode heuristics that look for Cargo.toml were misclassifying every
138
+ // marketplace install as dev mode and skipping this fallback (issue #12).
139
+ if (!binary) {
140
+ let version = 'latest';
141
+ try {
142
+ const pj = path.join(__dirname, '..', '.claude-plugin', 'plugin.json');
143
+ version = JSON.parse(fs.readFileSync(pj, 'utf8')).version || 'latest';
144
+ } catch { /* use latest */ }
145
+
146
+ process.stderr.write(
147
+ `[code-graph] Binary not found — serving 0-tool stub while installing ` +
148
+ `@sdsrs/code-graph@${version} in the background (tools appear when it lands)...\n`
149
+ );
150
+
151
+ const stub = serveEmptyMcpStub({
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,
157
+ shouldUpgrade: () => !!findBinary(),
158
+ spawnReal: () => {
159
+ const bin = findBinary();
160
+ if (!bin) return null;
161
+ process.stderr.write(`[code-graph] binary ready at ${bin} — upgrading plugin MCP to real tools (restart Claude Code for full tool steering)\n`);
162
+ return spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'inherit'], env: process.env });
163
+ },
164
+ },
165
+ });
166
+
167
+ const { GLOBAL_INSTALL_MARKER, INSTALL_LOCK_FILE } = require('./lifecycle');
168
+ installBinaryInBackground({
169
+ version,
170
+ findBinary,
171
+ clearCache,
172
+ // Nudge the handover immediately instead of waiting for the stub's next poll.
173
+ onInstalled: () => stub.attemptUpgrade(),
174
+ onFailed: () => printManualInstallHints(),
175
+ // Marker: this npm install was OURS, so lifecycle.js uninstall knows it
176
+ // owns removing the global packages (never yanks a user's own install).
177
+ recordGlobalInstall: () => {
178
+ fs.mkdirSync(path.dirname(GLOBAL_INSTALL_MARKER), { recursive: true });
179
+ fs.writeFileSync(GLOBAL_INSTALL_MARKER, JSON.stringify({
180
+ installedBy: 'code-graph-mcp launcher', version, at: new Date().toISOString(),
181
+ }, null, 2) + '\n');
182
+ },
183
+ // Serialize against other cold sessions + auto-update (parallel global npm
184
+ // installs corrupt the shared prefix).
185
+ lockPath: INSTALL_LOCK_FILE,
186
+ });
187
+ return; // top-level function scope of mcp-launcher.js
175
188
  }
176
189
 
177
190
  // Pre-spawn: verify binary is executable (catches macOS quarantine, permission issues)
@@ -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
  }
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+ // npm is `npm.cmd` on Windows: child_process spawn/execFileSync cannot exec a
3
+ // .cmd without a shell (and Node >= 18.20 throws EINVAL spawning .cmd directly
4
+ // as a CVE-2024-27980 mitigation). Every bare `spawn('npm', ...)` in the
5
+ // install/update flow therefore silently ENOENT'd on Windows while
6
+ // commandExists('npm') (via `where`) said npm was present. All args routed
7
+ // through here are fixed flags / package specs — shell-quoting-safe.
8
+ const NPM_NEEDS_SHELL = process.platform === 'win32';
9
+
10
+ /** Merge shell:true into spawn/exec options when the platform needs it. */
11
+ function npmSpawnOpts(opts = {}) {
12
+ return NPM_NEEDS_SHELL ? { ...opts, shell: true } : opts;
13
+ }
14
+
15
+ module.exports = { npmSpawnOpts, NPM_NEEDS_SHELL };
@@ -480,10 +480,13 @@ function runSessionInit({ source } = {}) {
480
480
  cleanupDisabledStatusline();
481
481
  // Genuine uninstall (not a temporary disable) leaves residue the settings-only
482
482
  // self-heal can't reach: ~/.cache/code-graph (the ~40MB binary + state) and the
483
- // current project's CLAUDE.md adoption block. CC fires no uninstall hook, so this
484
- // SessionStart is the only automated teardown symmetric to install's auto-adopt.
485
- // Per-project: only the cwd we're in; other adopted projects self-clean when next
486
- // opened, or via `code-graph-mcp unadopt`.
483
+ // current project's CLAUDE.md adoption block. CC fires no uninstall hook, AND it
484
+ // stops loading this plugin's hooks.json the moment the install record is gone
485
+ // so after a real `/plugin uninstall` this SessionStart usually never runs again.
486
+ // The reachable teardown is cleanupDisabledStatusline() via the composite
487
+ // statusline (still wired in settings.json); it removes the cache residue too.
488
+ // This branch remains for the disable→uninstall-while-running edge and as the
489
+ // only place project unadoption can happen automatically.
487
490
  let teardown = null;
488
491
  if (uninstalled) {
489
492
  const cacheRemoved = removeCacheResidue();
@@ -550,9 +553,15 @@ function runSessionInit({ source } = {}) {
550
553
  }
551
554
  if (autoAdopt.attempted && autoAdopt.result && autoAdopt.result.ok) {
552
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
+ : '';
553
562
  process.stderr.write(
554
- '[code-graph] Refreshed CLAUDE.md decision block to latest shipped version.\n' +
555
- ' 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'
556
565
  );
557
566
  } else {
558
567
  process.stderr.write(
@@ -57,13 +57,31 @@ const codeGraphDir = path.join(root, '.code-graph');
57
57
  // Check for background indexing progress file first
58
58
  const progressFile = path.join(codeGraphDir, 'indexing-status.json');
59
59
  try {
60
- const raw = fs.readFileSync(progressFile, 'utf8');
61
- const p = JSON.parse(raw);
62
- if (p.s === 'indexing' && p.t > 0) {
63
- const pct = Math.round((p.d / p.t) * 100);
60
+ // Staleness gate: the file is normally deleted by the server's IndexGuard, but
61
+ // a killed process (session exit, SIGKILL, the 30s MCP connect-timeout kill)
62
+ // skips Drop, and the orphan would pin "indexing N/M" here forever. A LIVE
63
+ // indexer heartbeats the file at least once per batch and per finalize phase,
64
+ // so an old mtime proves no indexer is writing it: ignore the file and fall
65
+ // through to the health check. (Mirrors INDEXING_STATUS_STALE_SECS in
66
+ // src/indexer/pipeline/mod.rs, which drives server/CLI-side stale cleanup.)
67
+ const INDEXING_STALE_MS = 120000;
68
+ const fresh = (Date.now() - fs.statSync(progressFile).mtimeMs) < INDEXING_STALE_MS;
69
+ const p = fresh ? JSON.parse(fs.readFileSync(progressFile, 'utf8')) : null;
70
+ if (p && p.s === 'indexing' && p.t > 0) {
71
+ // floor, not round: skipped files (parse errors, oversized) keep d below t
72
+ // even in the terminal progress write, and rounding displayed that state as
73
+ // a confusing stuck "100%".
74
+ const pct = Math.floor((p.d / p.t) * 100);
64
75
  process.stdout.write(`code-graph: \u21BB indexing ${p.d}/${p.t} (${pct}%)`);
65
76
  process.exit(0);
66
77
  }
78
+ if (p && p.s === 'finalizing' && p.t > 0) {
79
+ // Post-batch full-graph phases (context strings, import bind/prune, ANALYZE):
80
+ // the file count no longer moves, so show an explicit phase label instead of
81
+ // a frozen-looking counter.
82
+ process.stdout.write(`code-graph: ↻ finalizing ${p.d}/${p.t}`);
83
+ process.exit(0);
84
+ }
67
85
  } catch { /* no progress file or parse error — continue to health check */ }
68
86
 
69
87
  // No indexing in progress — show normal health status
@@ -131,8 +149,14 @@ function statusUnavailable(errText) {
131
149
  let report = null;
132
150
  let errText = '';
133
151
  try {
152
+ // 1500ms, NOT 3000ms: the composite wrapper kills this whole provider at
153
+ // 3000ms (statusline-composite.js runProvider), so an inner budget equal to
154
+ // the outer one guaranteed the OUTER timeout fired first on a slow
155
+ // health-check (e.g. CPU saturated by the embedding backfill) and the segment
156
+ // silently vanished. Keeping the inner budget well under the outer one turns
157
+ // "slow health-check" into a rendered "offline"/"updating" instead of a blank.
134
158
  report = parseReport(execFileSync(bin, ['health-check', '--format', 'json'], {
135
- timeout: 3000,
159
+ timeout: 1500,
136
160
  stdio: ['pipe', 'pipe', 'pipe'],
137
161
  // Run the binary FROM the resolved root so its own project-root resolution
138
162
  // lands on the same DB the gate above picked (a subdir cwd would otherwise