@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
@@ -15,6 +15,7 @@ import { findNodeToolBinary } from "./package-manager.js";
15
15
  import { isFullyQualified } from "./path-utils.js";
16
16
  import { safeSpawnAsync } from "./safe-spawn.js";
17
17
  import { createAvailabilityChecker, discoverManagedTool, getManagedToolEnvironment, resolveAvailableOrInstall, } from "./dispatch/runners/utils/runner-helpers.js";
18
+ import { createAvailabilityLatch } from "./dispatch/runners/utils/availability-policy.js";
18
19
  /**
19
20
  * Build madge's argv for a circular-dependency scan.
20
21
  *
@@ -158,7 +159,8 @@ export class DependencyChecker {
158
159
  environment: (cwd) => getManagedToolEnvironment("madge", cwd),
159
160
  unclassifiedFailureOutcome: "missing",
160
161
  });
161
- available = null;
162
+ /** Transient-aware availability memo — see `ensureAvailable` (#1467). */
163
+ availabilityLatch = createAvailabilityLatch();
162
164
  ensureInFlight = null;
163
165
  checkInFlight = new Map();
164
166
  scanInFlight = new Map();
@@ -320,12 +322,17 @@ export class DependencyChecker {
320
322
  this.circularFiles = circularFiles;
321
323
  }
322
324
  /**
323
- * Check if madge is available, auto-install if not
325
+ * Check if madge is available, auto-install if not.
326
+ *
327
+ * Shares knip's latch policy (#1467): only a durable verdict is memoized, so
328
+ * a probe that timed out — madge's `--version` is ~0.7 s today, but a host
329
+ * event-loop stall can expire any budget — does not disable madge for the
330
+ * life of the process.
324
331
  */
325
332
  async ensureAvailable(cwd = process.cwd()) {
326
- // Fast path: already checked
327
- if (this.available !== null)
328
- return this.available;
333
+ const memo = this.availabilityLatch.read();
334
+ if (memo !== null)
335
+ return memo;
329
336
  if (this.ensureInFlight)
330
337
  return this.ensureInFlight;
331
338
  this.ensureInFlight = this.doEnsureAvailable(cwd);
@@ -338,8 +345,13 @@ export class DependencyChecker {
338
345
  }
339
346
  async doEnsureAvailable(cwd) {
340
347
  const resolved = await resolveAvailableOrInstall(this.madgeAvailability, "madge", cwd);
341
- this.available = resolved !== null;
342
- return this.available;
348
+ if (resolved !== null) {
349
+ this.availabilityLatch.noteAvailable();
350
+ return true;
351
+ }
352
+ const verdict = this.madgeAvailability.getVerdict(cwd);
353
+ this.availabilityLatch.noteUnavailable(verdict.outcome ?? "missing", verdict.cause ?? "not-found");
354
+ return false;
343
355
  }
344
356
  /**
345
357
  * Check if a file is part of a circular dependency (from cache)
@@ -278,7 +278,7 @@ export function _resetStateCacheForTests() {
278
278
  * Neither can throw into the mark path; both are already internally
279
279
  * fail-safe, but the try/catch keeps a future regression in either from
280
280
  * breaking a mark. */
281
- function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
281
+ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, identity) {
282
282
  try {
283
283
  logDispositionEvent({
284
284
  event: "mark",
@@ -290,6 +290,8 @@ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
290
290
  reason,
291
291
  anchor,
292
292
  previousDisposition: existing?.disposition,
293
+ model: identity?.model || undefined,
294
+ provider: identity?.provider || undefined,
293
295
  });
294
296
  publishDisposition({
295
297
  cwd,
@@ -317,7 +319,7 @@ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
317
319
  * hang off it, so the agent tool and any future UI caller are covered without
318
320
  * per-caller wiring.
319
321
  */
320
- export function markDisposition(cwd, target, disposition, reason) {
322
+ export function markDisposition(cwd, target, disposition, reason, identity) {
321
323
  const anchor = disposition === "false-positive"
322
324
  ? computeStrictAnchor(target)
323
325
  : computeWeakAnchor(target);
@@ -327,7 +329,7 @@ export function markDisposition(cwd, target, disposition, reason) {
327
329
  const existing = readState(cwd).dispositions?.[anchor];
328
330
  if (disposition === "defer") {
329
331
  deferredThisSession.add(anchor);
330
- emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing);
332
+ emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, identity);
331
333
  return anchor;
332
334
  }
333
335
  const now = new Date().toISOString();
@@ -344,7 +346,7 @@ export function markDisposition(cwd, target, disposition, reason) {
344
346
  lineText,
345
347
  };
346
348
  commitDisposition(cwd, anchor, entry);
347
- emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing);
349
+ emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, identity);
348
350
  return anchor;
349
351
  }
350
352
  export function getDisposition(cwd, anchor) {
@@ -61,7 +61,7 @@
61
61
  */
62
62
  import { logBusEvent } from "./bus-events-logger.js";
63
63
  import { normalizeFilePath } from "./path-utils.js";
64
- import { createLiveBusEmitter, recordStaleBusFailure, } from "./live-bus-emitter.js";
64
+ import { createLiveBusEmitter, recordStaleBusFailure, resolveLiveBusEmitter, } from "./live-bus-emitter.js";
65
65
  import { isBusPublishEnabled } from "./bus-publish.js";
66
66
  export const BUS_DIAGNOSTICS_EVENT = "pilens:diagnostics";
67
67
  export const BUS_DIAGNOSTICS_VERSION = 1;
@@ -149,8 +149,13 @@ export function publishDiagnostics(args) {
149
149
  }
150
150
  return;
151
151
  }
152
- const busEmit = liveEmitter.get();
153
- if (!busEmit) {
152
+ const resolution = resolveLiveBusEmitter(liveEmitter, () => ({
153
+ event: BUS_DIAGNOSTICS_EVENT,
154
+ cwd: normalizeFilePath(args.cwd),
155
+ }));
156
+ if (resolution.outcome === "stale-session")
157
+ return;
158
+ if (resolution.outcome === "unwired") {
154
159
  if (!hasLoggedUnwired) {
155
160
  hasLoggedUnwired = true;
156
161
  logBusEvent({
@@ -161,6 +166,7 @@ export function publishDiagnostics(args) {
161
166
  }
162
167
  return;
163
168
  }
169
+ const busEmit = resolution.emit;
164
170
  try {
165
171
  const fileEntries = args.files.map((f) => {
166
172
  const normPath = normalizeFilePath(f.path);
@@ -204,6 +210,7 @@ export function publishDiagnostics(args) {
204
210
  outcome: "emit_failed",
205
211
  cwd: normalizeFilePath(args.cwd),
206
212
  error: String(err),
213
+ ctxSource: resolution.ctxSource,
207
214
  });
208
215
  if (!hasLoggedFailure) {
209
216
  hasLoggedFailure = true;
@@ -29,6 +29,7 @@ import { loadPiLensProjectConfig } from "../project-lens-config.js";
29
29
  import { RUNTIME_CONFIG, getRunnerTimeoutFloorMs } from "../runtime-config.js";
30
30
  import { safeSpawnAsync } from "../safe-spawn.js";
31
31
  import { classifyDiagnostic } from "./diagnostic-taxonomy.js";
32
+ import { classifyProbeFailure, logAvailabilityDecision, startHostStallSampler, transientRetryDelayMs, } from "./runners/utils/availability-policy.js";
32
33
  import { applyDispositions } from "../diagnostic-dispositions.js";
33
34
  import { applyInlineSuppressions } from "./inline-suppressions.js";
34
35
  import { getToolPlan } from "./plan.js";
@@ -76,12 +77,49 @@ export function normalizeCacheKey(cmd) {
76
77
  const normalized = cmd.replace(/\.(cmd|exe)$/i, "").toLowerCase();
77
78
  return `session.toolCache.${normalized}`;
78
79
  }
79
- async function checkToolAvailability(command, facts) {
80
+ /** Probe budget for the generic `hasTool` availability check, ms. */
81
+ const TOOL_PROBE_TIMEOUT_MS = 5000;
82
+ /**
83
+ * Session fact holding the epoch ms after which a TRANSIENT verdict for a
84
+ * command may be re-probed. Kept beside the boolean fact so both share the
85
+ * session's lifetime — no second global to reset (#1476).
86
+ */
87
+ function transientRetryKey(command) {
88
+ return `${normalizeCacheKey(command)}.retryAt`;
89
+ }
90
+ /**
91
+ * Consecutive transient verdicts for a command, so the cooldown ESCALATES the
92
+ * way the policy documents (30 s, 60 s, 120 s … capped at 5 min) instead of
93
+ * sitting flat at 30 s.
94
+ *
95
+ * This is the highest-traffic availability consumer in the product. A flat
96
+ * cooldown here means a permanently sick host is re-probed every 30 s per
97
+ * command for the whole session — ten times the storm the policy claims to
98
+ * bound, and the one place the bound matters most. `createAvailabilityChecker`
99
+ * tracks the same counter; this keeps the two seams telling one story.
100
+ */
101
+ function transientAttemptsKey(command) {
102
+ return `${normalizeCacheKey(command)}.transientAttempts`;
103
+ }
104
+ /**
105
+ * Is `command` usable right now? Cached per session.
106
+ *
107
+ * Latch policy (#1467/#1476): a `false` from a genuine absence is durable and
108
+ * is remembered for the session. A `false` from a TIMED-OUT probe is not — this
109
+ * is the highest-traffic availability consumer in the codebase, and caching a
110
+ * host stall here silently disabled a healthy tool for every later dispatch in
111
+ * the session. A transient verdict instead holds only for a bounded cooldown,
112
+ * which also stops a sick host from being re-probed on every dispatch.
113
+ */
114
+ export async function checkToolAvailability(command, facts) {
80
115
  const key = normalizeCacheKey(command);
81
116
  const cached = facts.getSessionFact(key);
82
117
  if (cached !== undefined) {
83
118
  return cached;
84
119
  }
120
+ const retryAt = facts.getSessionFact(transientRetryKey(command));
121
+ if (retryAt !== undefined && Date.now() < retryAt)
122
+ return false;
85
123
  // A command that isn't even on disk can't pass a --version probe; the ~μs
86
124
  // stat/PATH walk saves a guaranteed-to-fail spawn round-trip per cold tool.
87
125
  if (!(await isSpawnableCommand(command))) {
@@ -89,12 +127,68 @@ async function checkToolAvailability(command, facts) {
89
127
  return false;
90
128
  }
91
129
  try {
92
- const result = await safeSpawnAsync(command, ["--version"], {
93
- timeout: 5000,
130
+ // The budget is enforced by a HOST-side timer, so measure the loop stall
131
+ // that overlapped the window and let the shared classifier read it.
132
+ const sampler = startHostStallSampler();
133
+ const startedAt = Date.now();
134
+ let result;
135
+ let hostStallMs;
136
+ try {
137
+ result = await safeSpawnAsync(command, ["--version"], {
138
+ timeout: TOOL_PROBE_TIMEOUT_MS,
139
+ });
140
+ }
141
+ finally {
142
+ hostStallMs = sampler.stop();
143
+ }
144
+ const elapsedMs = Date.now() - startedAt;
145
+ if (result.status === 0) {
146
+ facts.setSessionFact(key, true);
147
+ // The tool answered: retire the cooldown facts rather than leaving a
148
+ // stale retry deadline behind a `true`.
149
+ facts.setSessionFact(transientRetryKey(command), 0);
150
+ facts.setSessionFact(transientAttemptsKey(command), 0);
151
+ logAvailabilityDecision({
152
+ tool: command,
153
+ verdict: "available",
154
+ outcome: "success",
155
+ cause: "ok",
156
+ elapsedMs,
157
+ latched: true,
158
+ hostStallMs,
159
+ budgetMs: TOOL_PROBE_TIMEOUT_MS,
160
+ });
161
+ return true;
162
+ }
163
+ // `missing` for anything unclassified preserves the pre-#1476 meaning of
164
+ // a non-zero exit: durable, cached for the session.
165
+ const { outcome, cause } = classifyProbeFailure(result, {
166
+ hostStallMs,
167
+ unclassifiedFailureOutcome: "missing",
94
168
  });
95
- const available = result.status === 0;
96
- facts.setSessionFact(key, available);
97
- return available;
169
+ let retryAfterMs;
170
+ if (outcome === "transient") {
171
+ const attempts = (facts.getSessionFact(transientAttemptsKey(command)) ?? 0) + 1;
172
+ facts.setSessionFact(transientAttemptsKey(command), attempts);
173
+ retryAfterMs = transientRetryDelayMs(attempts, cause);
174
+ facts.setSessionFact(transientRetryKey(command), Date.now() + retryAfterMs);
175
+ }
176
+ else {
177
+ facts.setSessionFact(key, false);
178
+ facts.setSessionFact(transientAttemptsKey(command), 0);
179
+ }
180
+ logAvailabilityDecision({
181
+ tool: command,
182
+ verdict: "unavailable",
183
+ outcome,
184
+ cause,
185
+ elapsedMs,
186
+ latched: outcome !== "transient",
187
+ hostStallMs,
188
+ ...(retryAfterMs !== undefined && { retryAfterMs }),
189
+ budgetMs: TOOL_PROBE_TIMEOUT_MS,
190
+ });
191
+ return false;
98
192
  }
99
193
  catch {
100
194
  facts.setSessionFact(key, false);
@@ -128,7 +222,10 @@ export function createDispatchContext(filePath, cwd, pi, facts, blockingOnly, mo
128
222
  /** Authoritative workspace root; `cwd` may be a nested language root. */
129
223
  projectRoot,
130
224
  /** Ordered per-file pipeline token, when this is a post-write dispatch. */
131
- writeIndex) {
225
+ writeIndex,
226
+ /** Runtime telemetry identity, when known (#1448) — threaded to the
227
+ * worklog append; see DispatchContext.telemetryModel's doc. */
228
+ telemetryModel, telemetryProvider) {
132
229
  const absoluteFilePath = resolveRunnerPath(cwd, filePath);
133
230
  const normalizedProjectRoot = normalizeMapKey(path.resolve(projectRoot ?? cwd));
134
231
  const normalizedCwd = normalizeMapKey(resolveLanguageRootForFile(absoluteFilePath, cwd));
@@ -150,6 +247,8 @@ writeIndex) {
150
247
  blockingOnly,
151
248
  modifiedRanges,
152
249
  writeIndex,
250
+ telemetryModel,
251
+ telemetryProvider,
153
252
  async hasTool(command) {
154
253
  return checkToolAvailability(command, facts);
155
254
  },
@@ -638,11 +737,16 @@ export async function dispatchForFile(ctx, groups, registry, onRunnerResult) {
638
737
  const blockers = visibleDiagnostics.filter((d) => d.semantic === "blocking");
639
738
  const warnings = visibleDiagnostics.filter((d) => d.semantic === "warning" || d.semantic === "none");
640
739
  const fixedItems = visibleDiagnostics.filter((d) => d.semantic === "fixed");
641
- // Append fixed and fixable diagnostics to the persistent worklog
740
+ // Append fixed and fixable diagnostics to the persistent worklog, attributed
741
+ // to the runtime's active model/provider when known (#1448).
742
+ const worklogIdentity = {
743
+ model: ctx.telemetryModel,
744
+ provider: ctx.telemetryProvider,
745
+ };
642
746
  if (fixedItems.length > 0) {
643
747
  import("../fix-worklog.js")
644
748
  .then(({ appendToWorklog }) => {
645
- appendToWorklog(ctx.cwd, fixedItems, true);
749
+ appendToWorklog(ctx.cwd, fixedItems, true, worklogIdentity);
646
750
  })
647
751
  .catch(() => { });
648
752
  }
@@ -650,7 +754,7 @@ export async function dispatchForFile(ctx, groups, registry, onRunnerResult) {
650
754
  if (fixableWarnings.length > 0) {
651
755
  import("../fix-worklog.js")
652
756
  .then(({ appendToWorklog }) => {
653
- appendToWorklog(ctx.cwd, fixableWarnings, false);
757
+ appendToWorklog(ctx.cwd, fixableWarnings, false, worklogIdentity);
654
758
  })
655
759
  .catch(() => { });
656
760
  }
@@ -19,9 +19,8 @@ import { fileURLToPath } from "node:url";
19
19
  import { formatCascadeNeighborDiagnostics } from "../cascade-format.js";
20
20
  import { logCascade } from "../cascade-logger.js";
21
21
  import { getDiagnosticTracker } from "../diagnostic-tracker.js";
22
- import { isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
23
- import { classifyCascadeWaitTier } from "../lsp/wait-policy/index.js";
24
- import { bindingStateLabel, } from "../lsp/diagnostic-binding.js";
22
+ import { classifyCascadeWaitTier, isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
23
+ import { bindingStateLabel, touchCoverageGap, } from "../lsp/diagnostic-binding.js";
25
24
  import { getServersForFileWithConfig } from "../lsp/config.js";
26
25
  import { getLSPService } from "../lsp/index.js";
27
26
  import { isExternalOrVendorFile, normalizeMapKey } from "../path-utils.js";
@@ -412,6 +411,11 @@ function readBoundToCurrentDisk(rawDiags) {
412
411
  * a future flag cannot be silently missed at just one of the several gate sites:
413
412
  * - `inconclusive` (#1093/#571): the notify/diagnostics wait lapsed its deadline,
414
413
  * so a resolved `[]` is NOT a confirmed clean (the #533 false-clean trap).
414
+ * - a COVERAGE GAP (#1470): an auxiliary's push wait was cut off by the aux grace
415
+ * timer, so the merged result is missing whatever that scanner would have said.
416
+ * Such a touch is deliberately NOT `inconclusive` (the primary answered), which
417
+ * is exactly why it needs naming here: reading `!inconclusive` alone would let a
418
+ * hung opengrep wipe a live footer finding and seed the recently-clean cache.
415
419
  * - `binding.boundToCurrentDisk === false` (#1095): the diagnostics were computed
416
420
  * against a DIFFERENT disk state than what is on disk now (the server's view
417
421
  * diverged / a pre-fix buffer) — not an observation of current disk. `true` and
@@ -426,7 +430,9 @@ function readInconclusive(rawDiags) {
426
430
  return rawDiags?.inconclusive === true;
427
431
  }
428
432
  function isConfirmedTouch(rawDiags) {
429
- return !readInconclusive(rawDiags) && readBoundToCurrentDisk(rawDiags) !== false;
433
+ return (!readInconclusive(rawDiags) &&
434
+ touchCoverageGap(rawDiags).length === 0 &&
435
+ readBoundToCurrentDisk(rawDiags) !== false);
430
436
  }
431
437
  const reverseDepsIndexCache = new Map();
432
438
  const REVERSE_DEPS_MAX_WARM_ROOTS = 8;
@@ -666,6 +672,12 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
666
672
  let importerSet = new Set();
667
673
  let callerSet = new Set();
668
674
  let referenceCount = 0;
675
+ // #1446 item 4: how many eligible neighbors the flat CASCADE_NEIGHBOUR_BUDGET
676
+ // cut off, distinct from candidates dropped by the filters above it
677
+ // (missing on disk, vendor, ignored, already-primary-this-turn) — those are
678
+ // never actionable regardless of budget, so counting them as "truncated"
679
+ // would overstate what a larger budget could actually recover.
680
+ let cascadeBudgetTruncated = 0;
669
681
  if (CASCADE_GRAPH_KINDS.has(fileKind)) {
670
682
  const graphStart = Date.now();
671
683
  const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts, seqState);
@@ -1004,7 +1016,7 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1004
1016
  ...impact.directCallers,
1005
1017
  ]);
1006
1018
  referenceCount = impact.neighborFiles.filter((n) => !importerOrCallerSet.has(n)).length;
1007
- sortedNeighbors = [...impact.neighborFiles]
1019
+ const eligibleNeighbors = [...impact.neighborFiles]
1008
1020
  .filter((n) => nodeFs.existsSync(n))
1009
1021
  .filter((n) => !isExternalOrVendorFile(n, cwd))
1010
1022
  // Honour the project's ignore config: a user-ignored neighbour (e.g.
@@ -1016,8 +1028,9 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1016
1028
  .sort((a, b) => {
1017
1029
  const rank = (p) => importerSet.has(p) ? 0 : callerSet.has(p) ? 1 : 2;
1018
1030
  return rank(a) - rank(b);
1019
- })
1020
- .slice(0, CASCADE_NEIGHBOUR_BUDGET);
1031
+ });
1032
+ cascadeBudgetTruncated = Math.max(0, eligibleNeighbors.length - CASCADE_NEIGHBOUR_BUDGET);
1033
+ sortedNeighbors = eligibleNeighbors.slice(0, CASCADE_NEIGHBOUR_BUDGET);
1021
1034
  }
1022
1035
  else {
1023
1036
  logCascade({
@@ -1066,6 +1079,28 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1066
1079
  // the latter must not collapse into a clean-looking result (#1104 honesty
1067
1080
  // rule, same doctrine as #1023's graph-degraded indeterminate marker).
1068
1081
  let fallbackBindingRejected = false;
1082
+ // #1444: neighbours whose in-lane wait was skipped for the quiet-window
1083
+ // reconcile to answer later. Logged on `cascade_result` so a cascade that
1084
+ // deferred EVERY neighbour is distinguishable from a genuine leaf (both are
1085
+ // `neighborCount: 0` with no output otherwise).
1086
+ let collectLaterSkipped = 0;
1087
+ // #1446 item 5: `recentlyCleanNeighborCache` and `neighborTouchCache` hits
1088
+ // are the whole point of both caches, but neither was ever counted — the
1089
+ // only visible signal was 267s/day of touch wall time with no way to tell
1090
+ // whether the caches were absorbing repeat work or every touch was cold.
1091
+ let recentlyCleanHits = 0;
1092
+ let cacheHits = 0;
1093
+ // F1 (#1446 follow-up): `coldTouches` must be counted at the point each
1094
+ // neighbour's OUTCOME is actually known, not derived from `coldSnapshotPaths`
1095
+ // (finalized earlier, before the cache-hit checks below run against it). Using
1096
+ // the pre-outcome list let a neighbour double-count (cold-snapshot AND cache/
1097
+ // recently-clean hit) or vanish from every bucket (an `activePaths` neighbour —
1098
+ // e.g. Python/Go — that misses both caches). These four counters partition the
1099
+ // touched-neighbour set `[...activePaths, ...coldSnapshotPaths]` exactly once
1100
+ // each: a neighbour with no LSP server configured is the only outcome
1101
+ // deliberately excluded (never attempted, no bucket).
1102
+ let deferredTouches = 0;
1103
+ let coldTouches = 0;
1069
1104
  if (sortedNeighbors.length > 0) {
1070
1105
  const snapshotPaths = sortedNeighbors.filter(shouldReadCascadeFromSnapshot);
1071
1106
  const activePaths = sortedNeighbors.filter((n) => !shouldReadCascadeFromSnapshot(n));
@@ -1186,6 +1221,7 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1186
1221
  turnSeq - recentlyClean.turnSeq <= RECENTLY_CLEAN_TTL_TURNS &&
1187
1222
  !hasFreshPassiveErrors) {
1188
1223
  producedLspData = true;
1224
+ recentlyCleanHits++;
1189
1225
  const durationMs = Date.now() - neighborStart;
1190
1226
  logCascade({
1191
1227
  phase: "neighbor_snapshot",
@@ -1213,6 +1249,7 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1213
1249
  const cached = writeSeq != null ? neighborTouchCache.get(cacheKey) : undefined;
1214
1250
  if (cached?.turnSeq === turnSeq && cached?.writeSeq === writeSeq) {
1215
1251
  producedLspData = true;
1252
+ cacheHits++;
1216
1253
  const durationMs = Date.now() - neighborStart;
1217
1254
  logCascade({
1218
1255
  phase: "neighbor_snapshot",
@@ -1245,10 +1282,10 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1245
1282
  }
1246
1283
  // A6: async read to avoid blocking event loop on network-mounted drives
1247
1284
  const content = await nodeFs.promises.readFile(neighborPath, "utf8");
1248
- // #458: tier-aware cascade-lane wait. A Tier-3 (push-only,
1249
- // silent-on-clean typescript is the lone core-set instance today)
1250
- // primary can never give this in-lane wait an affirmative clean
1251
- // signal, so the budget is pure cost. Fire the touch (didOpen/
1285
+ // #458/#1444: tier-aware cascade-lane wait. A Tier-3 silent server
1286
+ // cannot give this wait an affirmative clean signal. Native TS7 does
1287
+ // publish, but not inside the cold-snapshot budget. In both cases the
1288
+ // in-lane budget is pure cost. Fire the touch (didOpen/
1252
1289
  // didChange still happens — the server starts real work) and record
1253
1290
  // it as outstanding for the agent_settled quiet window to reconcile
1254
1291
  // instead of waiting here. Ambiguous/missing capability data always
@@ -1261,7 +1298,7 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1261
1298
  try {
1262
1299
  const snapshots = (await lspService.getCapabilitySnapshots?.(neighborPath)) ?? [];
1263
1300
  const tier = classifyCascadeWaitTier(lspService, neighborPath, snapshots);
1264
- if (tier === "tier3-silent") {
1301
+ if (tier === "tier3-silent" || tier === "collect-later") {
1265
1302
  const spawnedForTouch = await lspService.getClientForFile(neighborPath);
1266
1303
  if (spawnedForTouch) {
1267
1304
  // Sampled BEFORE the touchFile notify: a publish landing
@@ -1283,6 +1320,14 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1283
1320
  touchedAt,
1284
1321
  });
1285
1322
  const durationMs = Date.now() - neighborStart;
1323
+ if (tier === "collect-later")
1324
+ collectLaterSkipped++;
1325
+ // F1: both tier3-silent and collect-later skip the in-lane
1326
+ // wait and record an outstanding touch for the quiet-window
1327
+ // reconcile — neither a cache hit nor a genuine completed
1328
+ // cold touch, so both share this explicit "deferred" bucket
1329
+ // instead of falling out of the partition uncounted.
1330
+ deferredTouches++;
1286
1331
  logCascade({
1287
1332
  phase: "cascade_tier3_skip",
1288
1333
  filePath,
@@ -1290,7 +1335,10 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1290
1335
  durationMs,
1291
1336
  lspServerCount: configuredServerCount,
1292
1337
  coldSnapshot: isColdSnapshot,
1293
- metadata: { serverId: spawnedForTouch.client.serverId },
1338
+ metadata: {
1339
+ serverId: spawnedForTouch.client.serverId,
1340
+ waitTier: tier,
1341
+ },
1294
1342
  });
1295
1343
  // Deliberately NOT cached as clean/diagnosed — the wait was
1296
1344
  // skipped, not resolved, so neither neighborTouchCache nor
@@ -1311,6 +1359,14 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1311
1359
  // Cold-snapshot neighbors (autoPropagate LSP, server warm) use a tighter
1312
1360
  // 1000ms budget — they should respond quickly; we'd rather return zero
1313
1361
  // than block cascade for 2s on a slow open.
1362
+ // F1: this is the ONE remaining outcome after cache hit, recently-clean
1363
+ // hit, and tier-aware deferral have all been ruled out — a genuine
1364
+ // active LSP touch is being issued right now. Count it here (an
1365
+ // attempt, whether it resolves, times out, or the promise rejects
1366
+ // below in the allSettled catch) rather than from `coldSnapshotPaths`,
1367
+ // which is finalized before any of the above checks run and includes
1368
+ // neighbours that resolve via cache/recently-clean instead.
1369
+ coldTouches++;
1314
1370
  const rawDiags = await lspService.touchFile(neighborPath, content, {
1315
1371
  diagnostics: "document",
1316
1372
  collectDiagnostics: true,
@@ -1336,6 +1392,10 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1336
1392
  const confirmed = isConfirmedTouch(rawDiags);
1337
1393
  const bindingRejected = readBoundToCurrentDisk(rawDiags) === false;
1338
1394
  const inconclusive = readInconclusive(rawDiags);
1395
+ // #1470: the third, independent reason a touch is unconfirmed — an
1396
+ // auxiliary our grace timer cut off. Logged alongside the other two so
1397
+ // cascade.log alone still tells the three apart.
1398
+ const unconfirmedServerIds = touchCoverageGap(rawDiags);
1339
1399
  // #692: `source: "cascade"` no longer overrides `rule` (see the
1340
1400
  // doc comment on the sibling call above) — dropped rather than
1341
1401
  // migrated to `scanOrigin` since cascade output never touches
@@ -1345,8 +1405,9 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1345
1405
  // wrapper and are unaffected by this copy (the shape-5 fix).
1346
1406
  const diags = convertLspDiagnostics(rawDiags.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
1347
1407
  const durationMs = Date.now() - neighborStart;
1348
- // Update cache for this neighbor at the current write sequence
1349
- if (writeSeq != null) {
1408
+ // Cache only a confirmed answer. An inconclusive or binding-rejected
1409
+ // result must not become a confirmed cache hit on the next cascade.
1410
+ if (writeSeq != null && confirmed) {
1350
1411
  neighborTouchCache.set(cacheKey, {
1351
1412
  turnSeq,
1352
1413
  writeSeq,
@@ -1386,6 +1447,9 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1386
1447
  metadata: {
1387
1448
  inconclusive,
1388
1449
  ...(bindingRejected && { bindingState: bindingStateLabel(false) }),
1450
+ ...(unconfirmedServerIds.length > 0 && {
1451
+ unconfirmedServerIds: [...unconfirmedServerIds],
1452
+ }),
1389
1453
  },
1390
1454
  });
1391
1455
  // #1093/#1095: a completed, CONFIRMED active touch is a confirmed
@@ -1411,6 +1475,7 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1411
1475
  reason: neighborReason(importerSet, callerSet, neighborPath),
1412
1476
  diagnostics: diags,
1413
1477
  lspTouched: true,
1478
+ ...(inconclusive && { inconclusive: true }),
1414
1479
  durationMs,
1415
1480
  };
1416
1481
  }));
@@ -1513,9 +1578,29 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1513
1578
  metadata: {
1514
1579
  filesWithErrors,
1515
1580
  hasOutput: formatted.length > 0,
1581
+ // #1444: >0 means "answers are still outstanding", not "nothing found".
1582
+ collectLaterSkipped,
1516
1583
  // Log when cascade ran but found nothing — distinguishes "clean" from "no signal"
1517
1584
  noNeighbors: visibleNeighbors.length === 0,
1518
1585
  noErrors: visibleNeighbors.length > 0 && filesWithErrors === 0,
1586
+ // #1446 item 5: cache effectiveness as a number instead of an inference
1587
+ // from `coldSnapshot`/`snapshotMissing` flags scattered across
1588
+ // per-neighbor `neighbor_touch`/`neighbor_snapshot` rows.
1589
+ // F1: cacheHits + recentlyCleanHits + deferredTouches + coldTouches
1590
+ // partition `[...activePaths, ...coldSnapshotPaths]` exactly — each
1591
+ // counter increments at the point its neighbour's outcome is actually
1592
+ // decided, not from `coldSnapshotPaths` (a pre-outcome list finalized
1593
+ // before the cache-hit checks run). A neighbour with no LSP server
1594
+ // configured is the one deliberately uncounted outcome (never touched).
1595
+ cacheHits,
1596
+ recentlyCleanHits,
1597
+ deferredTouches,
1598
+ coldTouches,
1599
+ // #1446 item 4: the budget in force and how many eligible candidates
1600
+ // it cut off this run — the correctness half (a truncated run being
1601
+ // silently discarded) is #1443; this is observability only.
1602
+ neighborBudget: CASCADE_NEIGHBOUR_BUDGET,
1603
+ budgetTruncated: cascadeBudgetTruncated,
1519
1604
  neighbors: visibleNeighbors.slice(0, 10).map((n) => ({
1520
1605
  file: n.filePath.replace(/\\/g, "/").split("/").slice(-2).join("/"),
1521
1606
  diagnostics: n.diagnostics.length,
@@ -1742,7 +1827,7 @@ export async function dispatchLintWithResult(filePath, cwd, pi, modifiedRanges,
1742
1827
  // Default true preserves the per-edit fast path (errors only). Callers that
1743
1828
  // want the full picture (warnings + structural smells), e.g. the MCP review
1744
1829
  // facade, pass blockingOnly=false to run every runner.
1745
- const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? true, modifiedRanges, options?.projectRoot, options?.writeIndex);
1830
+ const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? true, modifiedRanges, options?.projectRoot, options?.writeIndex, options?.telemetryModel, options?.telemetryProvider);
1746
1831
  sessionFacts.clearFileFactsFor(ctx.filePath);
1747
1832
  const kind = ctx.kind;
1748
1833
  if (!kind) {
@@ -12,6 +12,7 @@
12
12
  * unified runner that delegates to the LSP service.
13
13
  */
14
14
  import { logExtension } from "../../extension-log.js";
15
+ import { touchCoverageGap } from "../../lsp/diagnostic-binding.js";
15
16
  import { getLSPService } from "../../lsp/index.js";
16
17
  import { RUNTIME_CONFIG } from "../../runtime-config.js";
17
18
  import { PRIORITY } from "../priorities.js";
@@ -111,6 +112,11 @@ const lspRunner = {
111
112
  // one spawned server) — an empty `lspDiags` in that case is NOT a
112
113
  // confirmed clean result and must not be reported as one (#570).
113
114
  let diagnosticsInconclusive = false;
115
+ // #1470: server ids the touch carries no evidence for — an auxiliary whose
116
+ // push wait our aux grace timer cut off. The touch is NOT inconclusive (the
117
+ // primary answered, and its findings below are real), so this is tracked
118
+ // separately: the only claim it invalidates is "0 diagnostics means clean".
119
+ let unconfirmedServerIds = [];
114
120
  let usedWarmAttach = false;
115
121
  let failureReason = "";
116
122
  const content = readFileContent(ctx.filePath);
@@ -131,11 +137,23 @@ const lspRunner = {
131
137
  usedWarmAttach = attached?.available === true;
132
138
  // #1179 (shape-5 structural fix): both branches normalize to the
133
139
  // `touchFile` wrapper shape. The warm-attach IPC branch resolves a plain
134
- // diagnostics array (the socket cannot carry a side-channel; the IPC client
135
- // already rejected any inconclusive answer, so `available` confirmed)
136
- // wrap it as `{ diags }`; the incumbent branch already returns the wrapper.
140
+ // diagnostics array `available` no longer implies a fully confirmed
141
+ // answer: a `partial` confirmation (an auxiliary cut off by the grace
142
+ // timer) is served as `available: true` too (the IPC gate at
143
+ // `clients/mcp/ipc.ts:248` rejects only `inconclusive`). Carry the
144
+ // incumbent's `unconfirmedServerIds` onto the wrapper so
145
+ // `touchCoverageGap` below sees it — dropping it here is the same
146
+ // false-clean defect already fixed at `clients/lsp/index.ts` (the
147
+ // workspace sweep wrapper) and `tools/lsp-diagnostics.ts` (the tool
148
+ // consumer); wrap it as `{ diags }`; the incumbent branch already
149
+ // returns the wrapper.
137
150
  const touched = attached?.available
138
- ? { diags: attached.response.diagnostics }
151
+ ? {
152
+ diags: attached.response.diagnostics,
153
+ ...(attached.response.unconfirmedServerIds !== undefined && {
154
+ unconfirmedServerIds: attached.response.unconfirmedServerIds,
155
+ }),
156
+ }
139
157
  : await lspService.touchFile(ctx.filePath, content, {
140
158
  diagnostics: "document",
141
159
  collectDiagnostics: true,
@@ -151,6 +169,7 @@ const lspRunner = {
151
169
  else {
152
170
  lspDiags = touched.diags;
153
171
  diagnosticsInconclusive = touched.inconclusive === true;
172
+ unconfirmedServerIds = touchCoverageGap(touched);
154
173
  }
155
174
  }
156
175
  catch (err) {
@@ -208,6 +227,20 @@ const lspRunner = {
208
227
  return { status: "skipped", diagnostics: [], semantic: "none" };
209
228
  }
210
229
  if (lspDiags.length === 0) {
230
+ if (unconfirmedServerIds.length > 0) {
231
+ // #1470: an auxiliary was cut off by the aux grace timer, so this empty
232
+ // merged result is missing whatever that scanner would have said — a
233
+ // hung opengrep must not read as a clean bill of health on the security
234
+ // lane. `RunnerResult` has no channel for "clean for these servers,
235
+ // unknown for those", so the only honest verdict this seam can express
236
+ // for an EMPTY result is "not checked" — which is what "skipped" means
237
+ // here, and it lets the coverage notice say so once. Nothing is thrown
238
+ // away: the primary answered with zero findings, so there is nothing to
239
+ // report; when it DOES have findings the branches below still report
240
+ // them (see the non-empty path), which is how a trustworthy primary
241
+ // stays trustworthy under a cut-off auxiliary.
242
+ return { status: "skipped", diagnostics: [], semantic: "none" };
243
+ }
211
244
  return {
212
245
  status: "succeeded",
213
246
  diagnostics: [],