@sdsrs/code-graph 0.147.0 → 0.149.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.147.0",
7
+ "version": "0.149.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -53,7 +53,10 @@ function commandExists(cmd, { exec = execFileSync, timeoutMs = PATH_PROBE_TIMEOU
53
53
 
54
54
  // ── Configuration ──────────────────────────────────────────
55
55
  const GITHUB_REPO = 'sdsrss/code-graph-mcp';
56
- const { UPDATE_STATE_FILE: STATE_FILE, MANIFEST_FILE, INSTALL_LOCK_FILE } = require('./cache-paths');
56
+ const {
57
+ UPDATE_STATE_FILE: STATE_FILE, MANIFEST_FILE, INSTALL_LOCK_FILE,
58
+ uninstallTombstoneActive,
59
+ } = require('./cache-paths');
57
60
  const BINARY_CACHE_DIR = path.join(CACHE_DIR, 'bin');
58
61
  const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — steady-state re-check
59
62
  const UP_TO_DATE_RECHECK_MS = 30 * 60 * 1000; // 30min — re-verify an "up to date" result (release-race guard)
@@ -166,14 +169,37 @@ function readState() {
166
169
  // The unlink/cleanup `catch {}`s elsewhere in this file stay silent on purpose:
167
170
  // a failed cleanup costs a stale temp file, not a broken invariant.
168
171
  let stateWriteWarned = false;
169
- function saveState(state) {
172
+ function saveState(state, { tombstoneActive = uninstallTombstoneActive, write = writeJsonAtomic } = {}) {
173
+ // A teardown deleted CACHE_DIR; writeJsonAtomic would re-create it to hold
174
+ // this file. Measured: in the arm where the teardown lands after curl has
175
+ // written its tmp, `update-state.json` was the ONE thing that came back, and
176
+ // it came back through here — noteUpdateFailure records the ENOENT the
177
+ // teardown caused, and the recording re-creates the directory. Guarding only
178
+ // the two download sites leaves this path open, so the arm keeps failing.
179
+ //
180
+ // The check sits inside saveState rather than at its eight call sites: one
181
+ // gate cannot drift out of step with seven others.
182
+ //
183
+ // It costs more than the file, and the cost is worth stating: this is also
184
+ // where the updater's anti-hammer memory lives. `fetchLatestRelease` records
185
+ // a GitHub 403 as `saveState({ ...state, rateLimited: true })` and
186
+ // `shouldCheck` reads it back to hold off; `lastCheck` drives the 6h/30min
187
+ // throttles the same way. While a tombstone stands, neither persists —
188
+ // measured, a rate-limited updater comes back with `rateLimited: null`,
189
+ // `lastCheck` unset and `shouldCheck` true, so it re-hits api.github.com on
190
+ // every invocation instead of backing off. Bounded by the TTL to five
191
+ // minutes, on a machine whose plugin was just torn down. No call site NEEDS
192
+ // the write — the state file lives inside the directory being reclaimed, so
193
+ // persisting it would mean re-creating exactly what this guard exists to
194
+ // prevent — but a reader should not have to discover the throttle loss.
195
+ if (tombstoneActive()) return;
170
196
  try {
171
197
  // The marker is an in-memory signal, never a persisted field: several call
172
198
  // sites do `saveState({ ...readState(), ... })`, and a persisted
173
199
  // `stateUnreadable` would park the updater permanently.
174
200
  const { stateUnreadable, ...clean } = state || {};
175
201
  void stateUnreadable;
176
- writeJsonAtomic(STATE_FILE, clean);
202
+ write(STATE_FILE, clean);
177
203
  } catch (e) {
178
204
  if (!stateWriteWarned) {
179
205
  stateWriteWarned = true;
@@ -583,7 +609,11 @@ function cachedBinaryStaleVsState(state, { binaryPath = cachedBinaryPath(), read
583
609
  * `needsUpdate` is injectable for tests only; production always uses the real
584
610
  * predicate. Returning false on a skip is accurate: nothing was updated.
585
611
  */
586
- async function downloadBinary(latest, { needsUpdate = cachedBinaryNeedsUpdate } = {}) {
612
+ async function downloadBinary(latest, {
613
+ needsUpdate = cachedBinaryNeedsUpdate,
614
+ tombstoneActive = uninstallTombstoneActive,
615
+ mkdir = fs.mkdirSync,
616
+ } = {}) {
587
617
  if (!latest || !latest.binaryUrl) return false;
588
618
  if (!needsUpdate(latest)) return false; // already at latest.version — no fetch
589
619
  if (!commandExists('curl')) {
@@ -595,8 +625,15 @@ async function downloadBinary(latest, { needsUpdate = cachedBinaryNeedsUpdate }
595
625
  const binaryDst = cachedBinaryPath();
596
626
  const binaryTmp = binaryDst + '.tmp.' + process.pid;
597
627
 
628
+ // The 41 MB arm. A teardown that lands before this mkdir gets the whole
629
+ // chain proceeding on a re-created tree, and it finishes by writing a fresh
630
+ // 42 MB binary into a directory the user just reclaimed. Checked here rather
631
+ // than at the top of the function so the guard sits adjacent to the write it
632
+ // protects.
633
+ if (tombstoneActive()) return false;
634
+
598
635
  try {
599
- fs.mkdirSync(BINARY_CACHE_DIR, { recursive: true });
636
+ mkdir(BINARY_CACHE_DIR, { recursive: true });
600
637
  // `-f` (fail on HTTP >= 400), same as the sidecar fetch below. Without it
601
638
  // curl writes GitHub's 404/503 HTML body to binaryTmp and exits 0, so the
602
639
  // error page travelled on as a candidate binary and was only caught two
@@ -655,7 +692,8 @@ function sha256File(filePath) {
655
692
  return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
656
693
  }
657
694
 
658
- function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSha256) {
695
+ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSha256,
696
+ { tombstoneActive = uninstallTombstoneActive } = {}) {
659
697
  try {
660
698
  // Size floor: every published binary is tens of MB, so anything under 1 MB
661
699
  // is a truncated transfer or an error page. It used to return false without
@@ -714,6 +752,13 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
714
752
  return false;
715
753
  }
716
754
 
755
+ // The other arm: the teardown landed after curl wrote the tmp, so the
756
+ // download is verified and ready and the destination directory is gone.
757
+ // Refusing HERE rather than earlier is deliberate — the `finally` below
758
+ // owns binaryTmp and unlinks it, so bailing at the last step costs one
759
+ // wasted download and leaves nothing on disk.
760
+ if (tombstoneActive()) return false;
761
+
717
762
  fs.renameSync(binaryTmp, binaryDst);
718
763
  clearBinaryCache();
719
764
  return true;
@@ -1529,6 +1574,7 @@ module.exports = {
1529
1574
  shouldHealGlobalsOnThrottle, inactiveNodeGlobalRelics,
1530
1575
  downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
1531
1576
  noteUpdateFailure, takeUpdateFailure,
1577
+ saveState,
1532
1578
  };
1533
1579
 
1534
1580
  // CLI: node auto-update.js [check|status] [--silent] [--install-missing]
@@ -19,6 +19,7 @@
19
19
  // file — it already does `require('./lifecycle')`, so its copy of the three path
20
20
  // segments bought nothing. An earlier version of this comment claimed both
21
21
  // modules needed the split (pre-ship review 2026-09-07).
22
+ const fs = require('fs');
22
23
  const os = require('os');
23
24
  const path = require('path');
24
25
 
@@ -31,9 +32,97 @@ const MANIFEST_FILE = path.join(CACHE_DIR, 'install-manifest.json');
31
32
  /** Inter-process install lock (see install-lock.js). */
32
33
  const INSTALL_LOCK_FILE = path.join(CACHE_DIR, 'install.lock');
33
34
 
35
+ /**
36
+ * Teardown tombstone — a SIBLING of CACHE_DIR, deliberately not a file inside
37
+ * it.
38
+ *
39
+ * A teardown that runs while a SessionStart-spawned `auto-update` is in flight
40
+ * used to get CACHE_DIR re-created under it: measured at 42,847,128 B of fresh
41
+ * binary plus three JSON files, 2 of 2 runs, in the arm where the teardown
42
+ * beats the download.
43
+ *
44
+ * INSTALL_LOCK_FILE above is the token the updater already respects, and taking
45
+ * it here was prototyped for exactly this job and REFUTED: it lives at
46
+ * CACHE_DIR/install.lock, `removeCacheResidue()` deletes CACHE_DIR, so the lock
47
+ * goes with the directory and the updater acquires freely seconds later. A
48
+ * mutual-exclusion token stored inside the resource being destroyed cannot
49
+ * guard that destruction — which is why this one is a sibling.
50
+ */
51
+ const UNINSTALL_TOMBSTONE_FILE = path.join(os.homedir(), '.cache', 'code-graph.uninstalled');
52
+
53
+ /**
54
+ * How long a tombstone suppresses writes. Longer than any teardown, shorter
55
+ * than any interval over which a stale one could matter: a reinstall inside the
56
+ * window merely skips one update check, and the install itself supplies the
57
+ * binary. The TTL is what keeps this from becoming a permanent kill switch —
58
+ * a tombstone that never expired would disable auto-update for the life of the
59
+ * machine, a worse failure than the residue it prevents.
60
+ */
61
+ const UNINSTALL_TOMBSTONE_TTL_MS = 5 * 60 * 1000;
62
+
63
+ /** Record that a teardown is in flight. Best-effort: a cache we cannot write
64
+ * to is one the updater's own writes will fail against too. */
65
+ function writeUninstallTombstone({ file = UNINSTALL_TOMBSTONE_FILE, now = Date.now() } = {}) {
66
+ try {
67
+ fs.mkdirSync(path.dirname(file), { recursive: true });
68
+ fs.writeFileSync(file, JSON.stringify({ at: new Date(now).toISOString() }));
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Is a teardown in flight right now?
77
+ *
78
+ * Fail-OPEN on every unreadable shape — absent, unparseable, no timestamp. The
79
+ * alternative is a corrupt file that can never expire, and the spec rates a
80
+ * permanently suppressed updater worse than the 41 MB this exists to stop.
81
+ */
82
+ function uninstallTombstoneActive({
83
+ file = UNINSTALL_TOMBSTONE_FILE,
84
+ now = Date.now(),
85
+ ttlMs = UNINSTALL_TOMBSTONE_TTL_MS,
86
+ } = {}) {
87
+ let at;
88
+ try {
89
+ at = Date.parse(JSON.parse(fs.readFileSync(file, 'utf8')).at);
90
+ } catch {
91
+ return false;
92
+ }
93
+ if (!Number.isFinite(at)) return false;
94
+ // `age >= 0` is not redundant with the upper bound: a NEGATIVE age — a
95
+ // timestamp in the future — is always less than the TTL, so a bare
96
+ // `age < ttlMs` would treat a tombstone stamped a year ahead as active for a
97
+ // year, and a legitimate one plus an 8h clock rollback (NTP correction, VM
98
+ // restore, RTC drift) as active for 8h05m. That is the never-expiring
99
+ // kill switch this design's TTL exists to prevent, in bounded form, and it is
100
+ // reachable without any corruption because the file carries OUR OWN
101
+ // Date.now(). A future stamp means the clock moved, not that a teardown is in
102
+ // flight, so it fails open like every other shape we cannot trust.
103
+ const age = now - at;
104
+ return age >= 0 && age < ttlMs;
105
+ }
106
+
107
+ /** Drop the tombstone. The TTL is the backstop; an install clears it eagerly so
108
+ * a reinstall inside the window does not skip its first update check. */
109
+ function clearUninstallTombstone({ file = UNINSTALL_TOMBSTONE_FILE } = {}) {
110
+ try {
111
+ fs.rmSync(file, { force: true });
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
34
118
  module.exports = {
35
119
  CACHE_DIR,
36
120
  UPDATE_STATE_FILE,
37
121
  MANIFEST_FILE,
38
122
  INSTALL_LOCK_FILE,
123
+ UNINSTALL_TOMBSTONE_FILE,
124
+ UNINSTALL_TOMBSTONE_TTL_MS,
125
+ writeUninstallTombstone,
126
+ uninstallTombstoneActive,
127
+ clearUninstallTombstone,
39
128
  };
@@ -102,20 +102,40 @@ function main({ argv = process.argv, findBinaryRoot = null } = {}) {
102
102
  "deletes ~/.cache/code-graph, and removes this project's CLAUDE.md adoption\n" +
103
103
  "block. --unadopt-all also removes the managed block + detail file from every\n" +
104
104
  "registered adopted project; --purge-global removes the globally-installed\n" +
105
- "@sdsrs npm packages even without the plugin-install marker. Also run\n" +
106
- "`/plugin uninstall code-graph-mcp` in Claude Code to sync its UI.\n");
105
+ "@sdsrs npm packages even without the plugin-install marker.\n\n" +
106
+ "This also removes Claude Code's own registration, so `claude plugin uninstall\n" +
107
+ "code-graph-mcp` afterwards answers \"not found\" — expected. Only a Claude Code\n" +
108
+ "session still listing the plugin needs `/plugin uninstall code-graph-mcp`.\n");
107
109
  process.exit(0);
108
110
  }
109
111
  rejectUnknownFlags(argv, "uninstall", new Set(["--help", "-h", "--unadopt-all", "--purge-global"]));
110
112
  const lifecycle = require("./lifecycle");
111
113
  const { unadopt } = require("./adopt");
114
+ // Unadopt THIS project BEFORE the teardown, not after.
115
+ //
116
+ // `lifecycle.removeCacheResidue` (step 6) holds two rules: PRESERVE a
117
+ // non-empty adopted-projects registry — those projects still carry a managed
118
+ // block someone has to be able to find (JS-17) — and never re-create
119
+ // CACHE_DIR merely to hold `[]`, which its own comment calls "just new
120
+ // residue". Unadopting afterwards produced exactly that `[]`, one step too
121
+ // late for anything to sweep it, so the file step 6 had carefully preserved
122
+ // for us became the residue step 6 exists to prevent. Unadopt first and the
123
+ // registry is already empty when step 6 reads it, so the whole cache
124
+ // directory goes. It is also why that comment describes "the normal
125
+ // SessionStart teardown order, which unadopts first" — this was the one
126
+ // caller that did not.
127
+ //
128
+ // Deliberate knock-on: `r.adoptedProjects` (captured at step 5.5) and the
129
+ // `--unadopt-all` sweep no longer see this project, so it is reported once,
130
+ // on the `this project unadopted=` line, instead of being counted twice.
131
+ // `otherAdopted` below already filtered `process.cwd()` out either way.
132
+ let ua = { ok: false };
133
+ try { ua = unadopt(); } catch { /* best-effort — the teardown below still runs */ }
134
+ const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
112
135
  const r = lifecycle.uninstall({
113
136
  purgeGlobal: argv.slice(3).includes("--purge-global"),
114
137
  unadoptAll: argv.slice(3).includes("--unadopt-all"),
115
138
  });
116
- let ua = { ok: false };
117
- try { ua = unadopt(); } catch { /* best-effort — settings/cache already cleaned */ }
118
- const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
119
139
  let out =
120
140
  `Uninstalled code-graph-mcp | settings cleaned=${r.settingsChanged}` +
121
141
  ` | this project unadopted=${projectUnadopted}\n`;
@@ -137,7 +157,14 @@ function main({ argv = process.argv, findBinaryRoot = null } = {}) {
137
157
  " `code-graph-mcp unadopt` + `rm -rf .code-graph`\n" +
138
158
  otherAdopted.map((p) => ` ${p}\n`).join("");
139
159
  }
140
- out += " Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its UI state.\n";
160
+ // Only when the registration actually went. `uninstall` reports the arm
161
+ // where it could not (unreadable / unwritable installed_plugins.json) via
162
+ // `installedPluginsUnusable`, and in that arm it has ALREADY printed the
163
+ // opposite instruction to stderr — so printing this too told the user both
164
+ // to run `/plugin uninstall` and that its failure was expected, in one run.
165
+ if (!r.installedPluginsUnusable) {
166
+ for (const line of lifecycle.POST_TEARDOWN_UI_NOTE) out += ` ${line}\n`;
167
+ }
141
168
  process.stdout.write(out);
142
169
  process.exit(0);
143
170
  }
@@ -1134,7 +1134,7 @@ function runRepairs(results, {
1134
1134
  console.log('\n Repairing hooks...');
1135
1135
  if (relicRepairGuard()) break;
1136
1136
  const { install, scanForBrokenPaths } = require('./lifecycle');
1137
- const installResult = install();
1137
+ const installResult = install({ clearTombstone: true });
1138
1138
  // Diagnosis already ran install()+re-scan and the paths were STILL
1139
1139
  // broken (that `repaired:false` is what raised hooks-invalid). Verify
1140
1140
  // this second attempt actually cleared them before counting it fixed \u2014
@@ -1173,7 +1173,7 @@ function runRepairs(results, {
1173
1173
  console.log('\n Registering code-graph hooks in settings.json...');
1174
1174
  if (relicRepairGuard()) break;
1175
1175
  const { install } = require('./lifecycle');
1176
- const r = install();
1176
+ const r = install({ clearTombstone: true });
1177
1177
  if (r.hooksRegistered) {
1178
1178
  console.log(' \u2705 settings.json updated — restart Claude Code to apply');
1179
1179
  fixed++;
@@ -20,7 +20,10 @@ const MARKETPLACE_NAME = 'code-graph-mcp';
20
20
  // the first version of this comment claimed `MANIFEST_FILE` was exported from
21
21
  // here: `CACHE_DIR` and `INSTALL_LOCK_FILE` are exported and still are;
22
22
  // `MANIFEST_FILE` is module-private and always was, so nothing importable moved.
23
- const { CACHE_DIR, MANIFEST_FILE, INSTALL_LOCK_FILE } = require('./cache-paths');
23
+ const {
24
+ CACHE_DIR, MANIFEST_FILE, INSTALL_LOCK_FILE,
25
+ writeUninstallTombstone, clearUninstallTombstone,
26
+ } = require('./cache-paths');
24
27
  // Bound for the `ps` fallback in getActiveCmdlines (pre-ship review 2026-09-06).
25
28
  // 2 s matches the other hook-path probes; the floor is what a budget-exhausted
26
29
  // hook still gives it, since an empty list degrades to recency-only.
@@ -1321,7 +1324,27 @@ function verifyHooksFire({ hooks, env, timeoutMs = 4000, tmpBase } = {}) {
1321
1324
 
1322
1325
  // --- Install (idempotent) ---
1323
1326
 
1324
- function install({ reclaimStatusline = false } = {}) {
1327
+ function install({ reclaimStatusline = false, clearTombstone = false } = {}) {
1328
+ // Clearing the teardown tombstone is OPT-IN, and defaults to off, because
1329
+ // "an install is happening" does not mean "the user asked to reinstall".
1330
+ //
1331
+ // The first version cleared unconditionally, on the reasoning that an install
1332
+ // ends any teardown. Pre-ship review reproduced the hole: after a genuine
1333
+ // uninstall, a DIFFERENT still-live session's SessionStart finds no manifest
1334
+ // (install-manifest.json went with CACHE_DIR) and no inactive-plugin markers
1335
+ // (the composite and the statusline registry went with it too), so it falls
1336
+ // through to `installReporting()` -> install() and cleared the tombstone 3 s
1337
+ // after it was written — then spawned its own updater fourteen lines later.
1338
+ // A global record that any concurrent session may erase protects nobody.
1339
+ //
1340
+ // So only the two genuinely user-initiated entry points pass true: the
1341
+ // `lifecycle.js install` CLI and doctor's repair pass. Everything automatic —
1342
+ // session-init's install reporting, healthCheck's repair — leaves it standing
1343
+ // and lets the TTL expire it. The cost is that a reinstall inside the window
1344
+ // may skip one update check, which the spec already accepts; the install
1345
+ // itself supplies the binary.
1346
+ if (clearTombstone) clearUninstallTombstone();
1347
+
1325
1348
  const version = getPluginVersion();
1326
1349
  const manifest = readManifest();
1327
1350
  // Probe FIRST, and pay the backup only on the write path (audit 2026-08-29
@@ -1989,7 +2012,68 @@ function isPluginUninstalled(settings = readJson(settingsPath()) || {}) {
1989
2012
  // here now. So the preservation belongs here, at the wipe, rather than at each
1990
2013
  // caller — the same "fix it at the shared layer, not per surface" the
1991
2014
  // <external> query filter needed.
1992
- function removeCacheResidue() {
2015
+ /**
2016
+ * What to say about Claude Code's own plugin registry after a teardown that
2017
+ * SUCCEEDED.
2018
+ *
2019
+ * `uninstall` step 3 drops the plugin from `enabledPlugins` and step 5 from
2020
+ * `installed_plugins.json`, so by the time this prints there is nothing left for
2021
+ * `claude plugin uninstall` to find: in a fresh terminal it exits non-zero with
2022
+ * `Plugin "code-graph-mcp" not found in installed plugins`. Both printers used to
2023
+ * say "also run `/plugin uninstall …`" with no such warning, which hands the user
2024
+ * an error message as the last word on an uninstall that worked.
2025
+ *
2026
+ * Deliberately NOT the same text as the two notes in `uninstall` itself
2027
+ * (`cannot write installed_plugins.json` / `cannot read …`): those fire exactly
2028
+ * when the registration could NOT be removed, so there `/plugin uninstall` is
2029
+ * both necessary and successful. That distinction is ENFORCED, not just
2030
+ * described — both printers gate on `installedPluginsUnusable`. It was only
2031
+ * described for one commit, and in that commit a corrupt installed_plugins.json
2032
+ * got both messages at once (pre-ship review of 7dc7eb4). One string because these two
2033
+ * printers are the pair that drifted before (v0.142.0 shipped a fix for two
2034
+ * printers of the same `unadopt` string, and the release before it fixed the
2035
+ * first of them).
2036
+ */
2037
+ const POST_TEARDOWN_UI_NOTE = [
2038
+ 'Claude Code UI: the registration is already gone, so `claude plugin uninstall',
2039
+ 'code-graph-mcp` in a new terminal answers "not found" — that is the expected',
2040
+ 'result here, not a failure. In a session that still lists the plugin, run',
2041
+ '`/plugin uninstall code-graph-mcp` there to sync its UI.',
2042
+ ];
2043
+
2044
+ function removeCacheResidue({ sweep = null } = {}) {
2045
+ // Announce the teardown BEFORE the delete, outside the directory being
2046
+ // deleted. A mid-flight `auto-update` in ANOTHER process will otherwise
2047
+ // re-create CACHE_DIR under us — measured at 42,847,128 B of fresh binary
2048
+ // plus three JSON files, 2 of 2 runs, when the teardown wins the race to
2049
+ // `downloadBinary`'s mkdir.
2050
+ //
2051
+ // The racing updater is always a different process. An earlier version of
2052
+ // this comment said `runSessionInit`'s teardown "runs from the same
2053
+ // SessionStart that spawns the updater"; it does not — that branch returns at
2054
+ // session-init.js:718 with `autoUpdateLaunched: false`, before
2055
+ // `launchBackgroundAutoUpdate`. SessionStart is still the racing surface,
2056
+ // just from a concurrent session rather than this one (pre-ship review).
2057
+ //
2058
+ // All three callers are genuine-uninstall paths — `uninstall()`,
2059
+ // `cleanupDisabledStatusline()` under `isPluginUninstalled`, and
2060
+ // `runSessionInit()` under `if (uninstalled)` — so the tombstone belongs here
2061
+ // rather than in each of them: one site, three callers, and no way for a
2062
+ // fourth to forget it. Verified against five sandbox states (explicit
2063
+ // disable, genuine uninstall, healthy, unreadable registry, missing
2064
+ // registry): only the genuine uninstall writes one.
2065
+ //
2066
+ // BEFORE, not after: written after the sweep there is a window in which an
2067
+ // updater sees nothing and can mkdir mid-delete, and the sweep's own
2068
+ // `catch { return false; }` would skip the write entirely on a failed delete.
2069
+ // `sweep` exists so a test can observe the tombstone at the moment the delete
2070
+ // is attempted — the ordering is the property, and asserting only that the
2071
+ // file exists on return passes with the two lines swapped.
2072
+ //
2073
+ // Best-effort by design: if this write fails, the delete below still runs and
2074
+ // we are no worse off than before the tombstone existed.
2075
+ writeUninstallTombstone();
2076
+
1993
2077
  // Path comes from adopt.js rather than a second spelling of the basename —
1994
2078
  // a literal here would silently stop matching the day adopt.js renames it,
1995
2079
  // and the failure mode is exactly the data loss this guard exists to stop.
@@ -2017,7 +2101,8 @@ function removeCacheResidue() {
2017
2101
  }
2018
2102
  } catch { /* POSIX-only helper or an unreadable path — nothing to preserve */ }
2019
2103
  try {
2020
- fs.rmSync(CACHE_DIR, { recursive: true, force: true });
2104
+ if (sweep) sweep(CACHE_DIR);
2105
+ else fs.rmSync(CACHE_DIR, { recursive: true, force: true });
2021
2106
  } catch { return false; }
2022
2107
  if (registryPath && registry) {
2023
2108
  try {
@@ -2051,6 +2136,7 @@ module.exports = {
2051
2136
  PLUGIN_ROOT, // v0.32.1 — for tests / consumers
2052
2137
  registerStatuslineProvider, unregisterStatuslineProvider, detachStatuslineIntegration,
2053
2138
  installedGlobalPkgs, GLOBAL_INSTALL_MARKER, INSTALL_LOCK_FILE, SHELL_PKG, // uninstall residue
2139
+ POST_TEARDOWN_UI_NOTE, // one wording, two printers
2054
2140
  PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
2055
2141
  settingsPath, installedPluginsPath, providersBackupFile, pluginsCacheDir,
2056
2142
  };
@@ -2059,8 +2145,11 @@ module.exports = {
2059
2145
  if (require.main === module) {
2060
2146
  const cmd = process.argv[2];
2061
2147
  if (cmd === 'install') {
2062
- // Explicit CLI install = user intent: reset any statusline stand-down and re-claim.
2063
- const r = install({ reclaimStatusline: true });
2148
+ // Explicit CLI install = user intent: reset any statusline stand-down and
2149
+ // re-claim, and drop a teardown tombstone if one is standing. This and
2150
+ // doctor's repair pass are the only two callers that pass clearTombstone —
2151
+ // see the comment on install() for why an automatic install must not.
2152
+ const r = install({ reclaimStatusline: true, clearTombstone: true });
2064
2153
  // Refusing to touch an unusable settings.json means NOTHING was installed —
2065
2154
  // no hooks, no statusline, no manifest stamp. Printing "Installed" and
2066
2155
  // exiting 0 there would make `lifecycle.js install && …` chains read the
@@ -2100,7 +2189,12 @@ if (require.main === module) {
2100
2189
  for (const p of r.adoptedProjects) console.log(` ${p}`);
2101
2190
  console.log(' Clean all at once: re-run with --unadopt-all, or per project `code-graph-mcp unadopt` + `rm -rf .code-graph`.');
2102
2191
  }
2103
- console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state.');
2192
+ // Suppressed in the arm where the registration could NOT be removed — the
2193
+ // stderr note printed there says the opposite, and both at once is a
2194
+ // contradiction. See the constant's own docstring.
2195
+ if (!r.installedPluginsUnusable) {
2196
+ for (const line of POST_TEARDOWN_UI_NOTE) console.log(` ${line}`);
2197
+ }
2104
2198
  } else if (cmd === 'update') {
2105
2199
  const r = update();
2106
2200
  if (r.settingsUnreadable || r.settingsUnwritable) {
@@ -151,6 +151,11 @@ if (!binary) {
151
151
 
152
152
  const stub = serveEmptyMcpStub({
153
153
  upgrade: {
154
+ // What a tool call gets told while this gate holds. Without it the stub
155
+ // answered with the OTHER gate's reason ("upgrades when cwd becomes a
156
+ // project"), which is both wrong here and unactionable — the cwd is fine,
157
+ // the binary is what is missing.
158
+ hint: `binary not installed yet — installing @sdsrs/code-graph@${version} in the background; if tools never appear, run \`code-graph-mcp doctor\``,
154
159
  // Each probe is a full discovery walk (incl. `npm root -g`, up to 2s);
155
160
  // offline the binary never appears, so back the poll off toward 60s.
156
161
  // The install chain's onInstalled nudge below still upgrades instantly.
@@ -205,13 +210,18 @@ try {
205
210
  process.exit(1);
206
211
  }
207
212
 
208
- // Spawn binary with stdio inheritance for MCP JSON-RPC
209
- const child = spawn(binary, ['serve'], hidden({
210
- stdio: 'inherit',
211
- env: process.env,
212
- }));
213
-
214
- child.on('error', (err) => {
213
+ // Both ways a spawn failure can reach us, reported through one function so
214
+ // neither path can drift into being the one with the good message.
215
+ //
216
+ // node hands exactly FIVE errnos to the async 'error' event — EACCES, EAGAIN,
217
+ // EMFILE, ENFILE, ENOENT ("Run-time errors should emit an error, not throw an
218
+ // exception", internal/child_process.js) — and throws every other one
219
+ // synchronously out of `spawn`. So the EACCES branch below always did reach its
220
+ // handler; what had no path at all was the synchronous side, where an ETXTBSY
221
+ // from a binary an auto-update replaced moments ago printed a raw stack instead
222
+ // of any of this. Unlike the stub's, this process exits 1 either way: here the
223
+ // catch buys the message, not the process.
224
+ function reportSpawnFailure(err) {
215
225
  process.stderr.write(`[code-graph] Failed to start: ${err.message}\n`);
216
226
  if (process.platform === 'darwin' && (err.code === 'EACCES' || err.code === 'EPERM')) {
217
227
  process.stderr.write(
@@ -219,12 +229,34 @@ child.on('error', (err) => {
219
229
  ` xattr -d com.apple.quarantine "${binary}"\n`
220
230
  );
221
231
  }
232
+ // ETXTBSY: something still holds a writer fd on the binary — an auto-update
233
+ // that replaced it moments ago. Retryable, unlike the rest of this function.
234
+ if (err.code === 'ETXTBSY') {
235
+ process.stderr.write(
236
+ 'The binary was still being written when we tried to run it.\n' +
237
+ ' Retry: restart Claude Code, or run `code-graph-mcp doctor`\n'
238
+ );
239
+ }
222
240
  // A glibc binary installed on musl (older npm ignores the `libc` field) is present
223
241
  // but execs into a loader error — surface the actionable platform hint.
224
242
  const platformHint = unsupportedPlatformHint();
225
243
  if (platformHint) process.stderr.write(platformHint + '\n');
226
244
  process.exit(1);
227
- });
245
+ }
246
+
247
+ // Spawn binary with stdio inheritance for MCP JSON-RPC
248
+ let child;
249
+ try {
250
+ child = spawn(binary, ['serve'], hidden({
251
+ stdio: 'inherit',
252
+ env: process.env,
253
+ }));
254
+ } catch (err) {
255
+ reportSpawnFailure(err); // exits; the return keeps `child` from being read as undefined
256
+ return;
257
+ }
258
+
259
+ child.on('error', reportSpawnFailure);
228
260
 
229
261
  child.on('exit', (code, signal) => {
230
262
  if (signal) {
@@ -84,10 +84,15 @@ function serveEmptyMcpStub(opts = {}) {
84
84
  else if (m === 'tools/list') result = { tools: [] };
85
85
  else if (m === 'resources/list') result = { resources: [] };
86
86
  else if (m === 'prompts/list') result = { prompts: [] };
87
+ // The reason a caller is talking to a 0-tool stub differs per gate, and this
88
+ // string is the ONLY place it reaches the model — the launcher's stderr goes
89
+ // to the Claude Code log, not into the tool result. One shared wording named
90
+ // the non-project cause for both gates, so the missing-binary gate told a
91
+ // caller sitting in a perfectly good git repo to make it a project.
87
92
  else error = {
88
93
  code: -32601,
89
94
  message: canUpgrade
90
- ? 'method not found (plugin MCP stub; upgrades when cwd becomes a project)'
95
+ ? `method not found (plugin MCP stub; ${upgrade.hint || 'upgrades when cwd becomes a project'})`
91
96
  : 'method not found (plugin MCP is in dedup stub mode)',
92
97
  };
93
98
  writeCc(error ? { jsonrpc: '2.0', id: req.id, error } : { jsonrpc: '2.0', id: req.id, result });
@@ -149,7 +154,38 @@ function serveEmptyMcpStub(opts = {}) {
149
154
  }
150
155
  return;
151
156
  }
152
- const spawned = upgrade.spawnReal();
157
+ // `spawnReal` may THROW rather than return null. node hands exactly FIVE
158
+ // errnos to the async 'error' event that `beginProxy` listens on — EACCES,
159
+ // EAGAIN, EMFILE, ENFILE, ENOENT ("Run-time errors should emit an error, not
160
+ // throw an exception", internal/child_process.js) — and throws every other
161
+ // one synchronously out of child_process.spawn. ETXTBSY is outside that set,
162
+ // and it is the one this path invites: the missing-binary gate nudges
163
+ // attemptUpgrade() from the install chain's onInstalled, so it execs a ~40MB
164
+ // binary at the instant the installer finished writing it, and Linux answers
165
+ // ETXTBSY for as long as any writer fd is still open. Uncaught, the throw
166
+ // escaped this function, escaped the poll-timer callback that called it, and
167
+ // killed the whole MCP server — at the moment the install had SUCCEEDED
168
+ // (QA 2026-09-13: 3/3 against a cold npm cache, 0/3 warm).
169
+ let spawned;
170
+ try {
171
+ spawned = upgrade.spawnReal();
172
+ } catch (err) {
173
+ const code = (err && err.code) || 'unknown';
174
+ process.stderr.write(`[code-graph] plugin MCP upgrade spawn failed (${code}); staying in 0-tool stub, will retry\n`);
175
+ // Clear the offline backoff. It models "the binary is not there yet", and
176
+ // `shouldUpgrade()` just returned TRUE — the binary IS there, this is a
177
+ // transient exec failure. Without this the throw returns into whatever
178
+ // `backoffTicks` a whole offline stretch ratcheted up (capped at 14 ticks
179
+ // = 56 s), and `pollTick` burns that down one tick at a time before it
180
+ // will even probe again: measured 4 s / 16 s / 36 s / 52 s of 0-tool stub
181
+ // after a 10 s / 30 s / 45 s / 90 s install (pre-ship review of 7dc7eb4).
182
+ // The retry is what makes containment a fix rather than a downgrade, so it
183
+ // has to be the next tick.
184
+ backoffTicks = 0;
185
+ backoffNext = 1;
186
+ noteUpgradeFailure(`spawn-threw-${code}`);
187
+ return;
188
+ }
153
189
  if (!spawned) { noteUpgradeFailure('binary-unresolved'); return; }
154
190
  if (poller) { clearIv(poller); poller = null; }
155
191
  child = spawned;
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
  - name: Build snapshot
37
37
  run: |
38
- npx -y -p @sdsrs/code-graph@0.147.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.149.0 code-graph-mcp snapshot create --out snapshot.db
39
39
  zstd -9 snapshot.db -o snapshot.db.zst
40
40
  mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
41
41
  - name: Upload to release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.147.0",
3
+ "version": "0.149.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.147.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.147.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.147.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.147.0",
42
- "@sdsrs/code-graph-win32-x64": "0.147.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.149.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.149.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.149.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.149.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.149.0"
43
43
  }
44
44
  }