@herbertgao/pi-extensions 2026.9.1 → 2026.9.2

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 (166) hide show
  1. package/node_modules/@narumitw/pi-btw/README.md +57 -141
  2. package/node_modules/@narumitw/pi-btw/dist/index.ts +466 -41
  3. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  4. package/node_modules/@narumitw/pi-btw/docs/workflows.md +74 -0
  5. package/node_modules/@narumitw/pi-btw/package.json +2 -1
  6. package/node_modules/@narumitw/pi-btw/src/btw.ts +15 -1
  7. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +32 -2
  8. package/node_modules/@narumitw/pi-btw/src/keybindings.ts +330 -0
  9. package/node_modules/@narumitw/pi-btw/src/menu.ts +136 -19
  10. package/node_modules/@narumitw/pi-btw/src/settings.ts +32 -0
  11. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +57 -5
  12. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +52 -30
  13. package/node_modules/pi-lens/CHANGELOG.md +84 -0
  14. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +0 -7
  15. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +1 -1
  16. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +1 -1
  17. package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +0 -7
  18. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +2 -0
  19. package/node_modules/pi-lens/dist/clients/bash-file-access.js +133 -1
  20. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +0 -6
  21. package/node_modules/pi-lens/dist/clients/bootstrap.js +4 -4
  22. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +1 -1
  23. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +0 -5
  24. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +1 -9
  25. package/node_modules/pi-lens/dist/clients/cache-observability.js +1 -6
  26. package/node_modules/pi-lens/dist/clients/cargo-manifest.js +2 -2
  27. package/node_modules/pi-lens/dist/clients/cascade-logger.js +0 -7
  28. package/node_modules/pi-lens/dist/clients/child-unref.js +1 -19
  29. package/node_modules/pi-lens/dist/clients/config-core/index.js +11 -8
  30. package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +1 -1
  31. package/node_modules/pi-lens/dist/clients/config-core/provenance.js +2 -9
  32. package/node_modules/pi-lens/dist/clients/config-core/records.js +1 -1
  33. package/node_modules/pi-lens/dist/clients/config-core/schema.js +3 -5
  34. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +1 -1
  35. package/node_modules/pi-lens/dist/clients/config-resolve.js +4 -4
  36. package/node_modules/pi-lens/dist/clients/config-schema.js +39 -2
  37. package/node_modules/pi-lens/dist/clients/dead-code-logger.js +0 -4
  38. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +1 -1
  39. package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +1 -1
  40. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +0 -4
  41. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +0 -26
  42. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +18 -1
  43. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +0 -6
  44. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +1 -1
  45. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +0 -3
  46. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-complexity.js +2 -2
  47. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-fan-out.js +1 -1
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +3 -1
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +4 -0
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +1 -1
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +1 -0
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +6 -2
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +1 -1
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +1 -1
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +4 -2
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +1 -64
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils.js +0 -21
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +2 -20
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +1 -0
  60. package/node_modules/pi-lens/dist/clients/dispatch/utils/format-utils.js +2 -2
  61. package/node_modules/pi-lens/dist/clients/disposition-logger.js +0 -7
  62. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +2 -2
  63. package/node_modules/pi-lens/dist/clients/file-kinds.js +1 -38
  64. package/node_modules/pi-lens/dist/clients/file-time.js +1 -1
  65. package/node_modules/pi-lens/dist/clients/file-utils.js +5 -1
  66. package/node_modules/pi-lens/dist/clients/format-service.js +0 -1
  67. package/node_modules/pi-lens/dist/clients/formatters.js +110 -3
  68. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +3 -4
  69. package/node_modules/pi-lens/dist/clients/git-guard.js +5 -0
  70. package/node_modules/pi-lens/dist/clients/grammar-source.js +3 -3
  71. package/node_modules/pi-lens/dist/clients/installer/index.js +220 -9
  72. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +84 -18
  73. package/node_modules/pi-lens/dist/clients/instance-reaper.js +6 -6
  74. package/node_modules/pi-lens/dist/clients/language-profile.js +0 -9
  75. package/node_modules/pi-lens/dist/clients/language-registry.js +5 -1
  76. package/node_modules/pi-lens/dist/clients/latency-logger.js +5 -7
  77. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -1
  78. package/node_modules/pi-lens/dist/clients/lens-config.js +19 -0
  79. package/node_modules/pi-lens/dist/clients/lens-engine.js +3 -4
  80. package/node_modules/pi-lens/dist/clients/lens-events.js +1 -1
  81. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +18 -0
  82. package/node_modules/pi-lens/dist/clients/lens-map.js +1 -1
  83. package/node_modules/pi-lens/dist/clients/log-cleanup.js +0 -36
  84. package/node_modules/pi-lens/dist/clients/lsp/client.js +4 -3
  85. package/node_modules/pi-lens/dist/clients/lsp/config.js +1 -0
  86. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +1 -1
  87. package/node_modules/pi-lens/dist/clients/lsp/edits.js +3 -3
  88. package/node_modules/pi-lens/dist/clients/lsp/index.js +216 -25
  89. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +1 -1
  90. package/node_modules/pi-lens/dist/clients/lsp/language.js +0 -8
  91. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  92. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +0 -2
  93. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +0 -1
  94. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +5 -5
  95. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +6 -0
  96. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +2 -1
  97. package/node_modules/pi-lens/dist/clients/lsp-document-symbols.js +1 -1
  98. package/node_modules/pi-lens/dist/clients/metrics-history.js +28 -107
  99. package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +1 -1
  100. package/node_modules/pi-lens/dist/clients/module-report.js +1 -1
  101. package/node_modules/pi-lens/dist/clients/mutating-tool.js +3 -3
  102. package/node_modules/pi-lens/dist/clients/mutation-attribution.js +3 -3
  103. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +0 -4
  104. package/node_modules/pi-lens/dist/clients/observed-mutation.js +9 -9
  105. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +2 -6
  106. package/node_modules/pi-lens/dist/clients/opengrep-config.js +1 -1
  107. package/node_modules/pi-lens/dist/clients/path-utils.js +23 -2
  108. package/node_modules/pi-lens/dist/clients/performance-report.js +2 -2
  109. package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +1 -1
  110. package/node_modules/pi-lens/dist/clients/pipeline.js +2 -2
  111. package/node_modules/pi-lens/dist/clients/project-changes.js +1 -1
  112. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +1 -1
  113. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/jscpd.js +1 -1
  114. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/knip.js +1 -1
  115. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +1 -1
  116. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/opengrep.js +1 -1
  117. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +1 -1
  118. package/node_modules/pi-lens/dist/clients/project-lens-config.js +46 -1
  119. package/node_modules/pi-lens/dist/clients/project-scan-policy.js +3 -51
  120. package/node_modules/pi-lens/dist/clients/python-provenance.js +1 -1
  121. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +1 -1
  122. package/node_modules/pi-lens/dist/clients/read-guard.js +5 -2
  123. package/node_modules/pi-lens/dist/clients/recent-touches.js +2 -2
  124. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +3 -5
  125. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +1 -1
  126. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +1 -1
  127. package/node_modules/pi-lens/dist/clients/runtime-session.js +45 -10
  128. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +11 -1
  129. package/node_modules/pi-lens/dist/clients/sanitize.js +0 -11
  130. package/node_modules/pi-lens/dist/clients/scan-utils.js +1 -56
  131. package/node_modules/pi-lens/dist/clients/session-summary.js +0 -24
  132. package/node_modules/pi-lens/dist/clients/sgconfig.js +1 -5
  133. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +1 -1
  134. package/node_modules/pi-lens/dist/clients/smells-rollup.js +2 -2
  135. package/node_modules/pi-lens/dist/clients/startup-scan.js +2 -10
  136. package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +77 -72
  137. package/node_modules/pi-lens/dist/clients/tool-policy.js +1 -1
  138. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +1 -52
  139. package/node_modules/pi-lens/dist/clients/tui-fit.js +0 -4
  140. package/node_modules/pi-lens/dist/clients/user-notify.js +0 -4
  141. package/node_modules/pi-lens/dist/clients/word-index-logger.js +0 -7
  142. package/node_modules/pi-lens/dist/clients/word-index-store.js +3 -7
  143. package/node_modules/pi-lens/dist/clients/word-index.js +9 -16
  144. package/node_modules/pi-lens/dist/clients/zizmor-config.js +1 -1
  145. package/node_modules/pi-lens/dist/index.js +1062 -538
  146. package/node_modules/pi-lens/dist/mcp/build-staleness.js +1 -1
  147. package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +1 -1
  148. package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +1 -1
  149. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +1 -1
  150. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +37 -6
  151. package/node_modules/pi-lens/dist/tools/lsp-structured-output.js +3 -3
  152. package/node_modules/pi-lens/dist/tools/render-compact.js +1 -1
  153. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -2
  154. package/node_modules/pi-lens/docs/globalconfig.md +20 -0
  155. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +9 -1
  156. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +6 -0
  157. package/node_modules/pi-lens/docs/pi-lens-subagent.md +12 -1
  158. package/node_modules/pi-lens/docs/release-qa-baseline.md +12 -4
  159. package/node_modules/pi-lens/docs/servercapabilities.md +7 -3
  160. package/node_modules/pi-lens/docs/word-index.md +1 -1
  161. package/node_modules/pi-lens/package.json +6 -15
  162. package/node_modules/pi-lens/rules/typos/_typos.toml +4 -2
  163. package/node_modules/pi-lens/scripts/lib/skills-predicate.mjs +1 -1
  164. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +1 -1
  165. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +4 -2
  166. package/package.json +3 -3
@@ -25,6 +25,7 @@ import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReaso
25
25
  import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
26
26
  import { sampleProcessTreeCpuPercent } from "../resource-sampler.js";
27
27
  import { bounded, withDeadline, withTimeout } from "../deadline-utils.js";
28
+ import { HOOK_WALL_BUDGET_MS } from "../hook-budgets.js";
28
29
  import { getAmbientAbortSignal } from "../safe-spawn.js";
29
30
  import { abortDeferredLspWork } from "../deferred-lsp-work.js";
30
31
  import { acquireWorkspaceSweepHold, clearWorkspaceSweepHoldForSessionStart, } from "./workspace-sweep-hold.js";
@@ -646,6 +647,12 @@ export class LSPService {
646
647
  /** Server/root pairs that already emitted unavailable for the current occurrence. */
647
648
  unavailableLogged = new Set();
648
649
  optionalDisabled = new Set();
650
+ /**
651
+ * One first-contact diagnostic wait per custom server in this service
652
+ * generation. Concurrent touches share the probe; a hook cutoff never arms
653
+ * the navigation-only latch, so the next touch can try again.
654
+ */
655
+ firstContactWaits = new Map();
649
656
  /**
650
657
  * #1934 review F1: what the last COMPLETED `spawnClient` call for a
651
658
  * (server, root) key decided, written by that call at every point it
@@ -833,6 +840,8 @@ export class LSPService {
833
840
  clientSpawnGate = Promise.resolve();
834
841
  /** True after shutdown() has been called; blocks new operations */
835
842
  isDestroyed = false;
843
+ /** Aborts bounded in-flight service work when this generation is retired. */
844
+ shutdownController = new AbortController();
836
845
  /**
837
846
  * #850: teardown completion for every singleton generation retired before
838
847
  * this service was published. Only replacement services receive one; direct
@@ -852,6 +861,8 @@ export class LSPService {
852
861
  clientSpawnedAt: new Map(),
853
862
  demonstratedReady: new Set(),
854
863
  demonstratedCold: new Set(),
864
+ diagnosticsPublished: new Set(),
865
+ diagnosticsUnsupported: new Set(),
855
866
  };
856
867
  }
857
868
  /**
@@ -3117,6 +3128,12 @@ export class LSPService {
3117
3128
  }
3118
3129
  const override = getServerInitOverride(server.id, filePath);
3119
3130
  const mergedInit = mergeInitializationOptions(spawned.initialization, override?.initializationOptions);
3131
+ // A replacement process is a new first-contact identity even when the
3132
+ // configured server id is unchanged. Re-arm both capability latches before
3133
+ // the replacement can publish a snapshot.
3134
+ this.state.diagnosticsPublished.delete(server.id);
3135
+ this.state.diagnosticsUnsupported.delete(server.id);
3136
+ this.firstContactWaits.delete(server.id);
3120
3137
  const client = await createLSPClient({
3121
3138
  serverId: server.id,
3122
3139
  process: spawned.process,
@@ -3125,6 +3142,9 @@ export class LSPService {
3125
3142
  initialization: mergedInit,
3126
3143
  initializeTimeoutMs: server.initializeTimeoutMs,
3127
3144
  launchVariant: spawned.launchVariant,
3145
+ onDiagnosticsPublished: (serverId) => {
3146
+ this.state.diagnosticsPublished.add(serverId);
3147
+ },
3128
3148
  });
3129
3149
  // Guard 2: service was shut down while we were completing the initialize
3130
3150
  // handshake. Shut down the live client best-effort and do not register it.
@@ -3286,6 +3306,11 @@ export class LSPService {
3286
3306
  return;
3287
3307
  }
3288
3308
  const startedAt = Date.now();
3309
+ const hookDeadlineAt = options.hook !== undefined &&
3310
+ Object.hasOwn(HOOK_WALL_BUDGET_MS, options.hook)
3311
+ ? startedAt +
3312
+ HOOK_WALL_BUDGET_MS[options.hook]
3313
+ : undefined;
3289
3314
  const normalizedPath = normalizeMapKey(filePath);
3290
3315
  const outsideRoot = await this.findOutsideProjectRoot(filePath);
3291
3316
  if (outsideRoot) {
@@ -3394,6 +3419,19 @@ export class LSPService {
3394
3419
  if (!leaseKeys) {
3395
3420
  // An idle eviction won between selection and lease admission. Resolve the
3396
3421
  // now-current client set and replay; no notification targets the retiree.
3422
+ if (this.checkDestroyed()) {
3423
+ const primaryServerIds = spawned
3424
+ .filter((entry) => entry.info.role !== "auxiliary")
3425
+ .map((entry) => entry.info.id);
3426
+ return {
3427
+ diags: [],
3428
+ inconclusive: true,
3429
+ ...(primaryServerIds.length > 0 && {
3430
+ inconclusiveServerIds: primaryServerIds,
3431
+ }),
3432
+ inconclusiveReason: "diagnostics-wait",
3433
+ };
3434
+ }
3397
3435
  return this.touchFile(filePath, content, options);
3398
3436
  }
3399
3437
  try {
@@ -3791,6 +3829,7 @@ export class LSPService {
3791
3829
  // fallback below may still fill it in on a timed-out empty result).
3792
3830
  let tsserverSyncEligible = false;
3793
3831
  let tsserverSyncConfirmed;
3832
+ let diagnosticsUnsupportedServerIds = [];
3794
3833
  if (diagnosticsMode !== "none") {
3795
3834
  // Resolution: env wins so users can tune the cap without rebuilding.
3796
3835
  // Otherwise, on the single-server hot path (primary scope), use that
@@ -3861,6 +3900,9 @@ export class LSPService {
3861
3900
  root: entry.client.root,
3862
3901
  operationSupport: entry.client.getOperationSupport(),
3863
3902
  workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
3903
+ customServer: entry.info.custom,
3904
+ diagnosticsPublished: this.state.diagnosticsPublished.has(entry.info.id),
3905
+ diagnosticsUnsupported: this.state.diagnosticsUnsupported.has(entry.info.id),
3864
3906
  advertisedCommands: entry.client.getAdvertisedCommands(),
3865
3907
  rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
3866
3908
  launchVariant: entry.client.getLaunchVariant?.(),
@@ -4004,6 +4046,29 @@ export class LSPService {
4004
4046
  const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
4005
4047
  options.collectDiagnostics === true &&
4006
4048
  spawned.some((e) => e.info.role === "auxiliary");
4049
+ if (spawned.some((entry) => entry.info.custom === true)) {
4050
+ try {
4051
+ const snapshotHook = options.hook ?? "tool_result_edit";
4052
+ const snapshotBudget = hookDeadlineAt === undefined
4053
+ ? HOOK_WALL_BUDGET_MS.tool_result_edit
4054
+ : Math.max(0, hookDeadlineAt - Date.now());
4055
+ const snapshots = await bounded(this.getCapabilitySnapshots(filePath), {
4056
+ ms: snapshotBudget,
4057
+ signal: getAmbientAbortSignal(),
4058
+ shutdownSignal: this.shutdownController.signal,
4059
+ hook: snapshotHook,
4060
+ label: `getCapabilitySnapshots:${filePath}`,
4061
+ });
4062
+ if (snapshots !== undefined) {
4063
+ diagnosticsUnsupportedServerIds = spawned
4064
+ .filter((entry) => classifyServerWaitTier(entry.info.id, snapshots.find((s) => s.serverId === entry.info.id)) === "diagnostics-unsupported")
4065
+ .map((entry) => entry.info.id);
4066
+ }
4067
+ }
4068
+ catch {
4069
+ // Capability uncertainty fails closed and retains the wait.
4070
+ }
4071
+ }
4007
4072
  // Per-server wait promises (each already bounded by its own
4008
4073
  // perServerTimeout — unchanged from before R8).
4009
4074
  let pressureSnapshots = [];
@@ -4028,12 +4093,33 @@ export class LSPService {
4028
4093
  ? auxWaitBudgetMs(entry.client.serverId, true, configuredAuxCeilingMs, declaredServerTimeout)
4029
4094
  : perServerDeclaredTimeouts[entryIndex];
4030
4095
  });
4096
+ const firstContactCutOffServerIds = new Set();
4097
+ const firstContactErroredServerIds = new Set();
4098
+ const answeredForThisTouch = (entry) => {
4099
+ const baseline = diagnosticBaselines.get(entry.client);
4100
+ const currentPathVersion = readPathVersion(entry.client);
4101
+ if (Number.isFinite(baseline) &&
4102
+ currentPathVersion !== undefined &&
4103
+ currentPathVersion > baseline) {
4104
+ return true;
4105
+ }
4106
+ try {
4107
+ return (entry.client.getAllDiagnostics?.().has(normalizedPath) === true);
4108
+ }
4109
+ catch {
4110
+ return false;
4111
+ }
4112
+ };
4031
4113
  const perServerRaceBudgets = spawned.map((entry, entryIndex) => {
4032
4114
  return hasTouchAuxiliaries && entry.info.role === "auxiliary"
4033
4115
  ? auxWaitBudgetMs(entry.client.serverId, coldAuxiliaryServerIds.has(entry.client.serverId), configuredAuxCeilingMs, perServerDeclaredTimeouts[entryIndex])
4034
4116
  : perServerDeclaredTimeouts[entryIndex];
4035
4117
  });
4036
4118
  const perServerWaits = spawned.map((entry, entryIndex) => {
4119
+ if (this.state.diagnosticsUnsupported.has(entry.info.id) ||
4120
+ diagnosticsUnsupportedServerIds.includes(entry.info.id)) {
4121
+ return Promise.resolve(undefined);
4122
+ }
4037
4123
  // #1459: a DEFERRED server never received this content, so its version
4038
4124
  // can never advance past the baseline — waiting on it burns its whole
4039
4125
  // budget and would flip the touch to `inconclusive`, discarding a
@@ -4063,7 +4149,7 @@ export class LSPService {
4063
4149
  const isWarmupTouch = source === "lsp_sweep_warmup";
4064
4150
  // #743: per-server — a server we DID push to still gets the
4065
4151
  // version-baseline wait even when a sibling was debounced away.
4066
- const wait = !notifySkippedServerIds.has(entry.info.id) &&
4152
+ const waitForDiagnostics = () => !notifySkippedServerIds.has(entry.info.id) &&
4067
4153
  Number.isFinite(baseline)
4068
4154
  ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
4069
4155
  minVersion: baseline,
@@ -4080,7 +4166,73 @@ export class LSPService {
4080
4166
  pullSettleSource: "pull-warmup",
4081
4167
  })
4082
4168
  : entry.client.waitForDiagnostics(filePath, serverTimeout);
4083
- return wait.catch(() => undefined);
4169
+ const firstContact = entry.info.custom === true &&
4170
+ !this.state.diagnosticsPublished.has(entry.info.id) &&
4171
+ !this.state.diagnosticsUnsupported.has(entry.info.id) &&
4172
+ entry.client.getWorkspaceDiagnosticsSupport().mode !== "pull";
4173
+ if (!firstContact)
4174
+ return waitForDiagnostics().catch(() => undefined);
4175
+ let shared = this.firstContactWaits.get(entry.info.id);
4176
+ if (!shared) {
4177
+ let removeShutdownListener;
4178
+ const shutdown = new Promise((resolve) => {
4179
+ const onShutdown = () => resolve("shutdown");
4180
+ if (this.shutdownController.signal.aborted) {
4181
+ onShutdown();
4182
+ return;
4183
+ }
4184
+ this.shutdownController.signal.addEventListener("abort", onShutdown, {
4185
+ once: true,
4186
+ });
4187
+ removeShutdownListener = () => this.shutdownController.signal.removeEventListener("abort", onShutdown);
4188
+ });
4189
+ const probe = Promise.race([
4190
+ waitForDiagnostics().then(() => "completed", () => "errored"),
4191
+ shutdown,
4192
+ ]);
4193
+ const completed = withDeadline(probe, {
4194
+ ms: serverTimeout,
4195
+ onTimeout: "undefined",
4196
+ });
4197
+ shared = completed
4198
+ .then((outcome) => {
4199
+ if (outcome === "errored" || outcome === "shutdown") {
4200
+ return "errored";
4201
+ }
4202
+ if (answeredForThisTouch(entry))
4203
+ return "answered";
4204
+ this.state.diagnosticsUnsupported.add(entry.info.id);
4205
+ recordDegradationOnce({
4206
+ kind: "lsp-diagnostics-unsupported",
4207
+ subject: entry.info.id,
4208
+ reason: "custom server has no pull provider and no publish evidence after first contact wait",
4209
+ });
4210
+ return "silent";
4211
+ })
4212
+ .catch(() => "errored")
4213
+ .finally(() => {
4214
+ removeShutdownListener?.();
4215
+ if (this.firstContactWaits.get(entry.info.id) === shared) {
4216
+ this.firstContactWaits.delete(entry.info.id);
4217
+ }
4218
+ });
4219
+ this.firstContactWaits.set(entry.info.id, shared);
4220
+ }
4221
+ const callerWait = hookDeadlineAt === undefined
4222
+ ? shared
4223
+ : withDeadline(shared, {
4224
+ ms: Math.max(0, hookDeadlineAt - Date.now()),
4225
+ onTimeout: "undefined",
4226
+ });
4227
+ return callerWait.then((outcome) => {
4228
+ if (outcome === undefined) {
4229
+ firstContactCutOffServerIds.add(entry.info.id);
4230
+ }
4231
+ else if (outcome === "errored") {
4232
+ firstContactErroredServerIds.add(entry.info.id);
4233
+ }
4234
+ return undefined;
4235
+ });
4084
4236
  });
4085
4237
  // The push wait — same per-server budget composition as before #707;
4086
4238
  // only the awaiting changed (assigned so it can be raced below).
@@ -4257,9 +4409,14 @@ export class LSPService {
4257
4409
  });
4258
4410
  })()
4259
4411
  : Promise.all(perServerWaits).then(() => { });
4260
- pushWait.then(() => {
4412
+ // Mark on BOTH paths: a rejected `pushWait` is settled too, and a
4413
+ // resolve-only `.then` left a derived promise that rejected unhandled
4414
+ // whenever a per-server wait failed (oxlint no-floating-promises,
4415
+ // 2026-09-07). The rejection itself still reaches the awaiters below.
4416
+ const markPushWaitSettled = () => {
4261
4417
  pushWaitSettled = true;
4262
- });
4418
+ };
4419
+ void pushWait.then(markPushWaitSettled, markPushWaitSettled);
4263
4420
  if (tsserverSyncEligible) {
4264
4421
  // #707 racing variant: rather than burning the full push-wait budget
4265
4422
  // on a silent-on-clean server (which by definition never answers on a
@@ -4331,6 +4488,35 @@ export class LSPService {
4331
4488
  await pushWait;
4332
4489
  }
4333
4490
  const waitedMs = Date.now() - waitStartedAt;
4491
+ const firstContactCustomServerIds = spawned
4492
+ .filter((entry) => entry.info.custom === true &&
4493
+ !this.state.diagnosticsPublished.has(entry.info.id) &&
4494
+ !this.state.diagnosticsUnsupported.has(entry.info.id) &&
4495
+ !firstContactCutOffServerIds.has(entry.info.id) &&
4496
+ !firstContactErroredServerIds.has(entry.info.id) &&
4497
+ entry.client.getWorkspaceDiagnosticsSupport().mode !== "pull")
4498
+ .map((entry) => entry.info.id);
4499
+ for (const serverId of firstContactCustomServerIds) {
4500
+ const entry = spawned.find((candidate) => candidate.info.id === serverId);
4501
+ if (!entry || answeredForThisTouch(entry))
4502
+ continue;
4503
+ this.state.diagnosticsUnsupported.add(serverId);
4504
+ diagnosticsUnsupportedServerIds.push(serverId);
4505
+ recordDegradationOnce({
4506
+ kind: "lsp-diagnostics-unsupported",
4507
+ subject: serverId,
4508
+ reason: "custom server has no pull provider and no publish evidence after first contact wait",
4509
+ });
4510
+ }
4511
+ for (const entry of spawned) {
4512
+ if (entry.info.custom === true &&
4513
+ this.state.diagnosticsUnsupported.has(entry.info.id)) {
4514
+ diagnosticsUnsupportedServerIds.push(entry.info.id);
4515
+ }
4516
+ }
4517
+ diagnosticsUnsupportedServerIds = [
4518
+ ...new Set(diagnosticsUnsupportedServerIds),
4519
+ ];
4334
4520
  // #1533: the same auxiliary coverage evidence for a collecting touch that
4335
4521
  // did NOT enter the aux-grace wait — in practice `clientScope: "all"`, the
4336
4522
  // batch/directory scan surface. Auxiliaries ARE spawned on that scope
@@ -4454,7 +4640,8 @@ export class LSPService {
4454
4640
  },
4455
4641
  });
4456
4642
  }
4457
- else if (waitedMs + 20 >= timeoutMs) {
4643
+ else if (waitedMs + 20 >= timeoutMs ||
4644
+ firstContactErroredServerIds.size > 0) {
4458
4645
  // Within ~20 ms of the configured budget we treat it as a timeout;
4459
4646
  // the LSP didn't beat the cap. Diagnostics that arrive late still
4460
4647
  // land in the client's cache and surface on the next edit.
@@ -4479,25 +4666,10 @@ export class LSPService {
4479
4666
  // reads as unanswered, which for a primary is exactly the pre-#1549
4480
4667
  // verdict. This block can therefore only ever NARROW an inconclusive
4481
4668
  // touch, never create one.
4482
- const answeredForThisTouch = (entry) => {
4483
- const baseline = diagnosticBaselines.get(entry.client);
4484
- const currentPathVersion = readPathVersion(entry.client);
4485
- if (Number.isFinite(baseline) &&
4486
- currentPathVersion !== undefined &&
4487
- currentPathVersion > baseline) {
4488
- return true;
4489
- }
4490
- try {
4491
- return (entry.client.getAllDiagnostics?.().has(normalizedPath) === true);
4492
- }
4493
- catch {
4494
- // Fail closed: an unreadable cache is not evidence of an answer.
4495
- return false;
4496
- }
4497
- };
4498
4669
  // A deferred server was never sent this content and is not waited on, so
4499
4670
  // it cannot have "timed out" — it is already reported as a coverage gap.
4500
- const waited = spawned.filter((entry) => !deferredResyncServerIds.has(entry.info.id));
4671
+ const waited = spawned.filter((entry) => !deferredResyncServerIds.has(entry.info.id) &&
4672
+ !diagnosticsUnsupportedServerIds.includes(entry.info.id));
4501
4673
  const unanswered = waited.filter((entry) => !answeredForThisTouch(entry));
4502
4674
  diagnosticsUnansweredServerIds = unanswered.map((e) => e.info.id);
4503
4675
  diagnosticsUnansweredPrimaryServerIds = unanswered
@@ -4507,8 +4679,9 @@ export class LSPService {
4507
4679
  // preserve, so it keeps the pre-#1549 touch-wide verdict rather than
4508
4680
  // absolving itself on an auxiliary's evidence.
4509
4681
  const hasWaitedPrimary = waited.some((entry) => entry.info.role !== "auxiliary");
4682
+ const hasUnsupportedPrimary = diagnosticsUnsupportedServerIds.some((serverId) => spawned.some((entry) => entry.info.id === serverId && entry.info.role !== "auxiliary"));
4510
4683
  diagnosticsTimedOut =
4511
- !hasWaitedPrimary ||
4684
+ (!hasWaitedPrimary && !hasUnsupportedPrimary) ||
4512
4685
  diagnosticsUnansweredPrimaryServerIds.length > 0;
4513
4686
  for (const entry of unanswered) {
4514
4687
  incrementDegradationCount({
@@ -5061,6 +5234,11 @@ export class LSPService {
5061
5234
  // collect, `binding` only for a collecting touch — a non-collecting touch
5062
5235
  // keeps resolving `{ diags: [] }`, no flags.
5063
5236
  const result = { diags: collected ?? [] };
5237
+ const primaryDiagnosticsUnsupported = diagnosticsUnsupportedServerIds.some((serverId) => spawned.some((entry) => entry.info.id === serverId && entry.info.role !== "auxiliary"));
5238
+ if (diagnosticsUnsupportedServerIds.length > 0) {
5239
+ result.diagnosticsUnsupportedServerIds =
5240
+ diagnosticsUnsupportedServerIds;
5241
+ }
5064
5242
  if (collected !== undefined && inconclusive) {
5065
5243
  result.inconclusive = true;
5066
5244
  // #1549: name the primary that produced the verdict and which deadline it
@@ -5073,6 +5251,11 @@ export class LSPService {
5073
5251
  }
5074
5252
  result.inconclusiveReason = verdict.inconclusiveReason;
5075
5253
  }
5254
+ else if (collected !== undefined && primaryDiagnosticsUnsupported) {
5255
+ // A navigation-only primary has no diagnostic confirmation to report.
5256
+ // Auxiliary coverage cannot turn that capability boundary into a clean
5257
+ // or partial diagnostic verdict.
5258
+ }
5076
5259
  else if (collected !== undefined && coverageGap) {
5077
5260
  // #1470/#1493: narrowed, not collapsed. Reached for EITHER no-answer
5078
5261
  // shape — a cut-off auxiliary or a silent one with nothing published for
@@ -5083,7 +5266,7 @@ export class LSPService {
5083
5266
  result.confirmation = "partial";
5084
5267
  result.unconfirmedServerIds = [...unconfirmedServerIds];
5085
5268
  }
5086
- else if (collected !== undefined) {
5269
+ else if (collected !== undefined && !primaryDiagnosticsUnsupported) {
5087
5270
  // Preserve the lower-level affirmative result across consumers. In
5088
5271
  // particular, the silent-clean gates above clear diagnosticsTimedOut only
5089
5272
  // after a successful notify and capability-confirmed wait; reclassifying
@@ -5854,6 +6037,9 @@ export class LSPService {
5854
6037
  root,
5855
6038
  operationSupport: client.getOperationSupport(),
5856
6039
  workspaceDiagnosticsSupport: client.getWorkspaceDiagnosticsSupport(),
6040
+ customServer: server.custom,
6041
+ diagnosticsPublished: this.state.diagnosticsPublished.has(server.id),
6042
+ diagnosticsUnsupported: this.state.diagnosticsUnsupported.has(server.id),
5857
6043
  advertisedCommands: client.getAdvertisedCommands(),
5858
6044
  rawCapabilityKeys: client.getRawCapabilityKeys?.() ?? [],
5859
6045
  launchVariant: client.getLaunchVariant?.(),
@@ -5871,6 +6057,9 @@ export class LSPService {
5871
6057
  root: client.root,
5872
6058
  operationSupport: client.getOperationSupport(),
5873
6059
  workspaceDiagnosticsSupport: client.getWorkspaceDiagnosticsSupport(),
6060
+ customServer: this.state.servers.get(serverId)?.custom,
6061
+ diagnosticsPublished: this.state.diagnosticsPublished.has(serverId),
6062
+ diagnosticsUnsupported: this.state.diagnosticsUnsupported.has(serverId),
5874
6063
  advertisedCommands: client.getAdvertisedCommands(),
5875
6064
  rawCapabilityKeys: client.getRawCapabilityKeys?.() ?? [],
5876
6065
  launchVariant: client.getLaunchVariant?.(),
@@ -7042,7 +7231,7 @@ export class LSPService {
7042
7231
  // ONE bracket for the whole fan-out, not one per file (#2272 review F1).
7043
7232
  // Per-file bracketing was the first shape and it was inert for exactly
7044
7233
  // the case above: `phaseFinished` pushes onto a ring capped at
7045
- // `CLOSED_BRACKET_CAP` (5, clients/latency-logger.ts), while `turn_end`
7234
+ // `RECENT_PHASE_CAP` (5, clients/latency-logger.ts), while `turn_end`
7046
7235
  // reads `getPhaseForWindow` AFTER the sweep returns. A 225-file sweep
7047
7236
  // therefore evicted the blocking file's own bracket unless the block
7048
7237
  // landed in the last five files, and the survivors were then rejected by
@@ -7515,6 +7704,7 @@ export class LSPService {
7515
7704
  if (this.checkDestroyed())
7516
7705
  return;
7517
7706
  this.isDestroyed = true;
7707
+ this.shutdownController.abort();
7518
7708
  for (const [key, token] of this.outstandingAuxNotifyWrites) {
7519
7709
  this.releaseOutstandingAuxNotifyWrite(key, token);
7520
7710
  }
@@ -7563,6 +7753,7 @@ export class LSPService {
7563
7753
  });
7564
7754
  this.state.clients.clear();
7565
7755
  this.state.broken.clear();
7756
+ this.firstContactWaits.clear();
7566
7757
  // #1934 review F1: map hygiene alongside the breaker it sits next to.
7567
7758
  // Not load-bearing — every read follows its own attempt's write — but a
7568
7759
  // verdict for a client generation that no longer exists is dead weight.
@@ -55,7 +55,7 @@ const TYPESCRIPT_DIAGNOSTIC_SOURCES = new Set(["typescript", "ts", "tsserver"]);
55
55
  export function isTsProjectFile(filePath) {
56
56
  return TS_PROJECT_EXTENSIONS.has(path.extname(filePath).toLowerCase());
57
57
  }
58
- export function isTypeScriptSourcedDiagnostic(d) {
58
+ function isTypeScriptSourcedDiagnostic(d) {
59
59
  return TYPESCRIPT_DIAGNOSTIC_SOURCES.has((d.source ?? "").toLowerCase());
60
60
  }
61
61
  /**
@@ -61,11 +61,3 @@ export function getLanguageId(filePath) {
61
61
  LANGUAGE_EXTENSIONS[base.toLowerCase()] ??
62
62
  getBasenameLanguageId(base));
63
63
  }
64
- /**
65
- * Get all extensions for a language ID
66
- */
67
- export function getExtensionsForLanguage(languageId) {
68
- return Object.entries(LANGUAGE_EXTENSIONS)
69
- .filter(([, id]) => id === languageId)
70
- .map(([ext]) => ext);
71
- }
@@ -2,4 +2,4 @@
2
2
  * Re-export from shared path-utils.
3
3
  * Kept as a local module for LSP imports that use relative paths.
4
4
  */
5
- export { isUnderDir, normalizeFilePath, normalizeEphemeralMapKey, normalizeMapKey, pathsEqual, pathToUri, uriToDiskPath, uriToPath, } from "../path-utils.js";
5
+ export { isUnderDir, normalizeEphemeralMapKey, normalizeMapKey, pathsEqual, uriToDiskPath, uriToPath, } from "../path-utils.js";
@@ -187,8 +187,6 @@ export function drainPendingAuxiliaryCoverage() {
187
187
  export function pendingAuxiliaryCoverageSize() {
188
188
  return pending.size;
189
189
  }
190
- /** Test-only alias for the store-size assertion seam. */
191
- export const pendingAuxiliaryCoverageSizeForTests = pendingAuxiliaryCoverageSize;
192
190
  /**
193
191
  * Drain (read and reset) the cap-eviction count accumulated since the last
194
192
  * drain. Mirrors {@link drainPendingAuxiliaryCoverage}'s consume-once shape
@@ -16,7 +16,6 @@
16
16
  * always sent, so an unrecognized/absent value never regresses `Full`/`None`
17
17
  * behavior.
18
18
  */
19
- export const TEXT_DOCUMENT_SYNC_KIND_NONE = 0;
20
19
  export const TEXT_DOCUMENT_SYNC_KIND_FULL = 1;
21
20
  export const TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL = 2;
22
21
  function isSyncKind(value) {
@@ -37,7 +37,7 @@ import { normalizeMapKey } from "../path-utils.js";
37
37
  // ---------------------------------------------------------------------------
38
38
  // Constants
39
39
  // ---------------------------------------------------------------------------
40
- export const TSSERVER_REQUEST_COMMAND = "typescript.tsserverRequest";
40
+ const TSSERVER_REQUEST_COMMAND = "typescript.tsserverRequest";
41
41
  /**
42
42
  * tsserver's synthetic inferred-project name, as its own source builds it:
43
43
  * `"/dev/null/inferredProject" + counter + "*"`. `/dev/null` is a deliberate
@@ -219,13 +219,13 @@ export async function fetchTsserverProjectIdentity(svc, file) {
219
219
  // ---------------------------------------------------------------------------
220
220
  // Helpers
221
221
  // ---------------------------------------------------------------------------
222
- export function isTsserverSyncRawDiagnostic(value) {
222
+ function isTsserverSyncRawDiagnostic(value) {
223
223
  if (!value || typeof value !== "object")
224
224
  return false;
225
225
  const v = value;
226
226
  return typeof v.message === "string" && typeof v.category === "string";
227
227
  }
228
- export function tsserverSeverityFromCategory(category) {
228
+ function tsserverSeverityFromCategory(category) {
229
229
  switch (category) {
230
230
  case "error":
231
231
  return 1;
@@ -242,7 +242,7 @@ export function tsserverSeverityFromCategory(category) {
242
242
  * `LSPDiagnostic`. Both `line`/`offset` are 1-based in tsserver's protocol
243
243
  * and 0-based in LSP — this conversion handles that.
244
244
  */
245
- export function tsserverSyncDiagnosticToLsp(d) {
245
+ function tsserverSyncDiagnosticToLsp(d) {
246
246
  const startLine = Math.max(0, (d.startLocation?.line ?? 1) - 1);
247
247
  const startChar = Math.max(0, (d.startLocation?.offset ?? 1) - 1);
248
248
  const endLine = Math.max(0, (d.endLocation?.line ?? d.startLocation?.line ?? 1) - 1);
@@ -265,7 +265,7 @@ export function tsserverSyncDiagnosticToLsp(d) {
265
265
  * executed, the response envelope isn't `{success:true, body:[...]}`, or
266
266
  * any error is thrown.
267
267
  */
268
- export async function runTsserverSyncCommand(svc, file, command) {
268
+ async function runTsserverSyncCommand(svc, file, command) {
269
269
  if (typeof svc.executeCommand !== "function")
270
270
  return undefined;
271
271
  const outcome = await svc.executeCommand(file, TSSERVER_REQUEST_COMMAND, [
@@ -24,6 +24,12 @@ export function classifyServerWaitTier(serverId, snapshot) {
24
24
  const mode = snapshot.workspaceDiagnosticsSupport?.mode;
25
25
  if (mode === "pull")
26
26
  return "pull-capable";
27
+ if (snapshot.customServer === true &&
28
+ mode === "push-only" &&
29
+ snapshot.diagnosticsUnsupported === true &&
30
+ snapshot.diagnosticsPublished !== true) {
31
+ return "diagnostics-unsupported";
32
+ }
27
33
  if (mode !== "push-only")
28
34
  return "waits";
29
35
  const strategy = getStrategy(serverId, snapshot.launchVariant);
@@ -73,6 +73,7 @@ export function resolveAstGrepNativeExe(platform = process.platform, arch = proc
73
73
  return undefined;
74
74
  }
75
75
  }
76
+ /** @public — loaded by scripts/probe-clean-signal.mjs through a dynamic import wrapper knip cannot see. */
76
77
  export const SERVER_DIAGNOSTIC_STRATEGIES = {
77
78
  typescript: {
78
79
  seedFirstPush: true,
@@ -287,7 +288,7 @@ const NATIVE_TS7_DIAGNOSTIC_STRATEGY = {
287
288
  silentOnClean: false,
288
289
  };
289
290
  /** Fallback for unknown servers. Conservative defaults. */
290
- export const DEFAULT_STRATEGY = {
291
+ const DEFAULT_STRATEGY = {
291
292
  seedFirstPush: false,
292
293
  pullRetryBudgetMs: 250,
293
294
  debounceMs: 150,
@@ -2,7 +2,7 @@ import { getLSPService } from "./lsp/index.js";
2
2
  // Grounded against this repo's workspace-native TypeScript 7.0.2 server
3
3
  // (`tsc --lsp --stdio`) on 2026-07-30: 20 already-open documentSymbol calls
4
4
  // measured p50 3.70 ms / p95 5.28 ms. Keep generous headroom for slower hosts.
5
- export const LSP_DOCUMENT_SYMBOL_TIMEOUT_MS = 150;
5
+ const LSP_DOCUMENT_SYMBOL_TIMEOUT_MS = 150;
6
6
  export const SYMBOL_KIND_NAMES = {
7
7
  1: "file",
8
8
  2: "module",