@herbertgao/pi-extensions 2026.8.5 → 2026.8.6

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 (155) hide show
  1. package/README.md +5 -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/context.ts +74 -5
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +48 -6
  6. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  7. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +10 -0
  8. package/node_modules/@herbertgao/pi-subagents/package.json +3 -3
  9. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +5 -2
  10. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +15 -6
  11. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +9 -5
  12. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
  13. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
  14. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -0
  15. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  16. package/node_modules/@narumitw/pi-btw/README.md +24 -17
  17. package/node_modules/@narumitw/pi-btw/package.json +5 -5
  18. package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
  19. package/node_modules/@narumitw/pi-btw/src/menu.ts +33 -13
  20. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  21. package/node_modules/pi-lens/CHANGELOG.md +95 -0
  22. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +314 -0
  23. package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
  24. package/node_modules/pi-lens/dist/clients/biome-client.js +121 -13
  25. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  26. package/node_modules/pi-lens/dist/clients/bus-publish.js +11 -3
  27. package/node_modules/pi-lens/dist/clients/cascade-format.js +57 -2
  28. package/node_modules/pi-lens/dist/clients/console-guard-install.js +16 -4
  29. package/node_modules/pi-lens/dist/clients/dead-code-client.js +135 -30
  30. package/node_modules/pi-lens/dist/clients/dependency-checker.js +19 -7
  31. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +6 -4
  32. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  33. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +114 -10
  34. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +101 -16
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -4
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +230 -50
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
  40. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  41. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  42. package/node_modules/pi-lens/dist/clients/extension-log.js +296 -3
  43. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  44. package/node_modules/pi-lens/dist/clients/format-events-publish.js +39 -8
  45. package/node_modules/pi-lens/dist/clients/git-guard.js +18 -21
  46. package/node_modules/pi-lens/dist/clients/go-client.js +21 -39
  47. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +116 -7
  48. package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
  49. package/node_modules/pi-lens/dist/clients/installer/index.js +5 -1
  50. package/node_modules/pi-lens/dist/clients/jscpd-client.js +4 -9
  51. package/node_modules/pi-lens/dist/clients/knip-client.js +51 -14
  52. package/node_modules/pi-lens/dist/clients/latency-logger.js +50 -1
  53. package/node_modules/pi-lens/dist/clients/lens-events.js +56 -25
  54. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  55. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +45 -2
  56. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  57. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +58 -13
  58. package/node_modules/pi-lens/dist/clients/lsp/client.js +169 -8
  59. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +29 -0
  60. package/node_modules/pi-lens/dist/clients/lsp/index.js +264 -66
  61. package/node_modules/pi-lens/dist/clients/lsp/server.js +247 -60
  62. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
  63. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  64. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
  65. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +4 -0
  66. package/node_modules/pi-lens/dist/clients/mcp/session.js +30 -17
  67. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  68. package/node_modules/pi-lens/dist/clients/pipeline.js +29 -2
  69. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
  70. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  71. package/node_modules/pi-lens/dist/clients/review-graph/query.js +24 -0
  72. package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
  73. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +153 -7
  74. package/node_modules/pi-lens/dist/clients/runtime-context.js +104 -11
  75. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +170 -22
  76. package/node_modules/pi-lens/dist/clients/runtime-session.js +28 -5
  77. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +124 -10
  78. package/node_modules/pi-lens/dist/clients/runtime-turn.js +388 -35
  79. package/node_modules/pi-lens/dist/clients/rust-client.js +21 -37
  80. package/node_modules/pi-lens/dist/clients/security-scan-client.js +88 -5
  81. package/node_modules/pi-lens/dist/clients/sg-runner.js +141 -23
  82. package/node_modules/pi-lens/dist/clients/smells-rollup.js +18 -11
  83. package/node_modules/pi-lens/dist/clients/startup-timing.js +7 -1
  84. package/node_modules/pi-lens/dist/clients/test-runner-client.js +431 -24
  85. package/node_modules/pi-lens/dist/clients/tool-policy.js +2 -0
  86. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  87. package/node_modules/pi-lens/dist/clients/warm-attach.js +17 -0
  88. package/node_modules/pi-lens/dist/clients/word-index.js +305 -33
  89. package/node_modules/pi-lens/dist/index.js +4827 -1633
  90. package/node_modules/pi-lens/dist/mcp/server.js +8 -2
  91. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  92. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  93. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  94. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +3 -2
  95. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +62 -7
  96. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  97. package/node_modules/pi-lens/docs/agent-guide.md +38 -13
  98. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +11 -3
  99. package/node_modules/pi-lens/docs/features.md +14 -1
  100. package/node_modules/pi-lens/docs/globalconfig.md +11 -0
  101. package/node_modules/pi-lens/docs/servercapabilities.md +1 -1
  102. package/node_modules/pi-lens/docs/settings.md +6 -0
  103. package/node_modules/pi-lens/docs/usage.md +23 -5
  104. package/node_modules/pi-lens/docs/word-index.md +35 -0
  105. package/node_modules/pi-lens/package.json +1 -1
  106. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
  107. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
  108. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  109. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
  110. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
  111. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
  112. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
  113. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  114. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
  115. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  116. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  117. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
  118. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
  119. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
  120. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
  121. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
  122. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +79 -0
  123. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
  124. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  125. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
  126. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
  127. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  128. package/node_modules/pi-mcp-adapter/CHANGELOG.md +13 -0
  129. package/node_modules/pi-mcp-adapter/README.md +4 -1
  130. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  131. package/node_modules/pi-mcp-adapter/cli.js +25 -4
  132. package/node_modules/pi-mcp-adapter/config.ts +4 -4
  133. package/node_modules/pi-mcp-adapter/direct-tools.ts +18 -15
  134. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  135. package/node_modules/pi-mcp-adapter/metadata-cache.ts +18 -8
  136. package/node_modules/pi-mcp-adapter/package.json +2 -1
  137. package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
  138. package/node_modules/pi-mcp-adapter/server-manager.ts +5 -0
  139. package/node_modules/pi-mcp-adapter/tool-metadata.ts +38 -18
  140. package/node_modules/pi-mcp-adapter/types.ts +93 -10
  141. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  142. package/node_modules/pi-web-access/README.md +18 -12
  143. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  144. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  145. package/node_modules/pi-web-access/curator-page.ts +5 -3
  146. package/node_modules/pi-web-access/curator-server.ts +2 -1
  147. package/node_modules/pi-web-access/extract.ts +106 -34
  148. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  149. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  150. package/node_modules/pi-web-access/gemini-search.ts +18 -4
  151. package/node_modules/pi-web-access/index.ts +120 -48
  152. package/node_modules/pi-web-access/package.json +2 -2
  153. package/node_modules/pi-web-access/summary-review.ts +11 -5
  154. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  155. package/package.json +9 -9
@@ -22,10 +22,10 @@ import { incrementDegradationCount, recordDegradation, recordDegradationOnce, }
22
22
  import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
23
23
  import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
24
24
  import { withDeadline } from "../deadline-utils.js";
25
- import { isAtOrAboveHomeDir, normalizeMapKey, uriToPath, } from "../path-utils.js";
25
+ import { isAtOrAboveHomeDir, isWindowsPath, normalizeMapKey, uriToPath, } from "../path-utils.js";
26
26
  import { recordLspMutation, } from "../lsp-mutation.js";
27
27
  import { createLSPClient } from "./client.js";
28
- import { bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, } from "./diagnostic-binding.js";
28
+ import { bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, touchCoverageGap, } from "./diagnostic-binding.js";
29
29
  import { getServersForFileWithConfig, getServerInitOverride } from "./config.js";
30
30
  import { getLanguageId } from "./language.js";
31
31
  import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
@@ -230,13 +230,15 @@ function readTsserverSyncGraceMs() {
230
230
  }
231
231
  /**
232
232
  * Read the `PI_LENS_AUX_GRACE_MS` env override at call time (not module
233
- * load time) so tests can set it per-case. Controls how long auxiliary-role
234
- * promises (opengrep, ast-grep, zizmor, …) are waited after all primary-role
235
- * promises have settled in both getDiagnostics (raceToCompletion) and the
236
- * touchFile push wait. Default 500ms — conservative enough to include
237
- * auxiliaries that are nearly done while not blocking the primary result.
238
- * Returns undefined when the var is absent (caller falls back to the
239
- * raceToCompletion default of 500ms, keeping the two in sync).
233
+ * load time) so tests can set it per-case. Controls the CEILING on how long
234
+ * auxiliary-role promises (opengrep, ast-grep, zizmor, …) are waited after
235
+ * all primary-role promises have settled, in both getDiagnostics
236
+ * (raceToCompletion) and the touchFile push wait (#1458 S2 — the two lanes
237
+ * share the same declared-budget-capped-by-ceiling shape). Each auxiliary
238
+ * still gets only its OWN declared `aggregateWaitMs` up to this ceiling —
239
+ * this is not a flat per-touch wait. Returns undefined when the var is
240
+ * absent; each call site then supplies its own default ceiling (touchFile:
241
+ * 2000ms; getDiagnostics: 2000ms — see the `?? 2000` at each call site).
240
242
  */
241
243
  function readEnvAuxGraceMs() {
242
244
  const raw = process.env.PI_LENS_AUX_GRACE_MS;
@@ -636,10 +638,13 @@ export class LSPService {
636
638
  if (!key.startsWith(prefix))
637
639
  continue;
638
640
  const ancestorKey = key.slice(prefix.length);
639
- const relative = path.relative(ancestorKey, rootKey);
641
+ const pathApi = isWindowsPath(ancestorKey) || isWindowsPath(rootKey)
642
+ ? path.win32
643
+ : path;
644
+ const relative = pathApi.relative(ancestorKey, rootKey);
640
645
  if (relative === "" ||
641
646
  relative.startsWith("..") ||
642
- path.isAbsolute(relative)) {
647
+ pathApi.isAbsolute(relative)) {
643
648
  continue;
644
649
  }
645
650
  if (!nearestAncestor || ancestorKey.length > nearestAncestor.length) {
@@ -1699,6 +1704,24 @@ export class LSPService {
1699
1704
  const notifySkippedServerIds = new Set(spawnedServerIds.filter((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId)));
1700
1705
  const notifySkipped = spawned.length > 0 && notifySkippedServerIds.size === spawned.length;
1701
1706
  const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
1707
+ // #1458: read a late auxiliary publication BEFORE the ordinary resync
1708
+ // clears its client cache. Carry it only when the publication's exact
1709
+ // sent-content fingerprint matches this touch's content. A changed edit,
1710
+ // version-less publication, or malformed binding fails closed and is not
1711
+ // replayed. The fresh notify still runs below, so scanners continue toward
1712
+ // a publication for this touch while the prior late result reaches the read.
1713
+ const touchContentHash = this.hashContent(content);
1714
+ const carriedAuxiliary = options.collectDiagnostics
1715
+ ? spawned.flatMap((entry) => {
1716
+ if (entry.info.role !== "auxiliary")
1717
+ return [];
1718
+ const binding = entry.client.getDiagnosticBinding?.(filePath);
1719
+ if (binding?.contentHash !== touchContentHash)
1720
+ return [];
1721
+ const diags = entry.client.getDiagnostics(filePath);
1722
+ return diags.length > 0 ? [{ diags, binding }] : [];
1723
+ })
1724
+ : [];
1702
1725
  // #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
1703
1726
  // write gets its OWN notifyWriteBudgetMs budget rather than one shared
1704
1727
  // deadline over a single Promise.all — otherwise one backpressured server
@@ -1827,7 +1850,7 @@ export class LSPService {
1827
1850
  const sweepFirstTouch = new Map();
1828
1851
  if (options.sweepIndexGate) {
1829
1852
  for (const entry of spawned) {
1830
- const strategy = getStrategy(entry.client.serverId);
1853
+ const strategy = getStrategy(entry.client.serverId, entry.client.getLaunchVariant?.());
1831
1854
  if (strategy.workspaceIndexing) {
1832
1855
  sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
1833
1856
  }
@@ -1848,7 +1871,7 @@ export class LSPService {
1848
1871
  const silentCleanWarmupServers = new Set();
1849
1872
  if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
1850
1873
  for (const entry of spawned) {
1851
- const strategy = getStrategy(entry.client.serverId);
1874
+ const strategy = getStrategy(entry.client.serverId, entry.client.getLaunchVariant?.());
1852
1875
  if (strategy.workspaceIndexing !== true)
1853
1876
  continue;
1854
1877
  try {
@@ -1877,7 +1900,8 @@ export class LSPService {
1877
1900
  // slow aux (opengrep) can't override the per-edit cap. Resolves as soon
1878
1901
  // as a server publishes; this is just its individual deadline. (#242)
1879
1902
  const perServerTimeout = (serverId) => {
1880
- const strategy = getStrategy(serverId);
1903
+ const launchVariant = spawned.find((entry) => entry.client.serverId === serverId)?.client.getLaunchVariant?.();
1904
+ const strategy = getStrategy(serverId, launchVariant);
1881
1905
  let strategyWait = strategy.aggregateWaitMs;
1882
1906
  // #645: a `workspaceIndexing` server (marksman) only needs the
1883
1907
  // full budget for the FIRST same-sweep touch to it — every
@@ -1951,7 +1975,7 @@ export class LSPService {
1951
1975
  clientScope === "primary" &&
1952
1976
  spawned.length === 1 &&
1953
1977
  spawned[0].client.serverId === "typescript" &&
1954
- getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1978
+ getStrategy(spawned[0].client.serverId, spawned[0].client.getLaunchVariant?.()).silentOnClean === true) {
1955
1979
  try {
1956
1980
  const snapshots = await this.getCapabilitySnapshots(filePath);
1957
1981
  tsserverSyncEligible =
@@ -1979,7 +2003,17 @@ export class LSPService {
1979
2003
  // The #707 tsserver sync race operates exclusively on single-server
1980
2004
  // primary-scope touches (guarded by `clientScope === "primary" &&
1981
2005
  // spawned.length === 1`), so there is NO interaction with this path.
2006
+ // #1458 S4: also gated on `collectDiagnostics` — a non-collecting
2007
+ // with-auxiliary touch has nothing to carry the aux wait's result
2008
+ // INTO (its diagnostics are discarded either way), so paying up to
2009
+ // `auxCeilingMs` of extra latency for it buys nothing. Both current
2010
+ // callers (`getDiagnostics`'s with-auxiliary path and the cascade's
2011
+ // collecting touch) already pass `collectDiagnostics: true`, so this
2012
+ // is latent-today defense, not a behavior change — but a future
2013
+ // non-collecting with-auxiliary caller must not silently inherit the
2014
+ // full aux-grace cost for diagnostics it's about to throw away.
1982
2015
  const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
2016
+ options.collectDiagnostics === true &&
1983
2017
  spawned.some((e) => e.info.role === "auxiliary");
1984
2018
  // Per-server wait promises (each already bounded by its own
1985
2019
  // perServerTimeout — unchanged from before R8).
@@ -2017,49 +2051,100 @@ export class LSPService {
2017
2051
  ? (() => {
2018
2052
  // Primary waits: all non-auxiliary servers.
2019
2053
  const primaryWaits = perServerWaits.filter((_, i) => spawned[i].info.role !== "auxiliary");
2020
- // Aux waits: auxiliary servers (advisory).
2054
+ // Aux waits: auxiliary servers (advisory). `client` and the
2055
+ // pre-notify `diagnosticsVersion` baseline travel alongside the
2056
+ // promise so the outcome can be decided from EVIDENCE after the
2057
+ // race, not from how the raced promise settled (#1458 S1 — see
2058
+ // below).
2021
2059
  const auxWaits = perServerWaits
2022
2060
  .map((p, i) => spawned[i].info.role === "auxiliary"
2023
- ? { promise: p, serverId: spawned[i].info.id }
2061
+ ? {
2062
+ promise: p,
2063
+ serverId: spawned[i].info.id,
2064
+ client: spawned[i].client,
2065
+ baseline: diagnosticBaselines.get(spawned[i].client),
2066
+ }
2024
2067
  : null)
2025
2068
  .filter((x) => x !== null);
2026
- const auxGraceMs = readEnvAuxGraceMs() ?? 500;
2027
- // After all primaries settle, give auxiliaries at most auxGraceMs.
2028
- // Late aux results are dropped from the wait (advisory only — they
2029
- // land in the client cache and surface on the next edit); aux servers
2030
- // that did answer within the grace are included automatically since
2069
+ const auxCeilingMs = readEnvAuxGraceMs() ?? 2000;
2070
+ // After all primaries settle, give each auxiliary the smaller of
2071
+ // its declared wait budget and the global auxiliary ceiling. The
2072
+ // 2000ms default admits measured ~1.3s warm scanner runs without
2073
+ // making every edit pay opengrep's 3500ms cold-start allowance.
2074
+ // Late aux results are dropped from this wait. A later unchanged-
2075
+ // content read may carry a SHA-256-bound cache publication before its
2076
+ // resync clears the cache; changed or unknown content never replays.
2077
+ // Aux servers that answer within the grace are included automatically since
2031
2078
  // their waitForDiagnostics already resolved. The cut-off server ids
2032
2079
  // are logged in the latency metadata (lsp_touch_file phase, field
2033
2080
  // `auxCutOffServerIds`).
2034
2081
  return Promise.all(primaryWaits).then(async () => {
2035
2082
  if (auxWaits.length === 0)
2036
2083
  return;
2037
- const auxTimeout = new Promise((resolve) => {
2038
- const t = setTimeout(() => resolve("timeout"), auxGraceMs);
2039
- if (typeof t === "object" && "unref" in t)
2040
- t.unref?.();
2084
+ const auxWaitStartedAt = Date.now();
2085
+ const outcomes = await Promise.all(auxWaits.map(async (aux) => {
2086
+ const budgetMs = Math.min(timeoutFor(aux.serverId), auxCeilingMs);
2087
+ let timer;
2088
+ const timeout = new Promise((resolve) => {
2089
+ timer = setTimeout(() => resolve(false), budgetMs);
2090
+ if (typeof timer === "object" && "unref" in timer) {
2091
+ timer.unref?.();
2092
+ }
2093
+ });
2094
+ const raced = await Promise.race([
2095
+ aux.promise.then(() => true),
2096
+ timeout,
2097
+ ]);
2098
+ if (timer)
2099
+ clearTimeout(timer);
2100
+ // #1458 S1: `waitForDiagnostics` RESOLVES on its own timeout
2101
+ // (client.ts) — it never rejects, and a silent scanner that
2102
+ // published nothing looks identical, promise-wise, to one
2103
+ // that answered. `raced === true` only means "the promise
2104
+ // settled before our timer fired"; it is not proof anything
2105
+ // was published. Decide the outcome from evidence instead:
2106
+ // did this aux's `diagnosticsVersion` advance past the
2107
+ // pre-notify baseline captured before the wait started?
2108
+ // - raced === false → "cut_off" (our timer won;
2109
+ // the aux's own wait never got to answer for itself).
2110
+ // - raced === true, no evidence → "silent" (the aux's own
2111
+ // wait gave up within its budget with nothing to report —
2112
+ // NOT the same as having answered).
2113
+ // - raced === true, evidence → "answered" (a fresh
2114
+ // publication actually landed for this touch).
2115
+ const publishedEvidence = raced &&
2116
+ Number.isFinite(aux.baseline) &&
2117
+ aux.client.diagnosticsVersion > aux.baseline;
2118
+ const outcome = !raced
2119
+ ? "cut_off"
2120
+ : publishedEvidence
2121
+ ? "answered"
2122
+ : "silent";
2123
+ return {
2124
+ serverId: aux.serverId,
2125
+ outcome,
2126
+ budgetMs,
2127
+ elapsedMs: Date.now() - auxWaitStartedAt,
2128
+ // #1458 S3: elapsed measured from BEFORE the primary wait
2129
+ // (waitStartedAt), not just from auxWaitStartedAt — this is
2130
+ // what lets a latency row validate the ~1.3s warm-scanner
2131
+ // figure the 2000ms ceiling was set from; `elapsedMs` alone
2132
+ // only covers the POST-primary aux phase.
2133
+ elapsedSinceNotifyMs: Date.now() - waitStartedAt,
2134
+ };
2135
+ }));
2136
+ const unfinished = outcomes
2137
+ .filter((outcome) => outcome.outcome === "cut_off")
2138
+ .map((outcome) => outcome.serverId);
2139
+ if (unfinished.length > 0)
2140
+ auxCutOffServerIds = unfinished;
2141
+ logLatency({
2142
+ type: "phase",
2143
+ phase: "lsp_aux_wait_outcome",
2144
+ filePath: normalizedPath,
2145
+ durationMs: Date.now() - auxWaitStartedAt,
2146
+ metadata: { clientScope, outcomes },
2041
2147
  });
2042
- const auxAll = Promise.all(auxWaits.map((a) => a.promise)).then(() => "done");
2043
- const outcome = await Promise.race([auxAll, auxTimeout]);
2044
- if (outcome === "timeout") {
2045
- // Record which auxiliaries did NOT finish in time.
2046
- const unfinished = [];
2047
- for (const a of auxWaits) {
2048
- let done = false;
2049
- // Check synchronously if already resolved by racing against
2050
- // a resolved promise.
2051
- await Promise.race([
2052
- a.promise.then(() => {
2053
- done = true;
2054
- }),
2055
- Promise.resolve(),
2056
- ]);
2057
- if (!done)
2058
- unfinished.push(a.serverId);
2059
- }
2060
- if (unfinished.length > 0)
2061
- auxCutOffServerIds = unfinished;
2062
- }
2063
2148
  });
2064
2149
  })()
2065
2150
  : Promise.all(perServerWaits).then(() => { });
@@ -2178,6 +2263,12 @@ export class LSPService {
2178
2263
  durationMs: waitedMs,
2179
2264
  metadata: {
2180
2265
  source,
2266
+ // #1444: WHICH server(s) burned the budget — without this the
2267
+ // ~221/day timeout rows can't be attributed to a server at all.
2268
+ // `info.id` (not `client.serverId`) for the same reason the
2269
+ // degradation ledger above uses it: test doubles and lightweight
2270
+ // clients need not expose `serverId`.
2271
+ serverIds: spawned.map((e) => e.info.id),
2181
2272
  clientScope,
2182
2273
  diagnosticsMode,
2183
2274
  timeoutMs,
@@ -2269,7 +2360,10 @@ export class LSPService {
2269
2360
  let collected = options.collectDiagnostics
2270
2361
  ? tsserverSyncConfirmed !== undefined
2271
2362
  ? mergeLspDiagnostics(tsserverSyncConfirmed)
2272
- : mergeLspDiagnostics(spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)))
2363
+ : mergeLspDiagnostics([
2364
+ ...spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)),
2365
+ ...carriedAuxiliary.flatMap((entry) => entry.diags),
2366
+ ])
2273
2367
  : undefined;
2274
2368
  // #1095 (P3-b): whether `collected` came from a tsserver sync confirm
2275
2369
  // (`tsserverSyncRequest`) rather than the publish cache. A sync-confirmed
@@ -2367,7 +2461,7 @@ export class LSPService {
2367
2461
  !tsserverSyncEligible &&
2368
2462
  clientScope === "primary" &&
2369
2463
  spawned.length === 1 &&
2370
- getStrategy(spawned[0].client.serverId).silentOnClean === true) {
2464
+ getStrategy(spawned[0].client.serverId, spawned[0].client.getLaunchVariant?.()).silentOnClean === true) {
2371
2465
  try {
2372
2466
  const snapshots = await this.getCapabilitySnapshots(filePath);
2373
2467
  if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
@@ -2412,6 +2506,22 @@ export class LSPService {
2412
2506
  // findings that just hadn't arrived yet — it must not be trusted as a
2413
2507
  // confirmed answer.
2414
2508
  const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
2509
+ // #1470: an auxiliary whose push wait was CUT OFF by the aux grace timer
2510
+ // (R8/#714) contributed exactly as much evidence about this file as one that
2511
+ // went silent inside its own budget — none. A hung opengrep resolved
2512
+ // `confirmation: "confirmed"` and read as confirmed-clean on the security
2513
+ // lane. (The SILENT case still does, and is NOT addressed here: a scanner
2514
+ // that settles inside its own budget without publishing also yields an
2515
+ // unqualified confirmation. Same #533 class, same lane, different door —
2516
+ // filed as #1493, deliberately untouched by this change.)
2517
+ // This does NOT flip the touch to inconclusive: that would discard a
2518
+ // primary answer that IS trustworthy (#533 honesty doctrine cuts both ways —
2519
+ // overclaiming and underclaiming are both dishonest). Instead the confirmation
2520
+ // is NARROWED: `"partial"`, naming the servers it does not speak for, so every
2521
+ // consumer that treats confirmation as proof of coverage fails closed while
2522
+ // the primary's findings still flow.
2523
+ const unconfirmedServerIds = auxCutOffServerIds ?? [];
2524
+ const coverageGap = unconfirmedServerIds.length > 0;
2415
2525
  // #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
2416
2526
  // "actually warm" signal `ensureWarmForSweep` waits for — mark every
2417
2527
  // spawned server so a later sweep in this session sees the check as a
@@ -2422,11 +2532,27 @@ export class LSPService {
2422
2532
  // write stalled must still be eligible, so only servers whose OWN write
2423
2533
  // timed out are skipped here (rather than gating the whole loop on the
2424
2534
  // file-level `inconclusive`).
2535
+ //
2536
+ // #1470: same per-server reasoning for a CUT-OFF auxiliary. "Demonstrated
2537
+ // ready" means this server answered for this file; an auxiliary our grace
2538
+ // timer cut off demonstrably did not.
2539
+ //
2540
+ // NO TEST PINS THIS LINE, and that is a property of today's readers rather
2541
+ // than a coverage gap: `ensureWarmForSweep` filters `role === "auxiliary"`
2542
+ // out of its server list entirely, so no reader consumes an auxiliary's
2543
+ // `demonstratedReady` mark and deleting this `continue` changes no observable
2544
+ // behavior (verified by mutation — the LSP suite stays green). It stays
2545
+ // because the mark's meaning is "this server answered", and the moment any
2546
+ // reader stops filtering auxiliaries out, marking a cut-off scanner warm
2547
+ // would let it skip a warm-up it never earned.
2425
2548
  const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
2549
+ const cutOffServerIds = new Set(unconfirmedServerIds);
2426
2550
  if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
2427
2551
  for (const entry of spawned) {
2428
2552
  if (notifyTimedOutServerIds.has(entry.info.id))
2429
2553
  continue;
2554
+ if (cutOffServerIds.has(entry.info.id))
2555
+ continue;
2430
2556
  const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
2431
2557
  if (key)
2432
2558
  this.markDemonstratedReadyKey(key);
@@ -2441,7 +2567,14 @@ export class LSPService {
2441
2567
  // empty `collected` must never erase a previously-confirmed non-empty
2442
2568
  // record (that's the #570 bug — a timeout silently reporting as clean
2443
2569
  // and wiping out known-good diagnostic state).
2444
- if (collected !== undefined && !inconclusive) {
2570
+ // #1470: a PARTIAL touch is the same hazard wearing a different flag. Its
2571
+ // merged array is missing whatever the cut-off auxiliary would have said, so
2572
+ // priming the cache with it would let `actionable-warnings`' hash-guarded
2573
+ // read replay a partially-covered result as an authoritative observation —
2574
+ // and an empty one would DELETE a previously-confirmed record on the strength
2575
+ // of a scanner that never answered. Skip the prime; the next read pays a real
2576
+ // round trip instead of trusting an incomplete one.
2577
+ if (collected !== undefined && !inconclusive && !coverageGap) {
2445
2578
  const normalizedKey = normalizeMapKey(filePath);
2446
2579
  if (collected.length > 0) {
2447
2580
  this.lastKnownDiagnostics.set(normalizedKey, collected);
@@ -2465,6 +2598,14 @@ export class LSPService {
2465
2598
  if (collected !== undefined && inconclusive) {
2466
2599
  result.inconclusive = true;
2467
2600
  }
2601
+ else if (collected !== undefined && coverageGap) {
2602
+ // #1470: narrowed, not collapsed. The primary's findings ride along in
2603
+ // `.diags` exactly as before; what changes is that the touch now states
2604
+ // which servers it does not speak for, so no consumer can read this as a
2605
+ // full clean bill of health.
2606
+ result.confirmation = "partial";
2607
+ result.unconfirmedServerIds = [...unconfirmedServerIds];
2608
+ }
2468
2609
  else if (collected !== undefined) {
2469
2610
  // Preserve the lower-level affirmative result across consumers. In
2470
2611
  // particular, the silent-clean gates above clear diagnosticsTimedOut only
@@ -2488,7 +2629,10 @@ export class LSPService {
2488
2629
  // Optional-chain so a client without the getter (test doubles, a
2489
2630
  // partially-mocked client) yields "unknown" rather than throwing —
2490
2631
  // unknown preserves pre-#1095 behavior for that contributor.
2491
- spawned.map((entry) => entry.client.getDiagnosticBinding?.(filePath)));
2632
+ [
2633
+ ...spawned.map((entry) => entry.client.getDiagnosticBinding?.(filePath)),
2634
+ ...carriedAuxiliary.map((entry) => entry.binding),
2635
+ ]);
2492
2636
  result.binding = binding;
2493
2637
  }
2494
2638
  // The recent-touches entries are recorded per server inside the notify-write
@@ -2527,6 +2671,13 @@ export class LSPService {
2527
2671
  }),
2528
2672
  diagnosticsTimedOut,
2529
2673
  inconclusive,
2674
+ // #1470: the touch's own honesty verdict, so a `cut_off` row in
2675
+ // `lsp_aux_wait_outcome` can be joined to the touch that produced it and
2676
+ // shown NOT to have claimed confirmation for that server's coverage.
2677
+ // Absent for a non-collecting touch, which claims nothing either way.
2678
+ ...(result.confirmation !== undefined && {
2679
+ confirmation: result.confirmation,
2680
+ }),
2530
2681
  // R8 (#714): server ids of auxiliaries whose push wait was cut off by
2531
2682
  // the aux grace window (primary settled clean + aux timed out in grace).
2532
2683
  // Absent when no aux was cut off. These servers' diagnostics are
@@ -2624,7 +2775,8 @@ export class LSPService {
2624
2775
  }
2625
2776
  const clientWaits = spawned.map(async (entry) => {
2626
2777
  const waitStart = Date.now();
2627
- const strategy = getStrategy(entry.info.id);
2778
+ const launchVariant = entry.client.getLaunchVariant?.();
2779
+ const strategy = getStrategy(entry.info.id, launchVariant);
2628
2780
  await entry.client.waitForDiagnostics(filePath, strategy.aggregateWaitMs);
2629
2781
  let diagnostics = entry.client.getDiagnostics(filePath);
2630
2782
  const firstWaitMs = Date.now() - waitStart;
@@ -2636,6 +2788,7 @@ export class LSPService {
2636
2788
  }
2637
2789
  return {
2638
2790
  serverId: entry.info.id,
2791
+ launchVariant,
2639
2792
  waitMs: Date.now() - waitStart,
2640
2793
  diagnosticCount: diagnostics.length,
2641
2794
  diagnostics,
@@ -2644,25 +2797,48 @@ export class LSPService {
2644
2797
  // Document mode: 0ms grace — return as soon as any client has results.
2645
2798
  // Full mode: 400ms grace — wait a bit for other clients to catch up.
2646
2799
  const graceMs = diagnosticsMode === "document" ? 0 : EARLY_UNBLOCK_GRACE_MS;
2647
- // R8 (#714): per-promise role descriptors so raceToCompletion can apply
2648
- // a bounded aux grace once all primary-role promises have settled.
2649
- // Servers with role:"auxiliary" (opengrep, ast-grep, zizmor, …) get at
2650
- // most PI_LENS_AUX_GRACE_MS (default 500ms) after the primary settles;
2651
- // late arrivals are dropped (advisory only — they land in the client
2800
+ // R8 (#714) / #1458 S2: per-promise role descriptors so raceToCompletion
2801
+ // can apply a bounded aux grace once all primary-role promises have
2802
+ // settled. Servers with role:"auxiliary" (opengrep, ast-grep, zizmor, …)
2803
+ // get their OWN declared `aggregateWaitMs` budget after the primary
2804
+ // settles, capped by the PI_LENS_AUX_GRACE_MS global ceiling (default
2805
+ // 2000ms) — the same "declared budget, capped by a ceiling" shape
2806
+ // `touchFile`'s with-auxiliary push wait uses, so this lane can no longer
2807
+ // starve a scanner whose measured warm run (e.g. opengrep ~1.3s) is
2808
+ // shorter than the ceiling but longer than a flat short grace. Late
2809
+ // arrivals are still dropped (advisory only — they land in the client
2652
2810
  // cache and surface on the next edit). Primary-only callers have no
2653
2811
  // auxiliary descriptors, so this path is never entered and there is
2654
2812
  // zero behavior change for the single-server hot path.
2655
- const diagDescriptors = spawned.map((entry) => ({
2656
- role: entry.info.role === "auxiliary" ? "auxiliary" : "primary",
2657
- }));
2813
+ const diagDescriptors = spawned.map((entry) => {
2814
+ if (entry.info.role !== "auxiliary")
2815
+ return { role: "primary" };
2816
+ const strategy = getStrategy(entry.info.id, entry.client.getLaunchVariant?.());
2817
+ // Deleting this `budgetMs` fails no end-to-end test, and that is
2818
+ // expected rather than a coverage gap: every promise in `clientWaits`
2819
+ // already self-bounds at this same `strategy.aggregateWaitMs`, so an
2820
+ // auxiliary cuts itself off at its declared budget whether or not the
2821
+ // shared grace timer also knows about it. The descriptor is what keeps
2822
+ // the two agreeing — it is the mitigation for the over-granting
2823
+ // `raceToCompletion` documents, and it starts mattering the moment a
2824
+ // promise here stops self-bounding. The narrowing itself is pinned in
2825
+ // tests/clients/lsp/aggregation.test.ts, which can build the
2826
+ // non-self-bounded promises this path cannot.
2827
+ return { role: "auxiliary", budgetMs: strategy.aggregateWaitMs };
2828
+ });
2658
2829
  // Result-aware racing: trigger early-unblock when any client has results,
2659
2830
  // OR when a seedFirstPush server returns (its first push is authoritative
2660
2831
  // even when empty — waiting longer yields nothing more).
2661
- const perServer = await raceToCompletion(clientWaits, (results) => results.some((r) => r.diagnosticCount > 0 || getStrategy(r.serverId).seedFirstPush), {
2662
- timeoutMs: Math.max(...spawned.map((entry) => getStrategy(entry.info.id).aggregateWaitMs)),
2832
+ const perServer = await raceToCompletion(clientWaits, (results) => results.some((r) => r.diagnosticCount > 0 ||
2833
+ getStrategy(r.serverId, r.launchVariant).seedFirstPush), {
2834
+ timeoutMs: Math.max(...spawned.map((entry) => getStrategy(entry.info.id, entry.client.getLaunchVariant?.())
2835
+ .aggregateWaitMs)),
2663
2836
  graceMs,
2664
2837
  descriptors: diagDescriptors,
2665
- auxGraceMs: readEnvAuxGraceMs(),
2838
+ // #1458 S2: ceiling, not a flat wait — each auxiliary's own
2839
+ // budgetMs (above) determines the actual per-touch grace up to
2840
+ // this cap. Matches touchFile's `readEnvAuxGraceMs() ?? 2000`.
2841
+ auxGraceMs: readEnvAuxGraceMs() ?? 2000,
2666
2842
  });
2667
2843
  // Fill in any slots that timed out before producing results.
2668
2844
  const earlyUnblockedCount = spawned.length - perServer.length;
@@ -2670,7 +2846,8 @@ export class LSPService {
2670
2846
  const found = perServer.find((r) => r.serverId === entry.info.id);
2671
2847
  return (found ?? {
2672
2848
  serverId: entry.info.id,
2673
- waitMs: getStrategy(entry.info.id).aggregateWaitMs,
2849
+ launchVariant: entry.client.getLaunchVariant?.(),
2850
+ waitMs: getStrategy(entry.info.id, entry.client.getLaunchVariant?.()).aggregateWaitMs,
2674
2851
  diagnosticCount: 0,
2675
2852
  diagnostics: [],
2676
2853
  });
@@ -3745,7 +3922,17 @@ export class LSPService {
3745
3922
  // answer, so `available` implies confirmed) — wrap it as `{ diags }`;
3746
3923
  // the incumbent branch already returns the wrapper.
3747
3924
  const touchResult = attached?.available
3748
- ? { diags: attached.response.diagnostics }
3925
+ ? {
3926
+ diags: attached.response.diagnostics,
3927
+ // #1470: the incumbent's coverage gap crosses the socket as an
3928
+ // explicit DTO field, so carry it onto the wrapper the sweep
3929
+ // reads. Dropping it here would let a partially covered
3930
+ // incumbent answer be persisted as a confirmed sweep result —
3931
+ // the same false clean this change closes on the local route.
3932
+ ...(attached.response.unconfirmedServerIds !== undefined && {
3933
+ unconfirmedServerIds: attached.response.unconfirmedServerIds,
3934
+ }),
3935
+ }
3749
3936
  : await withDeadline(this.touchFile(filePath, content, {
3750
3937
  diagnostics: "document",
3751
3938
  collectDiagnostics: true,
@@ -3767,7 +3954,18 @@ export class LSPService {
3767
3954
  // `perFileMs` deadline, which only catches a touch that never returned at
3768
3955
  // all within budget. Either one means the result wasn't confirmed.
3769
3956
  const inconclusive = touchResult?.inconclusive === true;
3770
- const timedOut = touchResult === undefined || inconclusive;
3957
+ // #1470: a cut-off auxiliary is the THIRD reason this result is not a
3958
+ // confirmed observation, and it is deliberately not `inconclusive`. The
3959
+ // record loop below persists every `!timedOut` result into the workspace
3960
+ // cache, so reading `inconclusive` alone caches a partially covered
3961
+ // answer as clean and replays it on every later sweep. Today the only
3962
+ // route that can reach this branch with a gap is the warm-attach
3963
+ // incumbent, whose touch runs `clientScope: "with-auxiliary"`; the
3964
+ // sweep's own local touch uses `clientScope: "all"`, which never arms
3965
+ // the aux grace timer at all. Both are gated here so a future scope
3966
+ // change cannot reopen the hole silently.
3967
+ const coverageGap = touchCoverageGap(touchResult).length > 0;
3968
+ const timedOut = touchResult === undefined || inconclusive || coverageGap;
3771
3969
  if (timedOut)
3772
3970
  timedOutFiles += 1;
3773
3971
  // #1104 (shape 5 — AGENTS.md): the touch's content binding is an