@herbertgao/pi-extensions 2026.8.7 → 2026.8.8

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.
Files changed (99) hide show
  1. package/README.md +7 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +63 -15
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +17 -6
  8. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  9. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
  10. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
  11. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
  12. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
  13. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
  14. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
  15. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
  16. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  17. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
  18. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
  19. package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
  20. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
  21. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
  22. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
  23. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
  24. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
  25. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
  26. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
  27. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
  28. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  29. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
  30. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  31. package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
  32. package/node_modules/pi-lens/CHANGELOG.md +90 -0
  33. package/node_modules/pi-lens/README.md +106 -0
  34. package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
  35. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
  36. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
  37. package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
  38. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
  39. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
  40. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
  41. package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
  58. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
  59. package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
  60. package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
  61. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
  62. package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
  63. package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
  64. package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
  65. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
  66. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
  67. package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
  68. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
  69. package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
  70. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
  71. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
  72. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
  73. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
  74. package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
  75. package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
  76. package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
  77. package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
  78. package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
  79. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
  80. package/node_modules/pi-lens/dist/index.js +1224 -393
  81. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
  82. package/node_modules/pi-lens/package.json +18 -5
  83. package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
  84. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
  85. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
  86. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
  87. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  88. package/node_modules/pi-web-access/README.md +6 -5
  89. package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
  90. package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
  91. package/node_modules/pi-web-access/extract.ts +12 -2
  92. package/node_modules/pi-web-access/firecrawl.ts +18 -2
  93. package/node_modules/pi-web-access/github-extract.ts +65 -36
  94. package/node_modules/pi-web-access/index.ts +19 -4
  95. package/node_modules/pi-web-access/openai-search.ts +17 -6
  96. package/node_modules/pi-web-access/package.json +1 -1
  97. package/node_modules/pi-web-access/pdf-extract.ts +18 -5
  98. package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
  99. package/package.json +9 -9
@@ -189,16 +189,41 @@ export function recordOutstandingCascadeTouch(entry) {
189
189
  });
190
190
  }
191
191
  }
192
- /** Test-only: clear the outstanding-touch registry between test cases. */
193
- export function _resetOutstandingCascadeTouchesForTests() {
192
+ /**
193
+ * #1910: session-boundary reset for the outstanding-touch registry and its
194
+ * sweep-scoped `_expiredSinceLastSweep`/`_evictedSinceLastSweep` counters.
195
+ * Both predate #1899 and were never wired into `handleSessionStart` — a
196
+ * session replacement inherited the prior session's outstanding touches, and
197
+ * a stray eviction/expiry landing between a sweep and the boundary attributed
198
+ * its count to the NEXT session's first reconcile gauge. Wired primary-only,
199
+ * same as every other entry in that reset block: a concurrently-live
200
+ * secondary never reaches `handleSessionStart` at all (the #473 guard in
201
+ * index.ts), so it can never race this reset against the still-live
202
+ * primary's outstanding touches.
203
+ */
204
+ export function resetCascadeTierSessionState() {
194
205
  _outstandingTouches.clear();
195
206
  _expiredSinceLastSweep = 0;
196
207
  _evictedSinceLastSweep = 0;
197
208
  }
209
+ /** Test-only: clear the outstanding-touch registry between test cases. */
210
+ export function _resetOutstandingCascadeTouchesForTests() {
211
+ resetCascadeTierSessionState();
212
+ }
198
213
  /** Test-only: peek at the registry without mutating it. */
199
214
  export function _getOutstandingCascadeTouchesForTests() {
200
215
  return [..._outstandingTouches.values()];
201
216
  }
217
+ /**
218
+ * Test-only: peek at the sweep-scoped expired/evicted counters without
219
+ * mutating them. Exists so a probe of `resetCascadeTierSessionState` can
220
+ * assert on THESE counters directly, rather than only on the registry map —
221
+ * a future counter added here and left out of the reset would otherwise
222
+ * stay invisible to any probe that only checks the map went empty.
223
+ */
224
+ export function _getCascadeTierSweepCountersForTests() {
225
+ return { expired: _expiredSinceLastSweep, evicted: _evictedSinceLastSweep };
226
+ }
202
227
  /**
203
228
  * Reconcile every outstanding Tier-3 touch against the LSP client's current
204
229
  * diagnostics cache. For each:
@@ -1422,6 +1422,41 @@ function setupConnectionLifecycle(state, recentStderr) {
1422
1422
  });
1423
1423
  }
1424
1424
  });
1425
+ // #1969: the exit CAUSE, in the degradation ledger.
1426
+ //
1427
+ // The `exit` log above is one latency.log line per death. That line is
1428
+ // necessary but not sufficient: an ast-grep child died 14 times in one day
1429
+ // with `code=1` and empty stderr, and the only thing visible downstream was
1430
+ // 19 `lsp_client_skipped_broken` cooldowns and 32 coverage gaps — fallout
1431
+ // with no cause attached. The ledger is what a session-end health read
1432
+ // looks at, so the cause belongs there too, counted per server.
1433
+ //
1434
+ // `close` rather than `exit`: `close` fires only after the child's stdio
1435
+ // streams have drained, so "stderr was empty" is a statement about the
1436
+ // server rather than a race against the pipe. `exit` can arrive first with
1437
+ // stderr still buffered.
1438
+ //
1439
+ // Gated on `shutdownRequested` exactly as the exit log is — an eviction or
1440
+ // an ordinary teardown is not a degradation and must not be recorded.
1441
+ // `incrementDegradationCount` counts every occurrence and retains the
1442
+ // latest reason per server, so a server dying in a loop shows as one entry
1443
+ // with an honest count rather than N entries.
1444
+ state.lspProcess.process.on("close", (code, signal) => {
1445
+ if (state.shutdownRequested)
1446
+ return;
1447
+ try {
1448
+ const stderrTail = recentStderr(20);
1449
+ incrementDegradationCount({
1450
+ kind: "lsp-server-unexpected-close",
1451
+ subject: state.serverId,
1452
+ reason: `closed unprompted: code=${code ?? "none"} signal=${signal ?? "none"} ` +
1453
+ `stderr=${stderrTail.length > 0 ? `present(${stderrTail.length}b)` : "empty"} root=${state.root}`,
1454
+ });
1455
+ }
1456
+ catch {
1457
+ // Telemetry must never break the observed path.
1458
+ }
1459
+ });
1425
1460
  }
1426
1461
  /** Margin between each source's own request timeout and the race's backstop
1427
1462
  * deadline, so the natural "all sources settled" path wins over the backstop
@@ -2750,13 +2785,85 @@ function armNavLateAnswerTelemetry(args) {
2750
2785
  // as an unhandled rejection.
2751
2786
  });
2752
2787
  }
2788
+ /**
2789
+ * #1969: pick the request the liveness probe may send to THIS server.
2790
+ *
2791
+ * The probe only needs a round-trip, so any request works — but it must be one
2792
+ * the server advertised. #1277 originally hardcoded `workspace/symbol` because
2793
+ * it needs no open document, and treated `MethodNotFound` as proof of life.
2794
+ * That rationale is sound for the round-trip, and wrong as a default: a server
2795
+ * that does not implement the method logs an error for every probe. ast-grep's
2796
+ * tower_lsp backend wrote "got a 'workspace/symbol' request, but it is not
2797
+ * implemented" on each one, and its `code=1` deaths clustered after them
2798
+ * (#1969). pi-lens must not keep pushing an unimplemented method at a server
2799
+ * just to learn it is awake.
2800
+ *
2801
+ * The ladder, best first:
2802
+ * 1. `workspace/symbol` when `workspaceSymbolProvider` is advertised — the
2803
+ * #1277 probe, unchanged, and still needs no open document.
2804
+ * 2. `textDocument/documentSymbol` on an already-open document when
2805
+ * `documentSymbolProvider` is advertised. Read-only, scoped to a document
2806
+ * the server already has, and cheap.
2807
+ * 3. `textDocument/hover` at the start of an already-open document when
2808
+ * `hoverProvider` is advertised. This step is what keeps ast-grep covered:
2809
+ * `docs/servercapabilities.md:53` records its advertised set as
2810
+ * codeActionProvider, executeCommandProvider, hoverProvider and
2811
+ * textDocumentSync — no symbol provider of either kind. #1714's own
2812
+ * measurement (clients/lsp/index.ts, `paceAuxNotify`) timed hover against
2813
+ * the real ast-grep binary at 1 ms idle and 2086 ms behind 30 `didOpen`s,
2814
+ * the same ordering proof it recorded for `workspace/symbol`. So the
2815
+ * notify throttle keeps a real barrier here, not a hollow one.
2816
+ * 4. Nothing. The caller then reports liveness from process and connection
2817
+ * state alone, and records that it did.
2818
+ *
2819
+ * `MethodNotFound`-counts-as-alive still holds for every probe: dynamic
2820
+ * registration can retract a capability after `initialize`, and an error reply
2821
+ * is still a reply. What changes is that pi-lens no longer PROVOKES that error
2822
+ * on every probe.
2823
+ */
2824
+ function chooseLivenessProbe(state) {
2825
+ if (state.operationSupport?.workspaceSymbol === true) {
2826
+ return {
2827
+ method: "workspace/symbol",
2828
+ params: { query: LIVENESS_PING_QUERY },
2829
+ };
2830
+ }
2831
+ const openUri = firstOpenDocumentUri(state);
2832
+ if (openUri === undefined)
2833
+ return undefined;
2834
+ if (state.operationSupport?.documentSymbol === true) {
2835
+ return {
2836
+ method: "textDocument/documentSymbol",
2837
+ params: { textDocument: { uri: openUri } },
2838
+ };
2839
+ }
2840
+ if (state.operationSupport?.hover === true) {
2841
+ return {
2842
+ method: "textDocument/hover",
2843
+ params: {
2844
+ textDocument: { uri: openUri },
2845
+ position: { line: 0, character: 0 },
2846
+ },
2847
+ };
2848
+ }
2849
+ return undefined;
2850
+ }
2851
+ /** URI of any document this client currently holds open, if it holds one. */
2852
+ function firstOpenDocumentUri(state) {
2853
+ for (const normalizedPath of state.openDocuments) {
2854
+ const uri = state.openDocumentUris?.get(normalizedPath);
2855
+ if (uri !== undefined)
2856
+ return uri;
2857
+ }
2858
+ return undefined;
2859
+ }
2753
2860
  // #1277: cheap liveness round-trip used by the silent-clean gates in
2754
2861
  // `index.ts`. `isAlive()`/`checkAlive()` only look at process/connection
2755
2862
  // state — a server that accepted the notify write and then wedged (still
2756
2863
  // running, connection still open, just never replying) reads as "alive" by
2757
2864
  // those checks even though it will never answer anything again. This sends a
2758
- // real request (`workspace/symbol`, chosen because it needs no open document)
2759
- // and reports whether the connection round-tripped it — success, a genuine
2865
+ // real request (see `chooseLivenessProbe` for which, #1969) and reports
2866
+ // whether the connection round-tripped it — success, a genuine
2760
2867
  // protocol-level error (e.g. MethodNotFound), and a stream-destroyed/
2761
2868
  // cancelled response (safeSendRequest swallows those to `undefined`, so the
2762
2869
  // final `isClientAlive` re-check is what catches "died mid-flight") ALL count
@@ -2766,10 +2873,30 @@ function armNavLateAnswerTelemetry(args) {
2766
2873
  async function clientPingLiveness(state, timeoutMs = LIVENESS_PING_TIMEOUT_MS) {
2767
2874
  if (!isClientAlive(state))
2768
2875
  return false;
2876
+ const probe = chooseLivenessProbe(state);
2877
+ if (probe === undefined) {
2878
+ // #1969: no advertised method to probe with, so this answer comes from
2879
+ // process and connection state alone — exactly the checks the probe
2880
+ // exists to strengthen. Say so in the ledger rather than let a weaker
2881
+ // verdict pass as the strong one. Rising edge per server, via the
2882
+ // ledger's own tally; the count carries the repeats.
2883
+ try {
2884
+ incrementDegradationCount({
2885
+ kind: "lsp-liveness-probe-unsupported",
2886
+ subject: state.serverId,
2887
+ reason: "no advertised request method to probe with (no " +
2888
+ "workspaceSymbolProvider, and no open document with " +
2889
+ "documentSymbolProvider or hoverProvider); liveness from " +
2890
+ "process and connection state only",
2891
+ });
2892
+ }
2893
+ catch {
2894
+ // Telemetry must never break the observed path.
2895
+ }
2896
+ return isClientAlive(state);
2897
+ }
2769
2898
  try {
2770
- await withTimeout(safeSendRequest(state.connection, "workspace/symbol", {
2771
- query: LIVENESS_PING_QUERY,
2772
- }), timeoutMs);
2899
+ await withTimeout(safeSendRequest(state.connection, probe.method, probe.params), timeoutMs);
2773
2900
  }
2774
2901
  catch (err) {
2775
2902
  if (err instanceof Error && err.message.startsWith("Timeout after")) {
@@ -3134,6 +3261,18 @@ export async function createLSPClient(options) {
3134
3261
  }), initializeTimeoutMs);
3135
3262
  }
3136
3263
  catch (err) {
3264
+ // #1969: claim this kill BEFORE issuing it. `setupConnectionLifecycle`
3265
+ // ran above, so its `exit` and `close` handlers are already armed on
3266
+ // this child, and both read `state.shutdownRequested` to tell a crash
3267
+ // from a teardown we asked for. The kill below is one we asked for.
3268
+ // Without this line it reported as an unprompted death, fabricating a
3269
+ // `lsp-server-unexpected-close` entry reading
3270
+ // "code=1 signal=none stderr=empty" — character for character the
3271
+ // ast-grep signature this issue exists to make trustworthy — every time
3272
+ // a server merely failed to complete its handshake. It also silenced a
3273
+ // false `lsp_server_unexpected_exit` latency line that this path has
3274
+ // been writing since the #615 follow-up.
3275
+ state.shutdownRequested = true;
3137
3276
  // Hard-kill the hung process so it doesn't become a zombie.
3138
3277
  // SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees.
3139
3278
  const pid = lspProcess.pid;
@@ -555,9 +555,15 @@ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceD
555
555
  continue;
556
556
  await walk(full);
557
557
  }
558
- else if (entry.isFile() &&
559
- !ignoreMatcher.isIgnored(full, false) &&
560
- getServersForFileWithConfig(full).length > 0) {
558
+ else if (
559
+ // #1974: the getServersForFileWithConfig lookup (an in-memory
560
+ // extension/pathFilter match against the registered LSP servers) is
561
+ // cheap relative to isIgnored's per-call minimatch pattern compile,
562
+ // so it gates first — the same order-independent shape as the four
563
+ // walkers fixed for #1974.
564
+ entry.isFile() &&
565
+ getServersForFileWithConfig(full).length > 0 &&
566
+ !ignoreMatcher.isIgnored(full, false)) {
561
567
  files.push(full);
562
568
  }
563
569
  }
@@ -576,6 +582,21 @@ export class LSPService {
576
582
  /** Server/root pairs that already emitted unavailable for the current occurrence. */
577
583
  unavailableLogged = new Set();
578
584
  optionalDisabled = new Set();
585
+ /**
586
+ * #1934 review F1: what the last COMPLETED `spawnClient` call for a
587
+ * (server, root) key decided, written by that call at every point it
588
+ * returns without a client. This is a direct signal, deliberately NOT an
589
+ * inference from breaker state: the "binary unavailable while installs are
590
+ * disabled" branch sets a cooldown yet is a POLICY decline by its own
591
+ * comment, so reading the cooldown mislabels it as a server failure.
592
+ *
593
+ * Not a latch, and nothing re-arms it at `session_start`. Every read sits
594
+ * in the same microtask as the `await` of the spawn promise that just
595
+ * wrote it, so a stale entry is unreachable: a read is always preceded by
596
+ * its own attempt's write. Cardinality matches `state.clients` — one entry
597
+ * per (server, root) — and a successful spawn deletes its entry.
598
+ */
599
+ lastSpawnVerdict = new Map();
579
600
  /** Consecutive failure counts for exponential backoff circuit breaker */
580
601
  failureCounts = new Map();
581
602
  /**
@@ -1589,9 +1610,20 @@ export class LSPService {
1589
1610
  const withBudget = async () => {
1590
1611
  if (servers.length === 0)
1591
1612
  return undefined;
1613
+ // #1934: the first server whose acquisition ERRORED, as opposed to
1614
+ // cleanly declining. Kept so a selection that served nobody still
1615
+ // says which server the pool actually tried and failed to spawn.
1616
+ let erroredServerId;
1592
1617
  // Try each matching server
1593
1618
  for (const server of servers) {
1594
- const spawned = await this.ensureClientForServer(filePath, server, resolvedRoots, noteSpawnInFlight);
1619
+ // A box, not a `let`: control-flow analysis cannot see the callback
1620
+ // write and would narrow a plain local to its initializer.
1621
+ const acquisition = {
1622
+ outcome: "declined",
1623
+ };
1624
+ const spawned = await this.ensureClientForServer(filePath, server, resolvedRoots, noteSpawnInFlight, (reported) => {
1625
+ acquisition.outcome = reported;
1626
+ });
1595
1627
  if (spawned) {
1596
1628
  logLatency({
1597
1629
  type: "phase",
@@ -1601,10 +1633,36 @@ export class LSPService {
1601
1633
  metadata: {
1602
1634
  serverId: server.id,
1603
1635
  candidateCount: servers.length,
1636
+ // Emitted RAW, never coerced to a "safe" value: a served
1637
+ // client always reports `warm-reuse` or `cold-spawn`, so a
1638
+ // `declined` here would be a real reporting bug and must be
1639
+ // visible in the log rather than laundered into a lie.
1640
+ outcome: acquisition.outcome,
1604
1641
  },
1605
1642
  });
1606
1643
  return spawned;
1607
1644
  }
1645
+ if (acquisition.outcome === "spawn-failure") {
1646
+ erroredServerId ??= server.id;
1647
+ }
1648
+ }
1649
+ if (erroredServerId !== undefined) {
1650
+ // Same record, same denominator as the two served outcomes. Bounded
1651
+ // by the LSP breaker, not by a latch: a spawn failure always cools
1652
+ // the (server, root) key down, so the next touch takes the
1653
+ // `lsp_client_skipped_broken` early return and reports `declined`
1654
+ // instead of reaching here again until the cooldown expires.
1655
+ logLatency({
1656
+ type: "phase",
1657
+ phase: "lsp_client_selected",
1658
+ filePath,
1659
+ durationMs: 0,
1660
+ metadata: {
1661
+ serverId: erroredServerId,
1662
+ candidateCount: servers.length,
1663
+ outcome: "spawn-failure",
1664
+ },
1665
+ });
1608
1666
  }
1609
1667
  const unavailable = (await Promise.all(servers.map(async (server) => {
1610
1668
  const root = await server.root(filePath);
@@ -1753,6 +1811,10 @@ export class LSPService {
1753
1811
  if (this.checkDestroyed())
1754
1812
  return undefined;
1755
1813
  const servers = getServersForFileWithConfig(filePath);
1814
+ // #1934: the (server, root) pairs that COULD have served this file and
1815
+ // were cold. A server with no resolvable root is not a pool miss — it
1816
+ // never had a slot to miss — so it stays out of this list.
1817
+ const missed = [];
1756
1818
  for (const server of servers) {
1757
1819
  const root = await this.resolveServerRoot(server, filePath);
1758
1820
  if (!root)
@@ -1762,6 +1824,27 @@ export class LSPService {
1762
1824
  if (existing?.isAlive()) {
1763
1825
  return { client: existing, info: server };
1764
1826
  }
1827
+ missed.push(key);
1828
+ }
1829
+ // #1934: an empty `missed` means "this file has no language server here",
1830
+ // which is the normal answer for most reads and must not be logged as a
1831
+ // pool miss. Callers run per file in the cascade quiet window and on the
1832
+ // read-expansion path, so a raw record would be a per-file log storm:
1833
+ // the ledger counts every miss exactly and only the FIRST per candidate
1834
+ // set also writes the detailed record.
1835
+ if (missed.length > 0) {
1836
+ emitBounded("lsp_warm_client_missing", missed.join(","), {
1837
+ filePath,
1838
+ durationMs: 0,
1839
+ metadata: {
1840
+ serverIds: missed.map((key) => key.slice(0, key.indexOf(":"))),
1841
+ roots: missed.map((key) => key.slice(key.indexOf(":") + 1)),
1842
+ },
1843
+ }, {
1844
+ ledgerKind: "lsp-warm-client-missing",
1845
+ risingEdgePer: "identity",
1846
+ reason: `no warm client for ${missed.join(",")}`,
1847
+ });
1765
1848
  }
1766
1849
  return undefined;
1767
1850
  }
@@ -1972,7 +2055,22 @@ export class LSPService {
1972
2055
  }
1973
2056
  this.documentDrift.recordSynced(filePath, content, at);
1974
2057
  }
1975
- async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight) {
2058
+ /**
2059
+ * #1934 review F1: record what a `spawnClient` call decided, at the point
2060
+ * it decides it. Called on EVERY path that returns without a client, so
2061
+ * "no verdict" cannot silently mean "failed".
2062
+ *
2063
+ * `"failed"` is a server failure: the spawn or the initialize handshake
2064
+ * went wrong. `"declined"` is policy or lifecycle: host trust refused the
2065
+ * binary, the service shut down mid-spawn, or the binary is absent while
2066
+ * installs are disabled. That last one sets a breaker cooldown but is NOT
2067
+ * a failure, which is exactly why the outcome cannot be inferred from
2068
+ * breaker state.
2069
+ */
2070
+ noteSpawnVerdict(key, verdict) {
2071
+ this.lastSpawnVerdict.set(key, verdict);
2072
+ }
2073
+ async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight, onOutcome) {
1976
2074
  const handoff = this.generationHandoff;
1977
2075
  if (handoff) {
1978
2076
  await handoff;
@@ -2043,6 +2141,10 @@ export class LSPService {
2043
2141
  logSessionStart(`lsp warm-start ${server.id}: reused root=${root} file=${filePath}`);
2044
2142
  this.warmStartLogged.add(key);
2045
2143
  }
2144
+ // #1934: the pool paid nothing. This is the outcome the reuse rate
2145
+ // is built from, so it is reported on the ONE path that returns a
2146
+ // client without spawning.
2147
+ onOutcome?.("warm-reuse");
2046
2148
  return { client: existing, info: server };
2047
2149
  }
2048
2150
  // Dead client — was previously alive, now needs respawn
@@ -2269,7 +2371,24 @@ export class LSPService {
2269
2371
  // which strands the settled promise and stops the server respawning.
2270
2372
  onSpawnInFlight?.(server.id);
2271
2373
  try {
2272
- return await spawnPromise;
2374
+ const spawned = await spawnPromise;
2375
+ // #1934: a client here cost a process WAIT, whether this caller
2376
+ // started the spawn or joined another caller's in-flight promise.
2377
+ // Either way the selection was not served from the warm pool.
2378
+ //
2379
+ // The verdict read is synchronous and sits in the same microtask as
2380
+ // the await above, so it can only see the attempt just settled.
2381
+ onOutcome?.(spawned
2382
+ ? "cold-spawn"
2383
+ : this.lastSpawnVerdict.get(key) === "failed"
2384
+ ? "spawn-failure"
2385
+ : "declined");
2386
+ return spawned;
2387
+ }
2388
+ catch (err) {
2389
+ // A throwing spawn promise is an errored acquisition by definition.
2390
+ onOutcome?.("spawn-failure");
2391
+ throw err;
2273
2392
  }
2274
2393
  finally {
2275
2394
  if (this.state.inFlight.get(key) === spawnPromise) {
@@ -2293,6 +2412,7 @@ export class LSPService {
2293
2412
  // failure, and the user may grant trust later in the same session.
2294
2413
  if (!isLspSpawnAllowedByTrust()) {
2295
2414
  logSessionStart(`lsp spawn ${server.id}: refused — ${projectTrustDenialReason()}`);
2415
+ this.noteSpawnVerdict(key, "declined");
2296
2416
  return undefined;
2297
2417
  }
2298
2418
  const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
@@ -2312,6 +2432,7 @@ export class LSPService {
2312
2432
  // pi-lens-ignore: missing-error-propagation — best-effort kill on aborted spawn
2313
2433
  }
2314
2434
  logSessionStart(`lsp spawn ${server.id}: aborted (service shut down mid-spawn)`);
2435
+ this.noteSpawnVerdict(key, "declined");
2315
2436
  return undefined;
2316
2437
  }
2317
2438
  if (!spawned) {
@@ -2325,6 +2446,17 @@ export class LSPService {
2325
2446
  if (!allowInstall) {
2326
2447
  logSessionStart(`lsp spawn ${server.id}: unavailable with install disabled; temporary cooldown only`);
2327
2448
  this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
2449
+ // #1934 review F1: this branch sets a cooldown but is a POLICY
2450
+ // decline by the comment above — the binary may appear on PATH
2451
+ // later in the same session, so it never counts toward permanent
2452
+ // disablement and the cooldown has no ladder. With installs
2453
+ // disabled or a project untrusted, a missing binary reaches here
2454
+ // once per 15s per (server, root) for the whole session. Calling
2455
+ // that a spawn failure would write thousands of mislabeled
2456
+ // records a day, so it reads as a decline. The event is already
2457
+ // recorded once by `lsp_client_unavailable` and by the
2458
+ // `sessionstart.log` line above.
2459
+ this.noteSpawnVerdict(key, "declined");
2328
2460
  return undefined;
2329
2461
  }
2330
2462
  const uCount = (this.failureCounts.get(key) ?? 0) + 1;
@@ -2336,6 +2468,9 @@ export class LSPService {
2336
2468
  this.recordBreaker(key, `permanently disabled after ${uCount} unavailable spawns`);
2337
2469
  logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${uCount} failures`);
2338
2470
  }
2471
+ // Installs were allowed and the server is still unavailable: a real
2472
+ // failure, on the exponential ladder toward permanent disablement.
2473
+ this.noteSpawnVerdict(key, "failed");
2339
2474
  return undefined;
2340
2475
  }
2341
2476
  const override = getServerInitOverride(server.id, filePath);
@@ -2353,6 +2488,7 @@ export class LSPService {
2353
2488
  if (this.isDestroyed) {
2354
2489
  client.shutdown({ fast: true }).catch(() => { });
2355
2490
  logSessionStart(`lsp spawn ${server.id}: aborted (service shut down mid-initialize)`);
2491
+ this.noteSpawnVerdict(key, "declined");
2356
2492
  return undefined;
2357
2493
  }
2358
2494
  const wsDiag = typeof client.getWorkspaceDiagnosticsSupport === "function"
@@ -2364,6 +2500,9 @@ export class LSPService {
2364
2500
  };
2365
2501
  this.state.clients.set(key, client);
2366
2502
  this.unavailableLogged.delete(key);
2503
+ // #1934 review F1: a success retires the previous verdict, so the map
2504
+ // never outlives the attempts it describes.
2505
+ this.lastSpawnVerdict.delete(key);
2367
2506
  this.state.clientSpawnedAt.set(key, Date.now());
2368
2507
  this.clientLastUsedAt.set(key, Date.now());
2369
2508
  this.scheduleTypeScriptIdleEviction(key);
@@ -2404,6 +2543,8 @@ export class LSPService {
2404
2543
  if (isOptionalServer) {
2405
2544
  this.optionalDisabled.add(key);
2406
2545
  }
2546
+ // The spawn or the initialize handshake threw: a server failure.
2547
+ this.noteSpawnVerdict(key, "failed");
2407
2548
  return undefined;
2408
2549
  }
2409
2550
  }
@@ -6479,6 +6620,10 @@ export class LSPService {
6479
6620
  });
6480
6621
  this.state.clients.clear();
6481
6622
  this.state.broken.clear();
6623
+ // #1934 review F1: map hygiene alongside the breaker it sits next to.
6624
+ // Not load-bearing — every read follows its own attempt's write — but a
6625
+ // verdict for a client generation that no longer exists is dead weight.
6626
+ this.lastSpawnVerdict.clear();
6482
6627
  // #1459: every gated client is gone, so no outstanding-write record can
6483
6628
  // describe a live one. The gate's identity check already neutralises a stale
6484
6629
  // entry; clearing keeps the map honest rather than relying on that.
@@ -1338,10 +1338,19 @@ export async function moduleReport(file, cwd, options) {
1338
1338
  let graph;
1339
1339
  let graphFileCap;
1340
1340
  let callGraphIdentity;
1341
+ // #1961 review F4: the blind read now serves a snapshot stamped at a
1342
+ // different commit. project_report says so in its trust notes; this report
1343
+ // reads through the SAME accessor, so without this it would present the
1344
+ // other branch's importers and call edges as current, under a builtAt that
1345
+ // looks fresh. Computed per call, never cached.
1346
+ let revisionDriftNote;
1341
1347
  try {
1342
- const { getCachedReviewGraph, getReviewGraphCacheIdentity, getReviewGraphSizeSkipVerdict, } = await import("./review-graph/builder.js");
1348
+ const { getCachedReviewGraph, getReviewGraphCacheIdentity, getReviewGraphSizeSkipVerdict, getReviewGraphRevisionDrift, formatReviewGraphRevisionDriftNote, } = await import("./review-graph/builder.js");
1343
1349
  graph = getCachedReviewGraph(cwd);
1344
1350
  graphFileCap = getReviewGraphSizeSkipVerdict(cwd)?.maxFileCount;
1351
+ const drift = graph ? getReviewGraphRevisionDrift(cwd) : undefined;
1352
+ if (drift)
1353
+ revisionDriftNote = formatReviewGraphRevisionDriftNote(drift);
1345
1354
  callGraphIdentity = graph
1346
1355
  ? (() => {
1347
1356
  const identity = getReviewGraphCacheIdentity(cwd, graph);
@@ -1374,6 +1383,10 @@ export async function moduleReport(file, cwd, options) {
1374
1383
  const api = topLevel.filter((entry) => entry.exported);
1375
1384
  const internal = topLevel.filter((entry) => !entry.exported);
1376
1385
  const warnings = [...(extractionWarnings ?? [])];
1386
+ // Every graph-derived section below (who-uses-this, blast radius, call
1387
+ // edges) came from the served snapshot, so the caveat belongs with them.
1388
+ if (revisionDriftNote)
1389
+ warnings.push(revisionDriftNote);
1377
1390
  if (callbackError) {
1378
1391
  warnings.push(`Failed to extract callbacks: ${callbackError}`);
1379
1392
  logLatency({