@sdsrs/code-graph 0.108.0 → 0.109.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
@@ -235,7 +235,14 @@ Remove the `code-graph` entry from your MCP settings file (e.g. `~/.cursor/mcp.j
235
235
 
236
236
  ### npm (Global)
237
237
 
238
+ Run the teardown **first**, while the CLI still exists — npm 7 removed the
239
+ `preuninstall`/`postuninstall` lifecycle scripts, so nothing runs on your behalf
240
+ during `npm uninstall`. Doing it in the other order leaves the hook entries in
241
+ `~/.claude/settings.json` and the ~40 MB binary cache behind, with no command
242
+ left on disk to remove them.
243
+
238
244
  ```bash
245
+ code-graph-mcp uninstall # restore statusline, strip hooks, drop the cache
239
246
  npm uninstall -g @sdsrs/code-graph
240
247
  ```
241
248
 
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.108.0",
7
+ "version": "0.109.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -39,7 +39,13 @@ const BINARY_CACHE_DIR = path.join(CACHE_DIR, 'bin');
39
39
  const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — steady-state re-check
40
40
  const UP_TO_DATE_RECHECK_MS = 30 * 60 * 1000; // 30min — re-verify an "up to date" result (release-race guard)
41
41
  const SESSION_START_MIN_GAP_MS = 2 * 60 * 1000; // 2min — anti-hammer floor for forced (session-start) checks
42
- const RATE_LIMIT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h if rate-limited
42
+ // GitHub's unauthenticated REST quota is 60 req/hr and resets HOURLY, so one
43
+ // hour is the whole wait — 24h was written when this constant was unreachable
44
+ // (checkForUpdate erased `rateLimited` on the same call that set it) and became
45
+ // load-bearing the moment that was fixed, having never once been exercised. At
46
+ // 24h a single 403 on a shared/NAT'd IP froze every update check for a day,
47
+ // `--force` included, because the backoff arm sits above the force arm below.
48
+ const RATE_LIMIT_INTERVAL_MS = 60 * 60 * 1000; // 1h — GitHub's reset window
43
49
  const FETCH_TIMEOUT_MS = 3000;
44
50
 
45
51
  function isSilentMode(argv = process.argv.slice(2), env = process.env) {
@@ -92,8 +98,10 @@ function saveState(state) {
92
98
 
93
99
  // Whether to hit GitHub now. Keyed to the previous check's outcome, with a force
94
100
  // override for high-intent triggers (session start / explicit reload). Ordering:
95
- // 1. rate-limit backoff (24h) wins over everything never push more requests
96
- // into a GitHub 403.
101
+ // 1. rate-limit backoff (RATE_LIMIT_INTERVAL_MS, 1h = GitHub's own reset
102
+ // window) wins over everything, force included — never push more requests
103
+ // into a GitHub 403. Safe to outrank force only because it is an hour; the
104
+ // 24h it said before made one 403 a silent day-long no-op for `--force`.
97
105
  // 2. force → only the short SESSION_START_MIN_GAP_MS floor applies, so opening
98
106
  // a new session re-checks immediately while a crash/reopen loop still can't
99
107
  // hammer the API.
@@ -204,23 +212,29 @@ function requestJson(url, timeoutMs = FETCH_TIMEOUT_MS) {
204
212
  });
205
213
  }
206
214
 
215
+ // Published by release.yml alongside the five platform binaries, each with a
216
+ // `.sha256` sidecar. Distinct from `tarball_url`, which is GitHub's
217
+ // auto-generated source archive and has no checksum published anywhere.
218
+ const PLUGIN_ASSET_NAME = 'claude-plugin.tar.gz';
219
+
207
220
  function parseLatestRelease(data, assetName = getPlatformAssetName()) {
208
221
  if (!data || typeof data.tag_name !== 'string' || typeof data.tarball_url !== 'string') {
209
222
  return null;
210
223
  }
211
224
 
212
- let binaryUrl = null;
213
- if (assetName && Array.isArray(data.assets)) {
214
- const asset = data.assets.find((entry) => entry && entry.name === assetName);
215
- if (asset && typeof asset.browser_download_url === 'string') {
216
- binaryUrl = asset.browser_download_url;
217
- }
218
- }
225
+ const assetUrl = (name) => {
226
+ if (!name || !Array.isArray(data.assets)) return null;
227
+ const asset = data.assets.find((entry) => entry && entry.name === name);
228
+ return asset && typeof asset.browser_download_url === 'string'
229
+ ? asset.browser_download_url
230
+ : null;
231
+ };
219
232
 
220
233
  return {
221
234
  version: data.tag_name.replace(/^v/, ''),
222
235
  tarballUrl: data.tarball_url,
223
- binaryUrl,
236
+ pluginTarballUrl: assetUrl(PLUGIN_ASSET_NAME),
237
+ binaryUrl: assetUrl(assetName),
224
238
  };
225
239
  }
226
240
 
@@ -330,19 +344,35 @@ async function downloadBinary(latest) {
330
344
  latest.binaryUrl,
331
345
  ], { timeout: 60000, stdio: 'pipe' });
332
346
 
333
- // Best-effort fetch of the integrity sidecar (<asset>.sha256). curl -f makes
334
- // a 404 (an older release with no sidecar) fail expectedSha stays null →
335
- // promoteVerifiedBinary takes the TOFU path. Same-origin, so this guards
336
- // corruption in transit, not a release-asset swap (version-exec is the
337
- // backstop there).
347
+ // Integrity sidecar (<asset>.sha256), fail-CLOSED. `curl -f` turns a 404 into
348
+ // a throw. One retry, because the alternative to a transient network blip is
349
+ // no update this cycle — the installed binary keeps working and the next
350
+ // check tries again, which is a strictly safer failure than exec'ing bytes
351
+ // nothing vouched for.
352
+ //
353
+ // This used to fall through to a TOFU path on a missing sidecar, which made
354
+ // it the one download chain in the repo that was fail-OPEN while
355
+ // `src/snapshot/install.rs` (whose comment reads "this used to warn and fail
356
+ // OPEN") is fail-closed. release.yml publishes a sidecar for every binary of
357
+ // every release — verified back to v0.100.0 — and downloads always target
358
+ // `releases/latest`, so there is no reachable no-sidecar case left to serve.
359
+ // Same-origin, so this defends transit/CDN corruption and truncation, not a
360
+ // release-asset swap; the version-exec check is the backstop there.
338
361
  let expectedSha = null;
339
362
  const shaTmp = binaryTmp + '.sha256';
340
- try {
341
- execFileSync('curl', ['-sfL', '-o', shaTmp, latest.binaryUrl + '.sha256'],
342
- { timeout: 30000, stdio: 'pipe' });
343
- expectedSha = (fs.readFileSync(shaTmp, 'utf8').trim().split(/\s+/)[0]) || null;
344
- } catch { /* no sidecar → TOFU */ } finally {
345
- try { if (fs.existsSync(shaTmp)) fs.unlinkSync(shaTmp); } catch { /* ok */ }
363
+ for (let attempt = 0; attempt < 2 && !expectedSha; attempt++) {
364
+ try {
365
+ execFileSync('curl', ['-sfL', '-o', shaTmp, latest.binaryUrl + '.sha256'],
366
+ { timeout: 30000, stdio: 'pipe' });
367
+ expectedSha = (fs.readFileSync(shaTmp, 'utf8').trim().split(/\s+/)[0]) || null;
368
+ } catch { /* retry once, then refuse below */ } finally {
369
+ try { if (fs.existsSync(shaTmp)) fs.unlinkSync(shaTmp); } catch { /* ok */ }
370
+ }
371
+ }
372
+ if (!expectedSha) {
373
+ console.error(`[code-graph] Refusing to install: no sha256 sidecar for ${latest.binaryUrl} (fetched twice). The current binary is unchanged; the next update check will retry.`);
374
+ try { fs.unlinkSync(binaryTmp); } catch { /* ok */ }
375
+ return false;
346
376
  }
347
377
 
348
378
  return promoteVerifiedBinary(binaryTmp, binaryDst, latest.version, expectedSha);
@@ -370,17 +400,21 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
370
400
  // or tampered download is never exec'd. The published <asset>.sha256 sidecar
371
401
  // is same-origin, so this defends transit/CDN corruption + truncation, not a
372
402
  // full release compromise (an attacker swapping the binary swaps the sidecar
373
- // too — the version-exec check below is the backstop there). No sidecar
374
- // (older release) → warn + proceed (TOFU), preserving the size + version
375
- // gates. Mirrors the snapshot checksum convention (src/snapshot/install.rs).
376
- if (expectedSha256) {
377
- const actualSha = sha256File(binaryTmp);
378
- if (actualSha.toLowerCase() !== String(expectedSha256).toLowerCase()) {
379
- console.error(`[code-graph] Binary checksum mismatch (sha256): expected ${expectedSha256}, got ${actualSha} — refusing to install.`);
380
- return false;
381
- }
382
- } else {
383
- console.error('[code-graph] No binary checksum sidecar found — content not verified (size + version checks still apply).');
403
+ // too — the version-exec check below is the backstop there).
404
+ //
405
+ // Fail-CLOSED: no expected sha, no install. The previous "warn and proceed"
406
+ // arm made this the only fail-open link in the four download chains, against
407
+ // a fail-closed `src/snapshot/install.rs` — and a warning printed to stderr
408
+ // during a background auto-update is seen by nobody.
409
+ if (!expectedSha256) {
410
+ console.error('[code-graph] No expected sha256 supplied — refusing to install an unverified binary.');
411
+ try { fs.unlinkSync(binaryTmp); } catch { /* ok */ }
412
+ return false;
413
+ }
414
+ const actualSha = sha256File(binaryTmp);
415
+ if (actualSha.toLowerCase() !== String(expectedSha256).toLowerCase()) {
416
+ console.error(`[code-graph] Binary checksum mismatch (sha256): expected ${expectedSha256}, got ${actualSha} — refusing to install.`);
417
+ return false;
384
418
  }
385
419
 
386
420
  // chmod BEFORE reading the version. readBinaryVersion executes the binary
@@ -460,16 +494,54 @@ async function downloadAndInstall(latest, {
460
494
  try {
461
495
  fs.mkdirSync(tmpDir, { recursive: true });
462
496
 
463
- // ── Step 1: Download and install plugin files from tarball ──
464
- const tarballPath = path.join(tmpDir, 'release.tar.gz');
497
+ // ── Step 1: Download and install plugin files from the release asset ──
498
+ //
499
+ // Fail-CLOSED on integrity, like the binary chain. This step extracts an
500
+ // archive and then COPIES ITS JAVASCRIPT into the plugin cache, where Claude
501
+ // Code runs it as hooks on every tool call — so of the four download chains
502
+ // it is the one where unverified bytes become executed code, and it was the
503
+ // only one with no checksum at all (`tarball_url` is GitHub's generated
504
+ // source archive; nothing publishes a digest for it).
505
+ // `claude-plugin.tar.gz` + `.sha256` are published by release.yml for every
506
+ // release from the one carrying this change onward, and updates always
507
+ // target `releases/latest` — so a missing asset means something is wrong
508
+ // with the release, not that we are talking to an older one. Refusing leaves
509
+ // the user on their current, working plugin version; the binary update below
510
+ // still runs.
511
+ if (!latest.pluginTarballUrl) {
512
+ console.error(`[code-graph] Plugin update skipped: release ${latest.version} publishes no ${PLUGIN_ASSET_NAME} — refusing to install plugin code from an unverifiable source archive.`);
513
+ return { pluginUpdated: false, binaryUpdated: await downloadBin(latest), marketplaceRefreshed: false };
514
+ }
515
+ const tarballPath = path.join(tmpDir, PLUGIN_ASSET_NAME);
465
516
  exec('curl', [
466
517
  '-sL', '-o', tarballPath,
467
- '-H', 'Accept: application/vnd.github+json',
468
- latest.tarballUrl,
518
+ '-H', 'Accept: application/octet-stream',
519
+ latest.pluginTarballUrl,
469
520
  ], { timeout: 30000, stdio: 'pipe' });
470
521
 
522
+ // One retry, matching the binary sidecar at :355 — same failure mode, same
523
+ // argument: a transient blip should cost an update cycle, not force a
524
+ // refusal. The first version of this gave the binary two attempts and the
525
+ // plugin one, for no reason anyone could state.
526
+ const shaPath = tarballPath + '.sha256';
527
+ let expectedSha = null;
528
+ for (let attempt = 0; attempt < 2 && !expectedSha; attempt++) {
529
+ try {
530
+ exec('curl', ['-sfL', '-o', shaPath, latest.pluginTarballUrl + '.sha256'],
531
+ { timeout: 30000, stdio: 'pipe' });
532
+ expectedSha = (fs.readFileSync(shaPath, 'utf8').trim().split(/\s+/)[0]) || null;
533
+ } catch { /* retried once, then refused just below */ }
534
+ }
535
+ const actualSha = fs.existsSync(tarballPath) ? sha256File(tarballPath) : null;
536
+ if (!expectedSha || !actualSha || expectedSha.toLowerCase() !== actualSha.toLowerCase()) {
537
+ console.error(`[code-graph] Plugin tarball integrity check failed (expected ${expectedSha || '<no sidecar>'}, got ${actualSha || '<no download>'}) — refusing to extract.`);
538
+ return { pluginUpdated: false, binaryUpdated: await downloadBin(latest), marketplaceRefreshed: false };
539
+ }
540
+
541
+ // No --strip-components: the asset archives `claude-plugin/` itself, while
542
+ // GitHub's source tarball wraps everything in `<owner>-<repo>-<sha>/`.
471
543
  exec('tar', [
472
- 'xzf', tarballPath, '-C', tmpDir, '--strip-components=1',
544
+ 'xzf', tarballPath, '-C', tmpDir,
473
545
  ], { timeout: 15000, stdio: 'pipe' });
474
546
 
475
547
  const pluginSrc = path.join(tmpDir, 'claude-plugin');
@@ -666,9 +738,18 @@ async function selfHealGlobalPkgs(latest, state, {
666
738
  const attempts = state.globalPkgHealVersion === latest.version ? (state.globalPkgHealAttempts || 0) : 0;
667
739
  if (attempts >= GLOBAL_PKG_HEAL_MAX_ATTEMPTS) return {};
668
740
  const ok = await install(stale.map((s) => `${s.name}@${latest.version}`));
741
+ // Success is "the stale copies are gone", not "npm exited 0". `npm i -g`
742
+ // installs into the prefix the CURRENT node resolves, which is not
743
+ // necessarily where the stale copy lives (nvm with several node versions,
744
+ // an `npm --prefix` in the user's npmrc, a sudo-owned /usr/local). In that
745
+ // shape npm reported success on every run while `staleGlobalPkgs` kept
746
+ // returning the same entry — the counter reset each time, so the retry
747
+ // budget never ran out and the install re-ran forever, once per throttle
748
+ // window. Re-read instead of trusting the exit code.
749
+ const remaining = ok ? readStale(latest.version) : stale;
669
750
  return {
670
751
  globalPkgHealVersion: latest.version,
671
- globalPkgHealAttempts: ok ? 0 : attempts + 1,
752
+ globalPkgHealAttempts: remaining.length === 0 ? 0 : attempts + 1,
672
753
  };
673
754
  }
674
755
 
@@ -687,7 +768,11 @@ function shouldHealGlobalsOnThrottle(state, { readStale = staleGlobalPkgs } = {}
687
768
  return readStale(state.latestVersion).length > 0;
688
769
  }
689
770
 
690
- async function checkForUpdate({ installMissing = false, force = false } = {}) {
771
+ // `requestJsonFn` is a test seam, forwarded to fetchLatestRelease the same
772
+ // injection point that function already exposes. It exists so the 403 path can
773
+ // be driven without a network: that path is where the rate-limit backoff either
774
+ // engages or is silently erased, and no other observable distinguishes the two.
775
+ async function checkForUpdate({ installMissing = false, force = false, requestJsonFn } = {}) {
691
776
  let installLock = null;
692
777
  try {
693
778
  // Skip in dev mode — unless the launcher explicitly requested a missing-
@@ -729,9 +814,17 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
729
814
  }
730
815
 
731
816
  // Check GitHub for latest release
732
- const latest = await fetchLatestRelease();
817
+ const latest = await fetchLatestRelease(requestJsonFn || requestJson);
733
818
  if (!latest) {
734
- saveState({ ...state, installedVersion, lastCheck: new Date().toISOString() });
819
+ // Re-read, do NOT spread the pre-fetch `state`. On a 403 fetchLatestRelease
820
+ // writes `rateLimited: true` to the state file, and this is the branch it
821
+ // returns null through — spreading the stale snapshot wrote that flag
822
+ // straight back to whatever it was before (normally absent). The
823
+ // RATE_LIMIT_INTERVAL_MS backoff in shouldCheck() therefore never engaged:
824
+ // it read a state where rateLimited had just been erased by the very call
825
+ // that set it, and kept polling GitHub on the ordinary interval while
826
+ // already rate-limited. Dead code since the backoff was written.
827
+ saveState({ ...readState(), installedVersion, lastCheck: new Date().toISOString() });
735
828
  return null;
736
829
  }
737
830
 
@@ -822,6 +915,7 @@ module.exports = {
822
915
  getExtractedPluginVersion, readBinaryVersion, promoteVerifiedBinary,
823
916
  isSilentMode, isInstallMissingMode, isForceMode,
824
917
  requestJson, resolveProxy, parseLatestRelease, fetchLatestRelease,
918
+ PLUGIN_ASSET_NAME,
825
919
  downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
826
920
  getPlatformAssetName,
827
921
  selfHealStaleBinary,
@@ -90,6 +90,21 @@ function readJsonResult(filePath) {
90
90
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
91
91
  return { value: null, missing: false, corrupt: true, raw: bytes };
92
92
  }
93
+ // VALID JSON can still have been decoded lossily. `toString('utf8')`
94
+ // substitutes U+FFFD for every invalid byte, and a latin-1/cp1252 byte
95
+ // inside a path (a non-ASCII username on a legacy code page, a hand-edited
96
+ // file) parses fine — so the object round-trips through JSON.stringify and
97
+ // the atomic write replaces those bytes with U+FFFD PERMANENTLY. The
98
+ // byte-exactness work above only covered the corrupt branch; this branch
99
+ // rewrote the file with no backup and no message at all.
100
+ //
101
+ // Re-encoding the decoded text and comparing to the original bytes detects
102
+ // exactly that: a lossless decode round-trips, a lossy one cannot. Reported
103
+ // as `lossy` rather than `corrupt` because the VALUE is usable — the caller
104
+ // preserves the true bytes first, then proceeds with it.
105
+ if (!Buffer.from(raw, 'utf8').equals(bytes)) {
106
+ return { value, missing: false, corrupt: false, lossy: true, raw: bytes };
107
+ }
93
108
  return { value, missing: false, corrupt: false, raw: bytes };
94
109
  } catch (err) {
95
110
  return { value: null, missing: false, corrupt: true, raw: bytes, error: err };
@@ -145,6 +160,27 @@ function backupCorruptFile(filePath, raw) {
145
160
  function readSettingsForWrite() {
146
161
  const p = settingsPath();
147
162
  const res = readJsonResult(p);
163
+ if (res.value && res.lossy) {
164
+ // Usable JSON whose bytes will not survive our rewrite (see readJsonResult).
165
+ // Preserve the true bytes, then proceed with the parsed value — refusing
166
+ // outright would strand the plugin over a byte we can still work around.
167
+ // If even the copy fails, refuse: silently destroying bytes is worse than
168
+ // skipping the settings work.
169
+ const backup = backupCorruptFile(p, res.raw);
170
+ if (!backup) {
171
+ console.error(
172
+ `[code-graph] ${p} contains bytes that are not valid UTF-8, and no backup copy ` +
173
+ `could be made. Leaving it untouched and skipping settings changes — rewriting it ` +
174
+ `would replace those bytes permanently.`
175
+ );
176
+ return { settings: null, backedUpTo: null };
177
+ }
178
+ console.error(
179
+ `[code-graph] ${p} contains bytes that are not valid UTF-8; rewriting it will replace ` +
180
+ `them. Saved the original to ${backup} first.`
181
+ );
182
+ return { settings: res.value, backedUpTo: backup };
183
+ }
148
184
  if (res.value) return { settings: res.value, backedUpTo: null };
149
185
  if (res.missing) return { settings: {}, backedUpTo: null };
150
186
  const why = res.error ? res.error.message : 'it does not contain a JSON object';
@@ -642,6 +678,27 @@ function registerHooksToSettings(settings) {
642
678
 
643
679
  // Extract the .js script path a hook command invokes — bare (`node "…"`) or
644
680
  // existence-guarded (`if [ -f "…" ]; then node "…"; fi`).
681
+ // Is the composite command currently in the statusLine slot one we should
682
+ // replace? Mirrors the staleness rule `surveyHookCoverage` applies to hooks —
683
+ // the two slots are claimed by the same install() and drift the same way.
684
+ //
685
+ // Stale means: unparseable, pointing at a script that no longer exists (a node
686
+ // version was uninstalled, a checkout deleted), or pinned to an OLDER
687
+ // plugin-cache version dir than ours. A live composite from a different
688
+ // delivery surface at the same or a newer version is left alone — that is the
689
+ // whole point. An in-place path (global npm, dev checkout) carries no version
690
+ // dir and can never go version-stale: npm overwrites the same path on upgrade.
691
+ function compositeSlotIsStale(currentCmd) {
692
+ const script = hookCmdScript(currentCmd);
693
+ if (!script) return true;
694
+ if (!fs.existsSync(script)) return true;
695
+ const pv = cacheDirVersion(script);
696
+ if (!pv) return false;
697
+ const { compareVersions } = require('./version-utils');
698
+ const dv = cacheDirVersion(hookCmdScript(compositeCommand())) || getPluginVersion();
699
+ return compareVersions(pv, dv) < 0;
700
+ }
701
+
645
702
  function hookCmdScript(cmd) {
646
703
  const m = (cmd || '').match(/node "([^"]+\.js)"/) || (cmd || '').match(/"([^"]+\.js)"/);
647
704
  return m ? m[1] : null;
@@ -652,7 +709,16 @@ function hookCmdScript(cmd) {
652
709
  // dir — npm overwrites the same path on upgrade, so such a path never goes
653
710
  // version-stale (only dead-path-stale, caught separately by fs.existsSync).
654
711
  function cacheDirVersion(scriptPath) {
655
- const m = (scriptPath || '').match(/\/code-graph-mcp\/code-graph-mcp\/(\d+\.\d+\.\d+[^/]*)\//);
712
+ // Separator-agnostic: the command string we parse is built with path.join,
713
+ // which yields `\` on Windows. A `/`-only pattern returned null there, so
714
+ // `compositeSlotIsStale` answered "not stale" for EVERY plugin-cache path
715
+ // and the statusline slot was never healed on Windows — it self-corrected
716
+ // only once cleanupOldCacheVersions eventually deleted the old version dir.
717
+ // The repo's own `an older plugin-cache version dir must still be healed`
718
+ // test could not catch it: plugin-tests runs on ubuntu only.
719
+ const m = (scriptPath || '')
720
+ .replace(/\\/g, '/')
721
+ .match(/\/code-graph-mcp\/code-graph-mcp\/(\d+\.\d+\.\d+[^/]*)\//);
656
722
  return m ? m[1] : null;
657
723
  }
658
724
 
@@ -881,7 +947,15 @@ function install({ reclaimStatusline = false } = {}) {
881
947
  const currentCmd = settings.statusLine && settings.statusLine.command;
882
948
  if (reclaimStatusline || process.env.CODE_GRAPH_FORCE_STATUSLINE === '1') {
883
949
  manifest.config.statuslineDisplaced = 0;
884
- } else if (manifest.config.statusLine === true && currentCmd) {
950
+ } else if (!currentCmd) {
951
+ // RE-ARM: the slot is EMPTY. Stand-down exists to stop a tug-of-war with
952
+ // another provider, and there is nobody to fight — whoever displaced us
953
+ // has been uninstalled, or the user cleared the slot. Without this the
954
+ // counter was write-only: once past the threshold the plugin stayed
955
+ // silently statusline-less for the life of the manifest, and the only way
956
+ // back was an env var nobody knows to set.
957
+ manifest.config.statuslineDisplaced = 0;
958
+ } else if (manifest.config.statusLine === true) {
885
959
  manifest.config.statuslineDisplaced = (manifest.config.statuslineDisplaced || 0) + 1;
886
960
  }
887
961
  if ((manifest.config.statuslineDisplaced || 0) > 2) {
@@ -905,9 +979,16 @@ function install({ reclaimStatusline = false } = {}) {
905
979
  manifest.config.statusLine = true;
906
980
  }
907
981
  } else {
908
- // Composite exists — ensure path is correct (may have been polluted by env leak)
982
+ // Composite exists — heal it only when it is actually stale. An exact
983
+ // string mismatch is NOT staleness: two copies of this plugin (plugin cache
984
+ // + global npm, or a dev checkout) derive different absolute paths for the
985
+ // same current composite, and rewriting on mismatch made each install()
986
+ // take the slot back from the other — a 2-cycle that rewrote settings.json
987
+ // on every SessionStart. Identical shape to the hook ping-pong ea0166d
988
+ // fixed; that fix's regression test asserted only `settings.hooks`, so this
989
+ // half of the pair stayed open.
909
990
  const cmd = compositeCommand();
910
- if (settings.statusLine.command !== cmd) {
991
+ if (settings.statusLine.command !== cmd && compositeSlotIsStale(settings.statusLine.command)) {
911
992
  settings.statusLine.command = cmd;
912
993
  settingsChanged = true;
913
994
  }
@@ -1398,7 +1479,9 @@ module.exports = {
1398
1479
  getPluginVersion, cleanupOldCacheVersions,
1399
1480
  removeHooksFromSettings, isOurHookEntry,
1400
1481
  registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
1401
- surveyHookCoverage, compositeCommand, // v0.49.1 — version-aware self-heal
1482
+ surveyHookCoverage, compositeCommand, compositeSlotIsStale, // v0.49.1 — version-aware self-heal
1483
+ cacheDirVersion, // exported for the separator-agnostic test
1484
+
1402
1485
  verifyHooksFire, defaultHookFireProbes, // v0.67.0 — firing self-test
1403
1486
  activeInstallPath, isStaleRelicContext, // v0.49.1 — stale-relic downgrade guard
1404
1487
  SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
@@ -255,8 +255,18 @@ function syncLifecycleConfig() {
255
255
  // v0.49.1: also self-heal when the composite path exists but is not the one
256
256
  // we'd write now (old plugin-cache version dir that still exists on disk —
257
257
  // invisible to the existence check above; same fault class as the binary pin).
258
- const { compositeCommand } = require('./lifecycle');
259
- if (settings.statusLine.command !== compositeCommand()) {
258
+ //
259
+ // "Not the one we'd write now" is NOT the same as stale, and this is the
260
+ // second gate that had to learn it: two copies of the plugin derive different
261
+ // absolute paths for the same current composite, so a bare string mismatch
262
+ // made each session take the slot back from the other. It also has to agree
263
+ // with `install()`, which now refuses to rewrite a live composite belonging to
264
+ // another delivery surface — otherwise this reports
265
+ // 'self-healed-stale-statusline' every single session while install() quietly
266
+ // changes nothing.
267
+ const { compositeCommand, compositeSlotIsStale } = require('./lifecycle');
268
+ if (settings.statusLine.command !== compositeCommand()
269
+ && compositeSlotIsStale(settings.statusLine.command)) {
260
270
  installReporting();
261
271
  return 'self-healed-stale-statusline';
262
272
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.108.0",
3
+ "version": "0.109.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": {
@@ -29,17 +29,16 @@
29
29
  ],
30
30
  "scripts": {
31
31
  "build": "cargo build --release --no-default-features && node scripts/copy-binary.js",
32
- "prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath scripts/githooks || true",
33
- "preuninstall": "node claude-plugin/scripts/lifecycle.js uninstall || true"
32
+ "prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath scripts/githooks || true"
34
33
  },
35
34
  "engines": {
36
35
  "node": ">=16"
37
36
  },
38
37
  "optionalDependencies": {
39
- "@sdsrs/code-graph-linux-x64": "0.108.0",
40
- "@sdsrs/code-graph-linux-arm64": "0.108.0",
41
- "@sdsrs/code-graph-darwin-x64": "0.108.0",
42
- "@sdsrs/code-graph-darwin-arm64": "0.108.0",
43
- "@sdsrs/code-graph-win32-x64": "0.108.0"
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"
44
43
  }
45
44
  }