@sdsrs/code-graph 0.109.0 → 0.111.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/README.md CHANGED
@@ -151,6 +151,19 @@ Then reconnect the MCP server in Claude Code with `/mcp`.
151
151
 
152
152
  > **Note:** Auto-update is disabled in the source repo directory (dev mode). Use manual update when developing the plugin itself.
153
153
 
154
+ #### Turning auto-update off
155
+
156
+ | Environment variable | Effect |
157
+ |----------------------|--------|
158
+ | `CODE_GRAPH_NO_AUTO_UPDATE=1` | Skips the update check entirely — no version check, no download, and no updater process is started. A **missing** binary is still installed on first run, so the MCP server can't be left with no engine. Update manually with the command above. |
159
+
160
+ Set it in the `env` block of `~/.claude/settings.json` (the environment of the
161
+ process hosting the MCP server), then reconnect with `/mcp`.
162
+
163
+ An update that keeps failing also stops retrying on its own: after 5 failed
164
+ attempts at the *same* release, the updater goes check-only until a newer
165
+ release is published. Run `code-graph-mcp doctor` to see the state.
166
+
154
167
  #### Invited-memory mode (quieter prompts)
155
168
 
156
169
  By default, every user prompt the plugin deems code-related gets a small context injection from `code-graph` CLI output. If you'd rather rely on MEMORY.md + explicit tool calls, opt into invited-memory mode:
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.109.0",
7
+ "version": "0.111.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -12,7 +12,8 @@ const { claudeHome } = require('./claude-config');
12
12
  const { clearCache: clearBinaryCache, globalNodeModulesCandidates, nvmNodeModulesDirs, PLATFORM_PKG, detectLibc } = require('./find-binary');
13
13
  const { readBinaryVersion, compareVersions, isDevMode } = require('./version-utils');
14
14
  const { cgTmpDir } = require('./tmp-dir');
15
- const { npmSpawnOpts } = require('./npm-exec');
15
+ const { npmInvocation } = require('./npm-exec');
16
+ const { hidden } = require('./proc-opts');
16
17
  const { acquireLock } = require('./install-lock');
17
18
 
18
19
  // ── Environment Checks ────────────────────────────────────
@@ -25,7 +26,7 @@ const { acquireLock } = require('./install-lock');
25
26
  function commandExists(cmd) {
26
27
  try {
27
28
  const whichCmd = process.platform === 'win32' ? 'where' : 'which';
28
- execFileSync(whichCmd, [cmd], { stdio: 'ignore' });
29
+ execFileSync(whichCmd, [cmd], hidden({ stdio: 'ignore' }));
29
30
  return true;
30
31
  } catch {
31
32
  return false;
@@ -47,11 +48,30 @@ const SESSION_START_MIN_GAP_MS = 2 * 60 * 1000; // 2min — anti-hammer flo
47
48
  // `--force` included, because the backoff arm sits above the force arm below.
48
49
  const RATE_LIMIT_INTERVAL_MS = 60 * 60 * 1000; // 1h — GitHub's reset window
49
50
  const FETCH_TIMEOUT_MS = 3000;
51
+ // After this many consecutive FAILED install cycles for the SAME target version,
52
+ // stop re-running the download chain and go check-only until a new release moves
53
+ // the target. Some failures are permanent for a given machine (a GNU tar that
54
+ // cannot open `C:\...`, a locked plugin cache, a full disk) and every retry was
55
+ // a fresh ~MB download plus — before the windowsHide sweep — a burst of console
56
+ // windows, once per session, forever (issue #40). Kept equal to statusline.js's
57
+ // STUCK_UPDATE_ATTEMPTS (drift-guarded in statusline.test.js) so the moment the
58
+ // updater gives up is the moment the statusline stops promising "↻ updating".
59
+ const MAX_UPDATE_ATTEMPTS = 5;
50
60
 
51
61
  function isSilentMode(argv = process.argv.slice(2), env = process.env) {
52
62
  return argv.includes('--silent') || env.CODE_GRAPH_AUTO_UPDATE_SILENT === '1';
53
63
  }
54
64
 
65
+ // Documented opt-out. Until now the only way to stop auto-update was the
66
+ // accidental one — CODE_GRAPH_DEV=1, which also changes binary resolution and
67
+ // several unrelated code paths (issue #40). This one does exactly what it says.
68
+ // It does NOT block --install-missing: that path exists to put a binary on disk
69
+ // for a server that has none, and disabling *updates* must not wedge a fresh
70
+ // install with no engine.
71
+ function isAutoUpdateDisabled(env = process.env) {
72
+ return env.CODE_GRAPH_NO_AUTO_UPDATE === '1';
73
+ }
74
+
55
75
  function isInstallMissingMode(argv = process.argv.slice(2)) {
56
76
  return argv.includes('--install-missing');
57
77
  }
@@ -342,7 +362,7 @@ async function downloadBinary(latest) {
342
362
  execFileSync('curl', [
343
363
  '-sL', '-o', binaryTmp,
344
364
  latest.binaryUrl,
345
- ], { timeout: 60000, stdio: 'pipe' });
365
+ ], hidden({ timeout: 60000, stdio: 'pipe' }));
346
366
 
347
367
  // Integrity sidecar (<asset>.sha256), fail-CLOSED. `curl -f` turns a 404 into
348
368
  // a throw. One retry, because the alternative to a transient network blip is
@@ -363,7 +383,7 @@ async function downloadBinary(latest) {
363
383
  for (let attempt = 0; attempt < 2 && !expectedSha; attempt++) {
364
384
  try {
365
385
  execFileSync('curl', ['-sfL', '-o', shaTmp, latest.binaryUrl + '.sha256'],
366
- { timeout: 30000, stdio: 'pipe' });
386
+ hidden({ timeout: 30000, stdio: 'pipe' }));
367
387
  expectedSha = (fs.readFileSync(shaTmp, 'utf8').trim().split(/\s+/)[0]) || null;
368
388
  } catch { /* retry once, then refuse below */ } finally {
369
389
  try { if (fs.existsSync(shaTmp)) fs.unlinkSync(shaTmp); } catch { /* ok */ }
@@ -464,7 +484,7 @@ function refreshMarketplaceClone({ dir = marketplaceCloneDir(), exec = execFileS
464
484
  try {
465
485
  if (!fs.existsSync(path.join(dir, '.git'))) return false;
466
486
  if (!commandExists('git')) return false;
467
- exec('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], { timeout: timeoutMs, stdio: 'pipe' });
487
+ exec('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], hidden({ timeout: timeoutMs, stdio: 'pipe' }));
468
488
  return true;
469
489
  } catch {
470
490
  return false;
@@ -517,7 +537,7 @@ async function downloadAndInstall(latest, {
517
537
  '-sL', '-o', tarballPath,
518
538
  '-H', 'Accept: application/octet-stream',
519
539
  latest.pluginTarballUrl,
520
- ], { timeout: 30000, stdio: 'pipe' });
540
+ ], hidden({ timeout: 30000, stdio: 'pipe' }));
521
541
 
522
542
  // One retry, matching the binary sidecar at :355 — same failure mode, same
523
543
  // argument: a transient blip should cost an update cycle, not force a
@@ -528,7 +548,7 @@ async function downloadAndInstall(latest, {
528
548
  for (let attempt = 0; attempt < 2 && !expectedSha; attempt++) {
529
549
  try {
530
550
  exec('curl', ['-sfL', '-o', shaPath, latest.pluginTarballUrl + '.sha256'],
531
- { timeout: 30000, stdio: 'pipe' });
551
+ hidden({ timeout: 30000, stdio: 'pipe' }));
532
552
  expectedSha = (fs.readFileSync(shaPath, 'utf8').trim().split(/\s+/)[0]) || null;
533
553
  } catch { /* retried once, then refused just below */ }
534
554
  }
@@ -540,9 +560,18 @@ async function downloadAndInstall(latest, {
540
560
 
541
561
  // No --strip-components: the asset archives `claude-plugin/` itself, while
542
562
  // GitHub's source tarball wraps everything in `<owner>-<repo>-<sha>/`.
563
+ //
564
+ // Relative archive name + `cwd`, never an absolute path with `-C`: GNU tar
565
+ // (git-for-Windows / MSYS, first on PATH for many Windows users) reads the
566
+ // drive letter in `C:\Users\...\claude-plugin.tar.gz` as a REMOTE HOST and
567
+ // fails with "Cannot connect to C: resolve failed" — the same colon-parsing
568
+ // family as issues #34/#35. Windows' built-in bsdtar accepts both spellings,
569
+ // so this form is the portable one. On a failing GNU tar this was the step
570
+ // that made plugin updates unachievable, which is what put the whole
571
+ // download chain on a per-session repeat loop (issue #40).
543
572
  exec('tar', [
544
- 'xzf', tarballPath, '-C', tmpDir,
545
- ], { timeout: 15000, stdio: 'pipe' });
573
+ 'xzf', PLUGIN_ASSET_NAME,
574
+ ], hidden({ cwd: tmpDir, timeout: 15000, stdio: 'pipe' }));
546
575
 
547
576
  const pluginSrc = path.join(tmpDir, 'claude-plugin');
548
577
  const pluginDst = path.join(
@@ -587,9 +616,9 @@ async function downloadAndInstall(latest, {
587
616
  try {
588
617
  const newLifecycle = path.join(pluginDst, 'scripts', 'lifecycle.js');
589
618
  if (fs.existsSync(newLifecycle)) {
590
- exec(process.execPath, [newLifecycle, 'update'], {
619
+ exec(process.execPath, [newLifecycle, 'update'], hidden({
591
620
  timeout: 5000, stdio: 'pipe',
592
- });
621
+ }));
593
622
  }
594
623
  } catch { /* not fatal — syncLifecycleConfig will self-heal on next session */ }
595
624
  }
@@ -698,10 +727,11 @@ function inactiveNodeGlobalRelics({ dirs = null, activeDir = null } = {}) {
698
727
  function npmInstallGlobal(specs) {
699
728
  return new Promise((resolve) => {
700
729
  if (!commandExists('npm')) { resolve(false); return; }
701
- const child = spawn('npm', ['install', '-g', ...specs], npmSpawnOpts({
730
+ const npm = npmInvocation(['install', '-g', ...specs], {
702
731
  timeout: GLOBAL_PKG_HEAL_TIMEOUT_MS,
703
732
  stdio: ['ignore', 'ignore', 'pipe'],
704
- }));
733
+ });
734
+ const child = spawn(npm.file, npm.args, npm.opts);
705
735
  let stderr = '';
706
736
  child.stderr.on('data', (d) => { stderr += d.toString(); });
707
737
  child.on('error', () => resolve(false));
@@ -775,10 +805,11 @@ function shouldHealGlobalsOnThrottle(state, { readStale = staleGlobalPkgs } = {}
775
805
  async function checkForUpdate({ installMissing = false, force = false, requestJsonFn } = {}) {
776
806
  let installLock = null;
777
807
  try {
778
- // Skip in dev mode unless the launcher explicitly requested a missing-
779
- // binary install, in which case we MUST proceed regardless of mode (the
780
- // alternative is wedging the MCP server with no binary on disk).
781
- if (!installMissing && isDevMode()) return null;
808
+ // Skip in dev mode / when the user opted out unless the launcher
809
+ // explicitly requested a missing-binary install, in which case we MUST
810
+ // proceed regardless of mode (the alternative is wedging the MCP server
811
+ // with no binary on disk).
812
+ if (!installMissing && (isDevMode() || isAutoUpdateDisabled())) return null;
782
813
 
783
814
  const state = readState();
784
815
  // manifest.version is authoritative — /plugin update writes it directly and
@@ -845,6 +876,35 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
845
876
  }
846
877
 
847
878
  if (hasUpdate) {
879
+ // Attempts are counted PER target version, so a newly published release
880
+ // always starts with a full budget. The counter used to be unscoped, which
881
+ // only mattered because nothing ever read it: the download chain re-ran on
882
+ // every single session no matter how many times it had already failed.
883
+ const attempts = state.latestVersion === latest.version ? (state.updateAttempts || 0) : 0;
884
+ if (attempts >= MAX_UPDATE_ATTEMPTS) {
885
+ // Suspended — check-only from here until a newer release moves the
886
+ // target. The one thing still worth attempting is a MISSING cached
887
+ // binary: without it the MCP server has no engine at all, so that
888
+ // self-heal stays reachable while the (separately failing) plugin
889
+ // tarball chain and the global-npm heal stay parked.
890
+ const healedMissing = !fs.existsSync(cachedBinaryPath()) && await downloadBinary(latest);
891
+ saveState({
892
+ ...state,
893
+ installedVersion,
894
+ lastCheck: new Date().toISOString(),
895
+ latestVersion: latest.version,
896
+ updateAvailable: true,
897
+ updateAttempts: attempts,
898
+ rateLimited: false,
899
+ binaryUpdated: healedMissing || state.binaryUpdated,
900
+ });
901
+ console.error(
902
+ `[code-graph] Auto-update to v${latest.version} suspended after ${attempts} failed attempts on this machine. ` +
903
+ 'Update manually (`/plugin update code-graph-mcp`, or `npm install -g @sdsrs/code-graph`) or run `code-graph-mcp doctor`. ' +
904
+ 'Retries resume automatically when a newer release is published.'
905
+ );
906
+ return { updateAvailable: true, suspended: true, from: installedVersion, to: latest.version };
907
+ }
848
908
  const result = await downloadAndInstall(latest);
849
909
  const success = result.pluginUpdated;
850
910
  const newState = {
@@ -857,7 +917,7 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
857
917
  // update (missing tar/curl, full disk, blocked network) pins "updating"
858
918
  // forever, asserting a self-heal that never happens. The statusline stops
859
919
  // trusting it past STUCK_UPDATE_ATTEMPTS; success resets to 0.
860
- updateAttempts: success ? 0 : (state.updateAttempts || 0) + 1,
920
+ updateAttempts: success ? 0 : attempts + 1,
861
921
  lastUpdate: success ? new Date().toISOString() : state.lastUpdate,
862
922
  rateLimited: false,
863
923
  binaryUpdated: result.binaryUpdated,
@@ -913,7 +973,8 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
913
973
  module.exports = {
914
974
  checkForUpdate, commandExists, isDevMode, readState, compareVersions, shouldCheck,
915
975
  getExtractedPluginVersion, readBinaryVersion, promoteVerifiedBinary,
916
- isSilentMode, isInstallMissingMode, isForceMode,
976
+ isSilentMode, isInstallMissingMode, isForceMode, isAutoUpdateDisabled,
977
+ MAX_UPDATE_ATTEMPTS,
917
978
  requestJson, resolveProxy, parseLatestRelease, fetchLatestRelease,
918
979
  PLUGIN_ASSET_NAME,
919
980
  downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
@@ -941,10 +1002,14 @@ if (require.main === module) {
941
1002
  if (silent) return;
942
1003
  if (result && result.updated) {
943
1004
  console.log(`Updated: v${result.from} → v${result.to} (binary: ${result.binaryUpdated ? 'yes' : 'no'})`);
1005
+ } else if (result && result.suspended) {
1006
+ console.log(`Update available: v${result.to} — auto-install SUSPENDED after ${MAX_UPDATE_ATTEMPTS} failed attempts. Update manually; retries resume on the next release.`);
944
1007
  } else if (result && result.updateAvailable) {
945
1008
  console.log(`Update available: v${result.to} (auto-install failed)`);
946
1009
  } else if (result && result.binaryUpdated) {
947
1010
  console.log(`Repaired binary cache (v${result.to})`);
1011
+ } else if (!installMissing && isAutoUpdateDisabled()) {
1012
+ console.log('CODE_GRAPH_NO_AUTO_UPDATE=1 — auto-update skipped');
948
1013
  } else if (!installMissing && isDevMode()) {
949
1014
  console.log('Dev mode — auto-update skipped');
950
1015
  } else {
@@ -19,6 +19,7 @@
19
19
  // runs cannot inflate the deny→use conversion funnel.
20
20
 
21
21
  const { spawnSync } = require('child_process');
22
+ const { hidden } = require('./proc-opts');
22
23
 
23
24
  // 2000 ms is a product decision, not a tuning knob: a PreToolUse hook that
24
25
  // stalls longer than this costs the user more than the answer is worth, so the
@@ -124,7 +125,7 @@ function runGrepAnswer(opts = {}) {
124
125
  const scope = sanitizeSearchPath(searchPath);
125
126
  const args = ['grep', pattern];
126
127
  if (scope) args.push(scope);
127
- const res = spawnSync(binary, args, {
128
+ const res = spawnSync(binary, args, hidden({
128
129
  cwd,
129
130
  timeout: timeoutMs,
130
131
  encoding: 'utf8',
@@ -133,7 +134,7 @@ function runGrepAnswer(opts = {}) {
133
134
  // Hook-internal run: a delivered answer, not a model-initiated conversion.
134
135
  // The CLI skips its recommendations.jsonl `use` record when this is set.
135
136
  env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
136
- });
137
+ }));
137
138
  if (res.error || res.signal) {
138
139
  return { status: 'unavailable' };
139
140
  }
@@ -190,14 +191,14 @@ function runShowAnswer(opts = {}) {
190
191
  const parts = [];
191
192
  for (const sym of symbols.slice(0, 3)) {
192
193
  if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
193
- const res = spawnSync(binary, ['show', sym], {
194
+ const res = spawnSync(binary, ['show', sym], hidden({
194
195
  cwd,
195
196
  timeout: timeoutMs,
196
197
  encoding: 'utf8',
197
198
  maxBuffer: 4 * 1024 * 1024,
198
199
  stdio: ['ignore', 'pipe', 'ignore'],
199
200
  env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
200
- });
201
+ }));
201
202
  if (res.error || res.signal || res.status !== 0) continue;
202
203
  const out = (res.stdout || '').trim();
203
204
  if (!out || out.startsWith(NO_MATCH_PREFIX)) continue;
@@ -238,14 +239,14 @@ function runOverviewAnswer(opts = {}) {
238
239
  binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
239
240
  }
240
241
  if (!binary) return { status: 'no-binary' };
241
- const res = spawnSync(binary, ['overview', dir], {
242
+ const res = spawnSync(binary, ['overview', dir], hidden({
242
243
  cwd,
243
244
  timeout: timeoutMs,
244
245
  encoding: 'utf8',
245
246
  maxBuffer: 4 * 1024 * 1024,
246
247
  stdio: ['ignore', 'pipe', 'ignore'],
247
248
  env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
248
- });
249
+ }));
249
250
  if (res.error || res.signal || res.status !== 0) {
250
251
  return { status: 'unavailable' };
251
252
  }
@@ -293,14 +294,14 @@ function runCallgraphAnswer(opts = {}) {
293
294
  }
294
295
  if (!binary) return { status: 'no-binary' };
295
296
 
296
- const res = spawnSync(binary, ['callgraph', symbol], {
297
+ const res = spawnSync(binary, ['callgraph', symbol], hidden({
297
298
  cwd,
298
299
  timeout: timeoutMs,
299
300
  encoding: 'utf8',
300
301
  maxBuffer: 4 * 1024 * 1024,
301
302
  stdio: ['ignore', 'pipe', 'ignore'],
302
303
  env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
303
- });
304
+ }));
304
305
  if (res.error || res.signal) return { status: 'unavailable' };
305
306
  // grep-parity exit codes: 1 = symbol not found (no graph node).
306
307
  if (res.status === 1) return { status: 'no-hits' };
@@ -11,6 +11,8 @@ const {
11
11
  installedGlobalPkgs, GLOBAL_INSTALL_MARKER, SHELL_PKG,
12
12
  } = require('./lifecycle');
13
13
  const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
14
+ const { hidden } = require('./proc-opts');
15
+ const { MAX_UPDATE_ATTEMPTS } = require('./auto-update');
14
16
 
15
17
  // ── Diagnostics ───────────────────────────────────────────
16
18
 
@@ -136,12 +138,12 @@ function runDiagnostics({ checkOnly = false } = {}) {
136
138
  if (execOk) {
137
139
  try {
138
140
  const cwd = process.cwd();
139
- const hcOutput = execFileSync(binary, ['health-check', '--json'], {
141
+ const hcOutput = execFileSync(binary, ['health-check', '--json'], hidden({
140
142
  cwd,
141
143
  timeout: 5000,
142
144
  encoding: 'utf8',
143
145
  stdio: ['pipe', 'pipe', 'pipe'],
144
- }).trim();
146
+ })).trim();
145
147
  const hc = JSON.parse(hcOutput);
146
148
 
147
149
  // No-index short-circuit — binary deliberately returns a structured
@@ -202,7 +204,20 @@ function runDiagnostics({ checkOnly = false } = {}) {
202
204
  // 5. Auto-update state
203
205
  try {
204
206
  const state = readJson(path.join(CACHE_DIR, 'update-state.json'));
205
- if (state && state.updateAvailable && state.binaryUpdated === false) {
207
+ const attempts = (state && state.updateAttempts) || 0;
208
+ if (state && state.updateAvailable && attempts >= MAX_UPDATE_ATTEMPTS) {
209
+ // The updater has given up on this release (issue #40). Deliberately NO
210
+ // fixId: re-running `auto-update.js check` is precisely the thing that was
211
+ // suspended, so offering it as a repair would print "✅ Update check
212
+ // complete" and count a fix that cannot happen. Say what is true and hand
213
+ // the user the manual route.
214
+ results.push({
215
+ name: 'Auto-update',
216
+ status: 'warn',
217
+ detail: `v${state.latestVersion} failed to install ${attempts}× — auto-retry suspended until a newer release. `
218
+ + 'Update manually: `npm install -g @sdsrs/code-graph` (or `/plugin update code-graph-mcp`)',
219
+ });
220
+ } else if (state && state.updateAvailable && state.binaryUpdated === false) {
206
221
  results.push({
207
222
  name: 'Auto-update',
208
223
  status: 'warn',
@@ -492,10 +507,10 @@ function runRepairs(results) {
492
507
  if (!isDevMode()) {
493
508
  console.log('\n Triggering binary update...');
494
509
  try {
495
- execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], {
510
+ execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], hidden({
496
511
  timeout: 60000,
497
512
  stdio: 'inherit',
498
- });
513
+ }));
499
514
  console.log(' \u2705 Update check complete');
500
515
  fixed++;
501
516
  } catch {
@@ -516,11 +531,11 @@ function runRepairs(results) {
516
531
  console.log(` \u2192 ${buildCmd}`);
517
532
  try {
518
533
  const projectRoot = path.resolve(__dirname, '..', '..');
519
- execSync(buildCmd, {
534
+ execSync(buildCmd, hidden({
520
535
  cwd: projectRoot,
521
536
  stdio: 'inherit',
522
537
  timeout: 600000, // embed-model (Candle) builds exceed the old 5min
523
- });
538
+ }));
524
539
  clearBinaryCache();
525
540
  console.log(' \u2705 Build complete');
526
541
  fixed++;
@@ -539,11 +554,11 @@ function runRepairs(results) {
539
554
  console.log(' (for semantic search: cargo build --release --features embed-model)');
540
555
  try {
541
556
  const projectRoot = path.resolve(__dirname, '..', '..');
542
- execSync('cargo build --release --no-default-features', {
557
+ execSync('cargo build --release --no-default-features', hidden({
543
558
  cwd: projectRoot,
544
559
  stdio: 'inherit',
545
560
  timeout: 600000,
546
- });
561
+ }));
547
562
  clearBinaryCache();
548
563
  console.log(' \u2705 Build complete');
549
564
  fixed++;
@@ -580,11 +595,11 @@ function runRepairs(results) {
580
595
  console.log('\n Rebuilding index...');
581
596
  console.log(' \u2192 code-graph-mcp incremental-index');
582
597
  try {
583
- execFileSync(binary, ['incremental-index'], {
598
+ execFileSync(binary, ['incremental-index'], hidden({
584
599
  cwd: process.cwd(),
585
600
  stdio: 'inherit',
586
601
  timeout: 120000,
587
- });
602
+ }));
588
603
  console.log(' \u2705 Index rebuilt');
589
604
  fixed++;
590
605
  } catch {
@@ -597,10 +612,10 @@ function runRepairs(results) {
597
612
  case 'update-incomplete': {
598
613
  console.log('\n Completing auto-update...');
599
614
  try {
600
- execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], {
615
+ execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], hidden({
601
616
  timeout: 60000,
602
617
  stdio: 'inherit',
603
- });
618
+ }));
604
619
  console.log(' \u2705 Update check complete');
605
620
  fixed++;
606
621
  } catch {
@@ -5,7 +5,8 @@ const path = require('path');
5
5
  const fs = require('fs');
6
6
  const os = require('os');
7
7
  const { readBinaryVersion, compareVersions } = require('./version-utils');
8
- const { npmSpawnOpts } = require('./npm-exec');
8
+ const { npmInvocation } = require('./npm-exec');
9
+ const { hidden } = require('./proc-opts');
9
10
 
10
11
  const PLATFORM = os.platform();
11
12
  const ARCH = os.arch();
@@ -108,11 +109,12 @@ function globalNodeModulesCandidates() {
108
109
  // 4. Last resort: ask npm directly. Slow (~50-200ms) but most accurate when
109
110
  // user has a non-standard prefix. Cached at the disk-cache layer above.
110
111
  try {
111
- const root = execFileSync('npm', ['root', '-g'], npmSpawnOpts({
112
+ const npm = npmInvocation(['root', '-g'], {
112
113
  timeout: 2000,
113
114
  stdio: ['pipe', 'pipe', 'pipe'],
114
115
  encoding: 'utf8',
115
- })).trim();
116
+ });
117
+ const root = execFileSync(npm.file, npm.args, npm.opts).trim();
116
118
  if (root) out.push(root);
117
119
  } catch { /* npm not on PATH or timed out */ }
118
120
 
@@ -351,7 +353,7 @@ function findBinaryUncached() {
351
353
  // --- PATH lookup (last resort for intentionally installed binaries) ---
352
354
  try {
353
355
  const which = PLATFORM === 'win32' ? 'where' : 'which';
354
- const found = execFileSync(which, [BINARY_NAME], { stdio: ['pipe', 'pipe', 'pipe'] })
356
+ const found = execFileSync(which, [BINARY_NAME], hidden({ stdio: ['pipe', 'pipe', 'pipe'] }))
355
357
  .toString().trim().split('\n')[0];
356
358
  const hit = gate.consider(found);
357
359
  if (hit) return hit;
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
  const { execFileSync } = require('child_process');
4
4
  const { findBinary } = require('./find-binary');
5
+ const { hidden } = require('./proc-opts');
5
6
 
6
7
  // v0.21 — gated default-off. v0.18.0 added query-time freshness
7
8
  // (ensure_file_indexed) inside MCP tools that take a file_path arg, so a
@@ -29,10 +30,10 @@ function runMain() {
29
30
  if (!bin) return; // silent — binary not installed yet
30
31
 
31
32
  try {
32
- execFileSync(bin, ['incremental-index', '--quiet'], {
33
+ execFileSync(bin, ['incremental-index', '--quiet'], hidden({
33
34
  timeout: 8000,
34
35
  stdio: ['pipe', 'pipe', 'pipe']
35
- });
36
+ }));
36
37
  } catch { /* timeout or error — silent for hook */ }
37
38
  }
38
39
 
@@ -22,7 +22,8 @@
22
22
  */
23
23
  const { spawn } = require('child_process');
24
24
  const path = require('path');
25
- const { NPM_NEEDS_SHELL } = require('./npm-exec');
25
+ const { npmInvocation } = require('./npm-exec');
26
+ const { hidden } = require('./proc-opts');
26
27
  const { acquireLock } = require('./install-lock');
27
28
 
28
29
  const NPM_TIMEOUT_MS = 60000;
@@ -48,11 +49,11 @@ function runStep(cmd, args, timeoutMs, prefix, spawnFn, cb, spawnOpts = {}) {
48
49
 
49
50
  let child;
50
51
  try {
51
- child = spawnFn(cmd, args, {
52
+ child = spawnFn(cmd, args, hidden({
52
53
  timeout: timeoutMs,
53
54
  stdio: ['ignore', 'ignore', 'pipe'],
54
55
  ...spawnOpts,
55
- });
56
+ }));
56
57
  } catch (e) {
57
58
  process.stderr.write(`[code-graph] install step ${cmd} failed to start: ${e.message}\n`);
58
59
  done();
@@ -124,7 +125,12 @@ function installBinaryInBackground({
124
125
  return findBinary();
125
126
  };
126
127
 
127
- runStep('npm', ['install', '-g', `@sdsrs/code-graph@${version}`], npmTimeoutMs, '[code-graph][npm]', spawnFn, (npmExit) => {
128
+ // npmInvocation, not a bare ('npm', args, { shell }) pair: on Windows npm is
129
+ // `npm.cmd` (needs a shell) and passing `args` alongside `shell: true` is
130
+ // DEP0190 — runtime-deprecated in Node 24, and unescaped. It pre-quotes the
131
+ // whole command into `file` with empty `args`, and carries windowsHide.
132
+ const npm = npmInvocation(['install', '-g', `@sdsrs/code-graph@${version}`]);
133
+ runStep(npm.file, npm.args, npmTimeoutMs, '[code-graph][npm]', spawnFn, (npmExit) => {
128
134
  if (resolved()) {
129
135
  if (npmExit === 0 && recordGlobalInstall) {
130
136
  try { recordGlobalInstall(); } catch { /* marker is best-effort */ }
@@ -140,7 +146,7 @@ function installBinaryInBackground({
140
146
  // The child would otherwise try to take the same install lock we hold.
141
147
  env: { ...process.env, CODE_GRAPH_INSTALL_LOCK_HELD: '1' },
142
148
  });
143
- }, NPM_NEEDS_SHELL ? { shell: true } : {});
149
+ }, npm.opts);
144
150
  }
145
151
 
146
152
  module.exports = { installBinaryInBackground, runStep, NPM_TIMEOUT_MS, GITHUB_TIMEOUT_MS };
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const { claudeHome } = require('./claude-config');
7
+ const { hidden } = require('./proc-opts');
7
8
 
8
9
  const PLUGIN_ID = 'code-graph-mcp@code-graph-mcp';
9
10
  const OLD_PLUGIN_IDS = [
@@ -879,13 +880,13 @@ function verifyHooksFire({ hooks, env, timeoutMs = 4000, tmpBase } = {}) {
879
880
  for (const h of probes) {
880
881
  let error = null, ok = false, emitted = false, code = null, signal = null;
881
882
  try {
882
- const r = spawnSync(process.execPath, [h.script], {
883
+ const r = spawnSync(process.execPath, [h.script], hidden({
883
884
  input: JSON.stringify(h.payload || {}),
884
885
  cwd: fixture,
885
886
  env: baseEnv,
886
887
  timeout: timeoutMs,
887
888
  encoding: 'utf8',
888
- });
889
+ }));
889
890
  code = r.status;
890
891
  signal = r.signal;
891
892
  ok = !r.error && r.status === 0;
@@ -1061,11 +1062,10 @@ function installedGlobalPkgs() {
1061
1062
 
1062
1063
  function defaultRunNpm(args) {
1063
1064
  const { spawnSync } = require('child_process');
1064
- const { npmSpawnOpts } = require('./npm-exec');
1065
+ const { npmInvocation } = require('./npm-exec');
1065
1066
  try {
1066
- const r = spawnSync('npm', args, npmSpawnOpts({
1067
- timeout: 120000, stdio: 'pipe', encoding: 'utf8',
1068
- }));
1067
+ const npm = npmInvocation(args, { timeout: 120000, stdio: 'pipe', encoding: 'utf8' });
1068
+ const r = spawnSync(npm.file, npm.args, npm.opts);
1069
1069
  return !r.error && r.status === 0;
1070
1070
  } catch { return false; }
1071
1071
  }
@@ -1311,9 +1311,9 @@ function readActiveProcessCmdlines() {
1311
1311
  } catch { /* fall through to ps */ }
1312
1312
  try {
1313
1313
  const { execFileSync } = require('child_process');
1314
- return execFileSync('ps', ['-axww', '-o', 'command='], {
1314
+ return execFileSync('ps', ['-axww', '-o', 'command='], hidden({
1315
1315
  encoding: 'utf8', maxBuffer: 8 * 1024 * 1024,
1316
- }).split('\n').filter(Boolean);
1316
+ })).split('\n').filter(Boolean);
1317
1317
  } catch { /* unsupported platform — caller falls back to recency-only */ }
1318
1318
  return [];
1319
1319
  }
@@ -12,6 +12,7 @@ const path = require('path');
12
12
  const fs = require('fs');
13
13
  const { isNonProjectCwd } = require('./project-detect');
14
14
  const { serveEmptyMcpStub } = require('./mcp-stub');
15
+ const { hidden } = require('./proc-opts');
15
16
 
16
17
  // Set plugin root so find-binary.js can locate bundled/dev binaries
17
18
  // Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
@@ -78,7 +79,7 @@ if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && isNonProjectCwd(process.c
78
79
  const bin = findBinary();
79
80
  if (!bin) return null;
80
81
  process.stderr.write(`[code-graph] cwd became a project — upgrading plugin MCP to real tools via ${bin} (restart Claude Code for full tool steering)\n`);
81
- return spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'inherit'], env: process.env });
82
+ return spawn(bin, ['serve'], hidden({ stdio: ['pipe', 'pipe', 'inherit'], env: process.env }));
82
83
  },
83
84
  },
84
85
  });
@@ -159,7 +160,7 @@ if (!binary) {
159
160
  const bin = findBinary();
160
161
  if (!bin) return null;
161
162
  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
+ return spawn(bin, ['serve'], hidden({ stdio: ['pipe', 'pipe', 'inherit'], env: process.env }));
163
164
  },
164
165
  },
165
166
  });
@@ -205,10 +206,10 @@ try {
205
206
  }
206
207
 
207
208
  // Spawn binary with stdio inheritance for MCP JSON-RPC
208
- const child = spawn(binary, ['serve'], {
209
+ const child = spawn(binary, ['serve'], hidden({
209
210
  stdio: 'inherit',
210
211
  env: process.env,
211
- });
212
+ }));
212
213
 
213
214
  child.on('error', (err) => {
214
215
  process.stderr.write(`[code-graph] Failed to start: ${err.message}\n`);
@@ -3,13 +3,59 @@
3
3
  // .cmd without a shell (and Node >= 18.20 throws EINVAL spawning .cmd directly
4
4
  // as a CVE-2024-27980 mitigation). Every bare `spawn('npm', ...)` in the
5
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.
6
+ // commandExists('npm') (via `where`) said npm was present.
7
+ const { hidden } = require('./proc-opts');
8
+
8
9
  const NPM_NEEDS_SHELL = process.platform === 'win32';
9
10
 
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;
11
+ // Args we are willing to hand to cmd.exe unquoted: flags, package specs,
12
+ // versions, paths. Everything else gets double-quoted; anything that cannot be
13
+ // made safe inside double quotes throws rather than being passed through.
14
+ const SAFE_CMD_ARG = /^[A-Za-z0-9_@./:\\+=-]+$/;
15
+
16
+ /**
17
+ * Quote one argument for the `cmd.exe /d /s /c "..."` line Node builds when
18
+ * `shell: true`. Node itself only space-joins `args` in that mode — the reason
19
+ * DEP0190 runtime-deprecated the combination in Node 24 — so the joining (and
20
+ * the quoting) has to happen here.
21
+ * @param {string} arg
22
+ * @returns {string}
23
+ */
24
+ function quoteCmdArg(arg) {
25
+ const s = String(arg);
26
+ if (SAFE_CMD_ARG.test(s)) return s;
27
+ // `"` ends our quoting; `%` and `!` are expanded inside double quotes; CR/LF
28
+ // splits the command line. None of our call sites produce these — a throw is
29
+ // a loud bug report, not a user-facing failure path.
30
+ if (/["%!\r\n]/.test(s)) {
31
+ throw new Error(`npm argument cannot be safely quoted for cmd.exe: ${JSON.stringify(s)}`);
32
+ }
33
+ return `"${s}"`;
34
+ }
35
+
36
+ /**
37
+ * Build a child_process invocation for `npm <args>` that is correct on both
38
+ * platforms. Spread into spawn/spawnSync/execFileSync:
39
+ *
40
+ * const { file, args, opts } = npmInvocation(['root', '-g'], { timeout: 2000 });
41
+ * execFileSync(file, args, opts);
42
+ *
43
+ * On Windows the whole command is pre-quoted into `file` with an EMPTY `args`
44
+ * array: passing `args` alongside `shell: true` is DEP0190 (runtime-deprecated
45
+ * in Node 24 — it space-joins without escaping, which is a shell-injection
46
+ * hole) and printed a deprecation warning on every npm call.
47
+ *
48
+ * @param {string[]} args
49
+ * @param {object} [opts] - extra child_process options
50
+ * @returns {{file: string, args: string[], opts: object}}
51
+ */
52
+ function npmInvocation(args, opts = {}) {
53
+ if (!NPM_NEEDS_SHELL) return { file: 'npm', args: [...args], opts: hidden(opts) };
54
+ return {
55
+ file: ['npm', ...args.map(quoteCmdArg)].join(' '),
56
+ args: [],
57
+ opts: hidden({ ...opts, shell: true }),
58
+ };
13
59
  }
14
60
 
15
- module.exports = { npmSpawnOpts, NPM_NEEDS_SHELL };
61
+ module.exports = { npmInvocation, quoteCmdArg, NPM_NEEDS_SHELL };
@@ -16,6 +16,7 @@
16
16
  // cg-answer.js).
17
17
 
18
18
  const { spawnSync } = require('child_process');
19
+ const { hidden } = require('./proc-opts');
19
20
 
20
21
  const MARKER = '<!-- code-graph-impact-review -->';
21
22
  const SPAWN_TIMEOUT_MS = 60_000;
@@ -88,7 +89,7 @@ function resolveBinary() {
88
89
  }
89
90
 
90
91
  function runAffected(binary, args, cwd, stdin) {
91
- const res = spawnSync(binary, args, {
92
+ const res = spawnSync(binary, args, hidden({
92
93
  cwd,
93
94
  input: stdin,
94
95
  timeout: SPAWN_TIMEOUT_MS,
@@ -96,7 +97,7 @@ function runAffected(binary, args, cwd, stdin) {
96
97
  maxBuffer: 16 * 1024 * 1024,
97
98
  stdio: ['pipe', 'pipe', 'ignore'],
98
99
  env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
99
- });
100
+ }));
100
101
  if (res.error || res.signal || res.status !== 0) return null;
101
102
  try {
102
103
  return JSON.parse((res.stdout || '').trim());
@@ -201,10 +202,10 @@ function renderMarkdown(review) {
201
202
  /// Upsert a sticky comment: find an existing comment containing MARKER and PATCH
202
203
  /// it, else POST a new one. Uses `gh api` (preinstalled on GitHub runners).
203
204
  function upsertComment(repo, prNumber, body) {
204
- const gh = (args, input) => spawnSync('gh', args, {
205
+ const gh = (args, input) => spawnSync('gh', args, hidden({
205
206
  encoding: 'utf8', input, timeout: SPAWN_TIMEOUT_MS,
206
207
  env: { ...process.env },
207
- });
208
+ }));
208
209
 
209
210
  const list = gh(['api', '--paginate', `repos/${repo}/issues/${prNumber}/comments`]);
210
211
  let existingId = null;
@@ -15,6 +15,7 @@ const { resolveProjectRoot } = require('./project-root');
15
15
  const { recordRecommendation } = require('./recommendation-log');
16
16
  const { formatCoveringTests } = require('./covering-tests');
17
17
  const { emitPreToolAllowContext } = require('./hook-emit');
18
+ const { hidden } = require('./proc-opts');
18
19
 
19
20
  // v0.49 — walk up from the shell cwd (subdir-cwd fix). The per-cwd index.db
20
21
  // gate kept this hook dark for entire sessions after `cd backend/` — daagu
@@ -81,10 +82,10 @@ if (!symbol || symbol.length < 3) {
81
82
  .sort((a, b) => b.length - a.length);
82
83
  for (const candidate of candidates.slice(0, 5)) {
83
84
  try {
84
- const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], {
85
+ const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], hidden({
85
86
  cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
86
87
  env: internalEnv,
87
- });
88
+ }));
88
89
  const grepResult = JSON.parse(raw);
89
90
  // Pick this candidate if it has few matches (precise location)
90
91
  const withContainer = (grepResult || []).filter(m => m.container && m.container.name);
@@ -136,13 +137,13 @@ try {
136
137
  if (relFile && !relFile.startsWith('..')) args.push('--file', relFile);
137
138
  // v0.49 — use the resolved binary (bare 'code-graph-mcp' was PATH-dependent,
138
139
  // diverging from the findBinary() result the rest of the hook trusts).
139
- const raw = execFileSync(binary, args, {
140
+ const raw = execFileSync(binary, args, hidden({
140
141
  cwd,
141
142
  timeout: 2500,
142
143
  encoding: 'utf8',
143
144
  stdio: ['pipe', 'pipe', 'pipe'],
144
145
  env: internalEnv,
145
- });
146
+ }));
146
147
  jsonResult = JSON.parse(raw);
147
148
  } catch {
148
149
  // Symbol not found, timeout, or parse error — exit silently
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+ /**
3
+ * Child-process option defaults shared by every spawn/exec in this plugin.
4
+ *
5
+ * Windows creates a NEW visible console window for every console-subsystem
6
+ * child whose parent has no console of its own — and none of our parents do:
7
+ * the MCP server (`node mcp-launcher.js`), the hooks and the statusline are all
8
+ * launched hidden by Claude Code. Node's `windowsHide` defaults to `false` on
9
+ * EVERY child_process API (spawn/spawnSync/exec/execSync/execFile/execFileSync),
10
+ * so each `where` / `curl` / `tar` / `npm` child flashed a console window for
11
+ * ~1s and stole keyboard focus. Reported as 5–7 flashes per session start
12
+ * (issue #40); the auto-update treadmill fixed alongside it made that per
13
+ * session, forever.
14
+ *
15
+ * `windowsHide: true` maps to CREATE_NO_WINDOW, which only stops the child from
16
+ * ALLOCATING a console — inherited stdio handles still work, so an interactive
17
+ * `doctor` run in a real terminal is unaffected. No-op on non-Windows.
18
+ *
19
+ * Every child_process call site under claude-plugin/scripts/ must route through
20
+ * here (or set windowsHide itself); `windows-hide.test.js` fails the build on a
21
+ * new call site that doesn't.
22
+ */
23
+ function hidden(opts = {}) {
24
+ return { windowsHide: true, ...opts };
25
+ }
26
+
27
+ module.exports = { hidden };
@@ -11,6 +11,7 @@ const {
11
11
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
12
12
  const { maybeAutoAdopt, isAdopted, unadopt } = require('./adopt');
13
13
  const { isNonProjectCwd } = require('./project-detect');
14
+ const { hidden } = require('./proc-opts');
14
15
 
15
16
  // v0.17.0 — quietHooks: unconditional quiet 默认。
16
17
  // 项目地图与 MEMORY.md plugin contract + on-demand `project_map` 工具高度重叠,
@@ -168,16 +169,20 @@ function formatRecentImpact(changed, affected, dependentCap = 6) {
168
169
 
169
170
  function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env, { force = false } = {}) {
170
171
  try {
172
+ // Documented opt-out (issue #40). Checked HERE as well as inside
173
+ // auto-update.js so an opted-out user doesn't pay for a node process per
174
+ // session just to have it exit immediately.
175
+ if (env.CODE_GRAPH_NO_AUTO_UPDATE === '1') return false;
171
176
  const args = [path.join(__dirname, 'auto-update.js'), 'check', '--silent'];
172
177
  // A session start / reload forces an immediate check (bypasses the soft
173
178
  // throttle down to auto-update.js's short anti-hammer floor + rate-limit
174
179
  // backoff), so an available update is picked up now rather than on the next tick.
175
180
  if (force) args.push('--force');
176
- const child = spawnFn(process.execPath, args, {
181
+ const child = spawnFn(process.execPath, args, hidden({
177
182
  detached: true,
178
183
  stdio: 'ignore',
179
184
  env: { ...env, CODE_GRAPH_AUTO_UPDATE_SILENT: '1' },
180
- });
185
+ }));
181
186
  if (child && typeof child.unref === 'function') child.unref();
182
187
  return true;
183
188
  } catch {
@@ -322,7 +327,7 @@ function indexNeedsRevalidation(bin, cwd) {
322
327
  let out;
323
328
  try {
324
329
  out = execFileSync(bin, ['health-check', '--format', 'json'],
325
- { cwd, timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
330
+ hidden({ cwd, timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] })).toString();
326
331
  } catch (e) {
327
332
  // health-check exits non-zero on an unhealthy index but still writes JSON.
328
333
  out = ((e && e.stdout) || '').toString();
@@ -363,7 +368,7 @@ function ensureIndexFresh() {
363
368
  try {
364
369
  const dbMtime = fs.statSync(dbPath).mtimeMs;
365
370
  const gitTs = parseInt(
366
- execSync('git log -1 --format=%ct', { cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
371
+ execSync('git log -1 --format=%ct', hidden({ cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })).trim()
367
372
  ) * 1000;
368
373
  if (gitTs > dbMtime) needsRefresh = true;
369
374
  } catch { /* no git / not a repo — fall through to the version probe */ }
@@ -373,11 +378,11 @@ function ensureIndexFresh() {
373
378
 
374
379
  if (!needsRefresh) return 'fresh';
375
380
 
376
- const child = spawn(bin, ['incremental-index', '--quiet'], {
381
+ const child = spawn(bin, ['incremental-index', '--quiet'], hidden({
377
382
  cwd,
378
383
  detached: true,
379
384
  stdio: 'ignore',
380
- });
385
+ }));
381
386
  if (child && typeof child.unref === 'function') child.unref();
382
387
  return 'refreshing';
383
388
  }
@@ -415,7 +420,7 @@ function verifyBinary() {
415
420
  // On macOS, verify the binary can actually run (Gatekeeper may block it)
416
421
  if (process.platform === 'darwin') {
417
422
  try {
418
- execFileSync(binary, ['--version'], { timeout: 3000, stdio: 'pipe' });
423
+ execFileSync(binary, ['--version'], hidden({ timeout: 3000, stdio: 'pipe' }));
419
424
  } catch (err) {
420
425
  const msg = (err.message || '') + (err.stderr ? err.stderr.toString() : '');
421
426
  if (msg.includes('quarantine') || msg.includes('not permitted') ||
@@ -674,7 +679,7 @@ function injectProjectMap() {
674
679
  const bin = findBinary();
675
680
  if (!bin) return false;
676
681
 
677
- const output = execFileSync(bin, ['map', '--compact'], {
682
+ const output = execFileSync(bin, ['map', '--compact'], hidden({
678
683
  cwd,
679
684
  timeout: 5000,
680
685
  encoding: 'utf8',
@@ -682,7 +687,7 @@ function injectProjectMap() {
682
687
  // Hook-internal delivery, not a model conversion — keep record_cli_use from
683
688
  // logging this `map` run as a phantom `use` (mirror injectRecentImpact's affected call).
684
689
  env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
685
- });
690
+ }));
686
691
 
687
692
  if (output && output.trim()) {
688
693
  process.stdout.write(
@@ -723,7 +728,7 @@ function injectRecentImpact({ source } = {}) {
723
728
  // last commit. Timeouts tightened (finding #1): worst-case cap sum is now
724
729
  // status(1s) + HEAD~1(1s) + affected(1.5s) = 3.5s, comfortably under the 5s
725
730
  // SessionStart hook budget; the old 2+2+3=7s could get the whole hook killed.
726
- const gitOpts = { cwd: sessionDir, timeout: 1000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
731
+ const gitOpts = hidden({ cwd: sessionDir, timeout: 1000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
727
732
  let changed = [];
728
733
  let isWip = false;
729
734
  try {
@@ -749,10 +754,10 @@ function injectRecentImpact({ source } = {}) {
749
754
 
750
755
  let affected;
751
756
  try {
752
- const raw = execFileSync(bin, ['affected', ...changed, '--json'], {
757
+ const raw = execFileSync(bin, ['affected', ...changed, '--json'], hidden({
753
758
  cwd, timeout: 1500, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
754
759
  env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
755
- });
760
+ }));
756
761
  affected = JSON.parse(raw);
757
762
  } catch {
758
763
  return false;
@@ -803,9 +808,9 @@ function checkHookFiring({ now = Date.now() } = {}) {
803
808
  // surface from the next start. Re-checks daily (catches post-install drift,
804
809
  // e.g. a node upgrade that breaks a hook).
805
810
  try {
806
- const child = spawn(process.execPath, [path.join(__dirname, 'lifecycle.js'), 'verify-hooks-fire'], {
811
+ const child = spawn(process.execPath, [path.join(__dirname, 'lifecycle.js'), 'verify-hooks-fire'], hidden({
807
812
  detached: true, stdio: 'ignore',
808
- });
813
+ }));
809
814
  if (child && typeof child.unref === 'function') child.unref();
810
815
  } catch { /* ok */ }
811
816
  }
@@ -9,6 +9,7 @@ const { execFileSync } = require('child_process');
9
9
  const path = require('path');
10
10
  const os = require('os');
11
11
  const lifecycle = require('./lifecycle');
12
+ const { hidden } = require('./proc-opts');
12
13
  const { readRegistry } = lifecycle;
13
14
  const cleanupDisabledStatusline = lifecycle.cleanupDisabledStatusline || (() => ({ cleaned: false }));
14
15
 
@@ -79,12 +80,12 @@ function runProvider(command, needsStdin, stdin) {
79
80
  const cwd = cwdFromStdin(stdin);
80
81
  const env = cwd ? { ...process.env, CODE_GRAPH_STATUSLINE_CWD: cwd } : process.env;
81
82
 
82
- const out = execFileSync(argv[0], argv.slice(1), {
83
+ const out = execFileSync(argv[0], argv.slice(1), hidden({
83
84
  timeout: 3000,
84
85
  stdio: ['pipe', 'pipe', 'pipe'],
85
86
  input: needsStdin ? stdin : '',
86
87
  env,
87
- }).toString().trim();
88
+ })).toString().trim();
88
89
 
89
90
  return out || null;
90
91
  } catch { return null; }
@@ -7,6 +7,7 @@ const path = require('path');
7
7
  const { findBinary } = require('./find-binary');
8
8
  const { resolveProjectRoot } = require('./project-root');
9
9
  const lifecycle = require('./lifecycle');
10
+ const { hidden } = require('./proc-opts');
10
11
  const cleanupDisabledStatusline = lifecycle.cleanupDisabledStatusline || (() => ({ cleaned: false }));
11
12
 
12
13
  // True when auto-update has a newer release queued or in flight (the background
@@ -155,14 +156,14 @@ try {
155
156
  // health-check (e.g. CPU saturated by the embedding backfill) and the segment
156
157
  // silently vanished. Keeping the inner budget well under the outer one turns
157
158
  // "slow health-check" into a rendered "offline"/"updating" instead of a blank.
158
- report = parseReport(execFileSync(bin, ['health-check', '--format', 'json'], {
159
+ report = parseReport(execFileSync(bin, ['health-check', '--format', 'json'], hidden({
159
160
  timeout: 1500,
160
161
  stdio: ['pipe', 'pipe', 'pipe'],
161
162
  // Run the binary FROM the resolved root so its own project-root resolution
162
163
  // lands on the same DB the gate above picked (a subdir cwd would otherwise
163
164
  // re-resolve to a stray nested index inside the binary).
164
165
  cwd: root
165
- }).toString());
166
+ })).toString());
166
167
  } catch (e) {
167
168
  // health-check exits NON-ZERO on an unhealthy/empty index but still writes the
168
169
  // full JSON report to stdout. The binary ran fine \u2014 recover the report from the
@@ -13,6 +13,7 @@ const os = require('os');
13
13
  // diagnostic blindness + the §8 recursive-grep footgun (see tmp-dir.js). The
14
14
  // other hook scripts already route through here; this one was the lone holdout.
15
15
  const { cgTmpDir } = require('./tmp-dir');
16
+ const { hidden } = require('./proc-opts');
16
17
 
17
18
  // Mid-session install detection: hook fires but no manifest yet.
18
19
  const MANIFEST_PATH = path.join(os.homedir(), '.cache', 'code-graph', 'install-manifest.json');
@@ -467,13 +468,13 @@ function runMain() {
467
468
  };
468
469
 
469
470
  function run(cmd, args) {
470
- return execFileSync(cmd, args, {
471
+ return execFileSync(cmd, args, hidden({
471
472
  cwd,
472
473
  timeout: 3000,
473
474
  encoding: 'utf8',
474
475
  stdio: ['pipe', 'pipe', 'pipe'],
475
476
  env: buildRunEnv(),
476
- });
477
+ }));
477
478
  }
478
479
 
479
480
  try {
@@ -2,6 +2,7 @@
2
2
  const { execFileSync } = require('child_process');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { hidden } = require('./proc-opts');
5
6
 
6
7
  // Tolerant match: the version line anywhere in the output (m flag), optional
7
8
  // "v" prefix, and anything after the numeric triple (build-metadata suffixes
@@ -13,12 +14,12 @@ const VERSION_OUTPUT_RE = /^code-graph-mcp\s+v?(\d+\.\d+\.\d+)/m;
13
14
 
14
15
  function readBinaryVersion(binaryPath) {
15
16
  try {
16
- const out = execFileSync(binaryPath, ['--version'], {
17
+ const out = execFileSync(binaryPath, ['--version'], hidden({
17
18
  // 5s: a cold exec of a freshly-written ~40MB binary (page-in, Windows AV
18
19
  // scan) regularly exceeded the old 2s, misclassifying a good binary.
19
20
  timeout: 5000,
20
21
  stdio: ['pipe', 'pipe', 'pipe'],
21
- }).toString().trim();
22
+ })).toString().trim();
22
23
  const match = out.match(VERSION_OUTPUT_RE);
23
24
  return match ? match[1] : null;
24
25
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.109.0",
3
+ "version": "0.111.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": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.109.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.109.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.109.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.109.0",
42
- "@sdsrs/code-graph-win32-x64": "0.109.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.111.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.111.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.111.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.111.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.111.0"
43
43
  }
44
44
  }