@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
@@ -18,6 +18,8 @@ import { getServersForFileWithConfig, isServerDisabled, } from "../../../lsp/con
18
18
  import { findGlobalBinary } from "../../../package-manager.js";
19
19
  import { safeSpawnAsync } from "../../../safe-spawn.js";
20
20
  import { getToolCommandSpec, shouldAutoInstallTool, } from "../../../tool-policy.js";
21
+ import { classifyProbeFailure, createAvailabilityLatch, isLatchingOutcome, logAvailabilityDecision, startHostStallSampler, transientRetryDelayMs, } from "./availability-policy.js";
22
+ export { createAvailabilityLatch, classifyProbeFailure, describeUnavailability, isTransientDecision, logAvailabilityDecision, startHostStallSampler, } from "./availability-policy.js";
21
23
  /**
22
24
  * True when the LSP runner will cover `ctx.filePath` via the given PRIMARY server
23
25
  * id. Used by CLI runners that duplicate a linter a warm LSP already wraps
@@ -64,6 +66,29 @@ if (typeof __dirname !== "undefined") {
64
66
  }
65
67
  // Managed tools directory (~/.pi-lens/tools) — where ensureTool() installs binaries
66
68
  const _managedToolsDir = path.join(getGlobalPiLensDir(), "tools");
69
+ /**
70
+ * The managed shim for a Node CLI tool (`~/.pi-lens/tools/node_modules/.bin/<tool>`),
71
+ * or null when it is not on disk.
72
+ *
73
+ * When the shim exists the tool IS installed, so availability needs no spawn at
74
+ * all — and a spawn that cannot happen cannot time out (#1467). knip and jscpd
75
+ * each carried a line-for-line copy of this resolver; #1476 folds them into one
76
+ * definition so the next managed tool inherits the fast path instead of a
77
+ * fourth copy.
78
+ *
79
+ * The pi-lens dir is read per call, never memoized at module load, so tests that
80
+ * point `getGlobalPiLensDir` at a temp home still see their own tree.
81
+ */
82
+ export function findManagedNodeToolBinary(tool) {
83
+ const base = path.join(getGlobalPiLensDir(), "tools", "node_modules", ".bin", tool);
84
+ const candidates = process.platform === "win32" ? [`${base}.cmd`, `${base}.exe`, base] : [base];
85
+ try {
86
+ return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
67
92
  /**
68
93
  * Find a command in venv first, then fall back to global.
69
94
  * Checks common venv locations (.venv, venv) before trying global.
@@ -191,6 +216,13 @@ export function resetDispatchAvailabilityState() {
191
216
  * `zig --version`). Passing the wrong probe makes the runner silently skip on
192
217
  * every machine, so toolchains with a non-standard version command must override
193
218
  * this.
219
+ *
220
+ * ## Latch policy (#1467)
221
+ *
222
+ * A `missing` / `non-installable` verdict is durable and is cached for the
223
+ * session. A `transient` verdict — timeout, abort, EAGAIN — is NOT: it is
224
+ * cached only for a bounded cooldown, after which the next caller re-probes.
225
+ * An installed tool therefore recovers on its own, without a host restart.
194
226
  */
195
227
  export function createAvailabilityChecker(command, windowsExt = "", versionArgs = ["--version"], options = {}) {
196
228
  const cacheByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
@@ -219,16 +251,63 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
219
251
  available: null,
220
252
  command: null,
221
253
  outcome: null,
254
+ cause: null,
255
+ elapsedMs: 0,
256
+ retryAtMs: 0,
257
+ transientAttempts: 0,
222
258
  };
223
259
  cacheByCwd.set(key, created);
224
260
  return created;
225
261
  }
262
+ /** Record a verdict on the cache and emit exactly one decision record. */
263
+ function noteDecision(cache, resolvedCwd, verdict) {
264
+ cache.available = verdict.available;
265
+ cache.outcome = verdict.outcome;
266
+ cache.cause = verdict.cause;
267
+ cache.elapsedMs = verdict.elapsedMs;
268
+ let retryAfterMs;
269
+ if (verdict.available) {
270
+ cache.retryAtMs = 0;
271
+ cache.transientAttempts = 0;
272
+ }
273
+ else if (isLatchingOutcome(verdict.outcome)) {
274
+ cache.retryAtMs = 0;
275
+ cache.transientAttempts = 0;
276
+ }
277
+ else {
278
+ cache.transientAttempts += 1;
279
+ retryAfterMs = transientRetryDelayMs(cache.transientAttempts, verdict.cause);
280
+ cache.retryAtMs = Date.now() + retryAfterMs;
281
+ }
282
+ logAvailabilityDecision({
283
+ tool: command,
284
+ verdict: verdict.available ? "available" : "unavailable",
285
+ outcome: verdict.outcome,
286
+ cause: verdict.cause,
287
+ elapsedMs: verdict.elapsedMs,
288
+ latched: verdict.available || isLatchingOutcome(verdict.outcome),
289
+ ...(verdict.hostStallMs !== undefined && {
290
+ hostStallMs: verdict.hostStallMs,
291
+ }),
292
+ ...(retryAfterMs !== undefined && { retryAfterMs }),
293
+ budgetMs: options.probeTimeout ?? 5000,
294
+ }, resolvedCwd);
295
+ }
226
296
  async function isAvailableAsync(cwd) {
227
297
  ensureCurrentGeneration();
228
298
  const resolvedCwd = cwd || process.cwd();
229
299
  const cache = getCache(resolvedCwd);
230
- if (cache.available === false)
231
- return false;
300
+ if (cache.available === false) {
301
+ // A durable "this machine does not have the tool" stays cached; a
302
+ // transient probe failure only holds until its cooldown expires, so an
303
+ // installed tool cannot be disabled for the life of the process by one
304
+ // slow second at warm-up (#1467).
305
+ if (cache.outcome !== "transient")
306
+ return false;
307
+ if (Date.now() < cache.retryAtMs)
308
+ return false;
309
+ cache.available = null;
310
+ }
232
311
  if (cache.available === true && cache.command) {
233
312
  if (await isSpawnableCommand(cache.command))
234
313
  return true;
@@ -237,6 +316,7 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
237
316
  cache.available = null;
238
317
  cache.command = null;
239
318
  cache.outcome = null;
319
+ cache.cause = null;
240
320
  }
241
321
  const key = path.resolve(resolvedCwd);
242
322
  const existing = inFlightByCwd.get(key);
@@ -247,9 +327,13 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
247
327
  promise = (async () => {
248
328
  const fastPath = options.fastPath?.();
249
329
  if (fastPath) {
250
- cache.available = true;
251
- cache.outcome = "success";
252
330
  cache.command = fastPath;
331
+ noteDecision(cache, resolvedCwd, {
332
+ available: true,
333
+ outcome: "success",
334
+ cause: "fast-path",
335
+ elapsedMs: 0,
336
+ });
253
337
  return true;
254
338
  }
255
339
  // A bad/removed workspace must not be mistaken for a missing tool and
@@ -258,49 +342,71 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
258
342
  try {
259
343
  const cwdStat = await fs.promises.stat(resolvedCwd);
260
344
  if (!cwdStat.isDirectory()) {
261
- cache.available = false;
262
- cache.outcome = "non-installable";
345
+ noteDecision(cache, resolvedCwd, {
346
+ available: false,
347
+ outcome: "non-installable",
348
+ cause: "bad-cwd",
349
+ elapsedMs: 0,
350
+ });
263
351
  return false;
264
352
  }
265
353
  }
266
354
  catch {
267
- cache.available = false;
268
- cache.outcome = "non-installable";
355
+ noteDecision(cache, resolvedCwd, {
356
+ available: false,
357
+ outcome: "non-installable",
358
+ cause: "bad-cwd",
359
+ elapsedMs: 0,
360
+ });
269
361
  return false;
270
362
  }
271
363
  const cmd = findCommand(resolvedCwd);
272
364
  const env = await options.environment?.(resolvedCwd);
273
- const result = await safeSpawnAsync(cmd, versionArgs, {
274
- timeout: options.probeTimeout ?? 5000,
275
- cwd: resolvedCwd,
276
- env,
277
- });
365
+ // The probe budget is enforced by a HOST-side timer, so host event-loop
366
+ // stalls are charged to the child. Measure the stall that overlapped the
367
+ // window and hand it to the classifier (#1467).
368
+ const stallSampler = startHostStallSampler();
369
+ const startedAt = Date.now();
370
+ let result;
371
+ let hostStallMs;
372
+ try {
373
+ result = await safeSpawnAsync(cmd, versionArgs, {
374
+ timeout: options.probeTimeout ?? 5000,
375
+ cwd: resolvedCwd,
376
+ env,
377
+ });
378
+ }
379
+ finally {
380
+ hostStallMs = stallSampler.stop();
381
+ }
382
+ const elapsedMs = Date.now() - startedAt;
278
383
  if (!result.error && result.status === 0) {
279
- cache.available = true;
280
- cache.outcome = "success";
281
384
  cache.command = cmd;
385
+ noteDecision(cache, resolvedCwd, {
386
+ available: true,
387
+ outcome: "success",
388
+ cause: "ok",
389
+ elapsedMs,
390
+ hostStallMs,
391
+ });
282
392
  return true;
283
393
  }
284
- cache.available = false;
285
- const errorCode = result.error?.code;
286
- if (result.spawnFailure?.kind === "tool-not-found") {
394
+ const { outcome, cause } = classifyProbeFailure(result, {
395
+ hostStallMs,
396
+ unclassifiedFailureOutcome: options.unclassifiedFailureOutcome,
397
+ });
398
+ // Only a TYPED tool-not-found invalidates the PATH walk memo; an
399
+ // `unclassifiedFailureOutcome: "missing"` compatibility verdict is a
400
+ // guess, not evidence that PATH changed.
401
+ if (result.spawnFailure?.kind === "tool-not-found")
287
402
  resetPathWalkMemo();
288
- cache.outcome = "missing";
289
- }
290
- else if (result.failure === "timeout" ||
291
- result.failure === "aborted" ||
292
- result.failure === "signal" ||
293
- result.spawnFailure?.kind === "killed" ||
294
- errorCode === "EAGAIN" ||
295
- errorCode === "EBUSY") {
296
- cache.outcome = "transient";
297
- }
298
- else {
299
- // A present command that rejects its version probe (or an EACCES/
300
- // EINVAL/UNKNOWN failure) is not repaired by reinstalling it.
301
- cache.outcome =
302
- options.unclassifiedFailureOutcome ?? "non-installable";
303
- }
403
+ noteDecision(cache, resolvedCwd, {
404
+ available: false,
405
+ outcome,
406
+ cause,
407
+ elapsedMs,
408
+ hostStallMs,
409
+ });
304
410
  return false;
305
411
  })().finally(() => {
306
412
  // A session reset clears this map and a caller may immediately start a
@@ -323,7 +429,18 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
323
429
  ensureCurrentGeneration();
324
430
  return getCache(cwd || process.cwd()).outcome;
325
431
  }
326
- return { isAvailableAsync, getCommand, getOutcome, reset };
432
+ function getVerdict(cwd) {
433
+ ensureCurrentGeneration();
434
+ const cache = getCache(cwd || process.cwd());
435
+ return {
436
+ outcome: cache.outcome,
437
+ cause: cache.cause,
438
+ elapsedMs: cache.elapsedMs,
439
+ latched: cache.available !== false || isLatchingOutcome(cache.outcome ?? "missing"),
440
+ retryAtMs: cache.retryAtMs,
441
+ };
442
+ }
443
+ return { isAvailableAsync, getCommand, getOutcome, getVerdict, reset };
327
444
  }
328
445
  /**
329
446
  * Per-cwd cached availability probe for spawn signatures that don't fit
@@ -498,20 +615,46 @@ export function resolveAvailableOrInstall(checker, toolId, cwd) {
498
615
  // =============================================================================
499
616
  // SHARED AST-GREP AVAILABILITY
500
617
  // =============================================================================
501
- // Shared ast-grep availability cache across all slop runners
502
- let sgAvailable = null;
618
+ /**
619
+ * Shared ast-grep availability across all slop runners, behind the transient-
620
+ * aware latch (#1476). This module-level memo carried the same shape `SgRunner`
621
+ * did — one failed sweep, including a timeout, disabled ast-grep for every slop
622
+ * runner for the life of the process.
623
+ */
624
+ const sgLatch = createAvailabilityLatch();
503
625
  let sgCmd = null;
504
626
  let sgCmdArgs = [];
627
+ /** Classification of the current sweep, accumulated across candidates. */
628
+ let sgSweepSawTransient = false;
629
+ let sgSweepTransientCause = "probe-timeout";
630
+ let sgSweepHostStallMs = 0;
505
631
  function isAstGrepVersionOutput(output) {
506
632
  return /\bast[- ]grep\b/i.test(output);
507
633
  }
508
634
  async function probeAstGrepCommandAsync(cmd, argsPrefix = []) {
509
- const check = await safeSpawnAsync(cmd, [...argsPrefix, "--version"], {
510
- timeout: 5000,
511
- });
512
- return (!check.error &&
635
+ const sampler = startHostStallSampler();
636
+ let check;
637
+ let hostStallMs;
638
+ try {
639
+ check = await safeSpawnAsync(cmd, [...argsPrefix, "--version"], {
640
+ timeout: 5000,
641
+ });
642
+ }
643
+ finally {
644
+ hostStallMs = sampler.stop();
645
+ sgSweepHostStallMs += hostStallMs;
646
+ }
647
+ if (!check.error &&
513
648
  check.status === 0 &&
514
- isAstGrepVersionOutput(`${check.stdout}\n${check.stderr}`));
649
+ isAstGrepVersionOutput(`${check.stdout}\n${check.stderr}`)) {
650
+ return true;
651
+ }
652
+ const { outcome, cause } = classifyProbeFailure(check, { hostStallMs });
653
+ if (outcome === "transient") {
654
+ sgSweepSawTransient = true;
655
+ sgSweepTransientCause = cause;
656
+ }
657
+ return false;
515
658
  }
516
659
  /** Pre-filter local node_modules/.bin candidates that actually exist on disk. */
517
660
  function buildSgLocalBins() {
@@ -545,7 +688,7 @@ let sgAvailabilityGeneration = availabilityStateGeneration;
545
688
  function ensureCurrentSgGeneration() {
546
689
  if (sgAvailabilityGeneration === availabilityStateGeneration)
547
690
  return;
548
- sgAvailable = null;
691
+ sgLatch.reset();
549
692
  sgCmd = null;
550
693
  sgCmdArgs = [];
551
694
  sgAvailableInFlight = null;
@@ -553,17 +696,24 @@ function ensureCurrentSgGeneration() {
553
696
  }
554
697
  export async function isSgAvailableAsync() {
555
698
  ensureCurrentSgGeneration();
556
- if (sgAvailable !== null)
557
- return sgAvailable;
699
+ // `read()` returns null when the last verdict was transient and its cooldown
700
+ // expired: re-probe rather than stay dead for the session (#1476).
701
+ const memo = sgLatch.read();
702
+ if (memo !== null)
703
+ return memo;
558
704
  if (sgAvailableInFlight)
559
705
  return sgAvailableInFlight;
560
706
  sgAvailableInFlight = (async () => {
707
+ const startedAt = Date.now();
708
+ sgSweepSawTransient = false;
709
+ sgSweepTransientCause = "probe-timeout";
710
+ sgSweepHostStallMs = 0;
561
711
  // 1. Local node_modules/.bin
562
712
  for (const localBin of buildSgLocalBins()) {
563
713
  if (await probeAstGrepCommandAsync(localBin)) {
564
714
  sgCmd = localBin;
565
715
  sgCmdArgs = [];
566
- sgAvailable = true;
716
+ noteSgAvailable(startedAt);
567
717
  return true;
568
718
  }
569
719
  }
@@ -572,7 +722,7 @@ export async function isSgAvailableAsync() {
572
722
  if (await probeAstGrepCommandAsync(cmd)) {
573
723
  sgCmd = cmd;
574
724
  sgCmdArgs = [];
575
- sgAvailable = true;
725
+ noteSgAvailable(startedAt);
576
726
  return true;
577
727
  }
578
728
  }
@@ -583,7 +733,7 @@ export async function isSgAvailableAsync() {
583
733
  if (globalBin && (await probeAstGrepCommandAsync(globalBin))) {
584
734
  sgCmd = globalBin;
585
735
  sgCmdArgs = [];
586
- sgAvailable = true;
736
+ noteSgAvailable(startedAt);
587
737
  return true;
588
738
  }
589
739
  }
@@ -591,16 +741,46 @@ export async function isSgAvailableAsync() {
591
741
  if (await probeAstGrepCommandAsync("npx", ["--no", "--", "ast-grep"])) {
592
742
  sgCmd = "npx";
593
743
  sgCmdArgs = ["--no", "--", "ast-grep"];
594
- sgAvailable = true;
744
+ noteSgAvailable(startedAt);
595
745
  return true;
596
746
  }
597
- sgAvailable = false;
747
+ // A timeout on ANY candidate is evidence about the host, not the tool.
748
+ noteSgUnavailable(startedAt, sgSweepSawTransient ? "transient" : "missing", sgSweepSawTransient ? sgSweepTransientCause : "not-found");
598
749
  return false;
599
750
  })().finally(() => {
600
751
  sgAvailableInFlight = null;
601
752
  });
602
753
  return sgAvailableInFlight;
603
754
  }
755
+ /** Record a successful shared-ast-grep sweep, with one decision record. */
756
+ function noteSgAvailable(startedAt) {
757
+ sgLatch.noteAvailable();
758
+ logAvailabilityDecision({
759
+ tool: "ast-grep",
760
+ verdict: "available",
761
+ outcome: "success",
762
+ cause: "ok",
763
+ elapsedMs: Date.now() - startedAt,
764
+ latched: true,
765
+ hostStallMs: sgSweepHostStallMs,
766
+ budgetMs: 5000,
767
+ });
768
+ }
769
+ /** Record a failed shared-ast-grep sweep; a transient verdict expires. */
770
+ function noteSgUnavailable(startedAt, outcome, cause) {
771
+ const retryAfterMs = sgLatch.noteUnavailable(outcome, cause);
772
+ logAvailabilityDecision({
773
+ tool: "ast-grep",
774
+ verdict: "unavailable",
775
+ outcome,
776
+ cause,
777
+ elapsedMs: Date.now() - startedAt,
778
+ latched: outcome !== "transient",
779
+ hostStallMs: sgSweepHostStallMs,
780
+ ...(retryAfterMs > 0 && { retryAfterMs }),
781
+ budgetMs: 5000,
782
+ });
783
+ }
604
784
  export function getSgCommand() {
605
785
  ensureCurrentSgGeneration();
606
786
  return {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared toolchain-availability lifecycle (#1476 Sonar follow-up).
3
+ *
4
+ * `candidate-probe.ts` factored out the PATH sweep the Go and Rust clients ran,
5
+ * but the LIFECYCLE around it stayed written twice: the transient-aware latch,
6
+ * the in-flight dedupe that keeps concurrent first-time callers to one sweep,
7
+ * the path memo, and the two `availability_decision` records. Two copies of one
8
+ * rule is how #1467's fix missed seven sites, so the rule lives here once and
9
+ * each client contributes only its configuration.
10
+ *
11
+ * Callers keep their own public method names — `isGoAvailableAsync`,
12
+ * `findCargoPathAsync` — because other modules call them; only the body moves.
13
+ */
14
+ import { createAvailabilityLatch, logAvailabilityDecision, } from "./availability-policy.js";
15
+ import { probeAvailabilityCandidates } from "./candidate-probe.js";
16
+ /**
17
+ * Own one toolchain's availability: sweep the platform candidate list, memoize
18
+ * the path that answered, and park the verdict behind the shared latch so a
19
+ * timed-out probe expires instead of latching "the toolchain is not installed"
20
+ * for the life of the process.
21
+ */
22
+ export function createToolchainAvailability(config) {
23
+ const availabilityLatch = createAvailabilityLatch();
24
+ let toolPath = null;
25
+ /** Classification of the candidate sweep, for the retry decision. */
26
+ let sweepSawTransient = false;
27
+ let sweepTransientCause = "probe-timeout";
28
+ let sweepHostStallMs = 0;
29
+ let ensureInFlight = null;
30
+ async function findPath() {
31
+ if (toolPath)
32
+ return toolPath;
33
+ const paths = process.platform === "win32" ? config.windowsPaths : config.unixPaths;
34
+ const sweep = await probeAvailabilityCandidates(paths, config.probeArgs, config.budgetMs);
35
+ sweepSawTransient = sweep.sawTransient;
36
+ sweepTransientCause = sweep.transientCause;
37
+ sweepHostStallMs = sweep.hostStallMs;
38
+ if (sweep.foundPath)
39
+ toolPath = sweep.foundPath;
40
+ return sweep.foundPath;
41
+ }
42
+ async function resolveAvailability() {
43
+ const startedAt = Date.now();
44
+ const found = (await findPath()) !== null;
45
+ if (found) {
46
+ availabilityLatch.noteAvailable();
47
+ config.log(`${config.label} found: ${toolPath}`);
48
+ logAvailabilityDecision({
49
+ tool: config.tool,
50
+ verdict: "available",
51
+ outcome: "success",
52
+ cause: "ok",
53
+ elapsedMs: Date.now() - startedAt,
54
+ latched: true,
55
+ hostStallMs: sweepHostStallMs,
56
+ budgetMs: config.budgetMs,
57
+ });
58
+ return true;
59
+ }
60
+ // A timed-out version probe is evidence about this moment, not about
61
+ // whether the toolchain is installed; it expires instead of latching.
62
+ const outcome = sweepSawTransient ? "transient" : "missing";
63
+ const cause = sweepSawTransient ? sweepTransientCause : "not-found";
64
+ const retryAfterMs = availabilityLatch.noteUnavailable(outcome, cause);
65
+ logAvailabilityDecision({
66
+ tool: config.tool,
67
+ verdict: "unavailable",
68
+ outcome,
69
+ cause,
70
+ elapsedMs: Date.now() - startedAt,
71
+ latched: outcome !== "transient",
72
+ hostStallMs: sweepHostStallMs,
73
+ ...(retryAfterMs > 0 && { retryAfterMs }),
74
+ budgetMs: config.budgetMs,
75
+ });
76
+ return false;
77
+ }
78
+ async function isAvailable() {
79
+ // `read()` returns null when the last verdict was transient and its
80
+ // cooldown expired, which re-enters the candidate sweep (#1476).
81
+ const memo = availabilityLatch.read();
82
+ if (memo !== null)
83
+ return memo;
84
+ // Now that a verdict can expire, concurrent callers arriving just after a
85
+ // cooldown must share ONE sweep rather than each spawning their own.
86
+ if (ensureInFlight)
87
+ return ensureInFlight;
88
+ ensureInFlight = resolveAvailability();
89
+ try {
90
+ return await ensureInFlight;
91
+ }
92
+ finally {
93
+ ensureInFlight = null;
94
+ }
95
+ }
96
+ return { findPath, isAvailable };
97
+ }
@@ -24,7 +24,7 @@
24
24
  import { logBusEvent } from "./bus-events-logger.js";
25
25
  import { isBusPublishEnabled } from "./bus-publish.js";
26
26
  import { normalizeFilePath } from "./path-utils.js";
27
- import { createLiveBusEmitter, recordStaleBusFailure, } from "./live-bus-emitter.js";
27
+ import { createLiveBusEmitter, recordStaleBusFailure, resolveLiveBusEmitter, } from "./live-bus-emitter.js";
28
28
  export const BUS_DISPOSITION_EVENT = "pilens:diagnostic:disposition";
29
29
  export const BUS_DISPOSITION_VERSION = 1;
30
30
  const liveEmitter = createLiveBusEmitter();
@@ -67,8 +67,13 @@ export function publishDisposition(args) {
67
67
  }
68
68
  return;
69
69
  }
70
- const busEmit = liveEmitter.get();
71
- if (!busEmit) {
70
+ const resolution = resolveLiveBusEmitter(liveEmitter, () => ({
71
+ event: BUS_DISPOSITION_EVENT,
72
+ cwd: normalizeFilePath(args.cwd),
73
+ }));
74
+ if (resolution.outcome === "stale-session")
75
+ return;
76
+ if (resolution.outcome === "unwired") {
72
77
  if (!hasLoggedUnwired) {
73
78
  hasLoggedUnwired = true;
74
79
  logBusEvent({
@@ -79,6 +84,7 @@ export function publishDisposition(args) {
79
84
  }
80
85
  return;
81
86
  }
87
+ const busEmit = resolution.emit;
82
88
  try {
83
89
  const payload = {
84
90
  v: BUS_DISPOSITION_VERSION,
@@ -106,6 +112,7 @@ export function publishDisposition(args) {
106
112
  outcome: "emit_failed",
107
113
  cwd: normalizeFilePath(args.cwd),
108
114
  error: String(err),
115
+ ctxSource: resolution.ctxSource,
109
116
  });
110
117
  if (!hasLoggedFailure) {
111
118
  hasLoggedFailure = true;
@@ -0,0 +1,17 @@
1
+ import { performance } from "node:perf_hooks";
2
+ /**
3
+ * The moment pi-lens's module graph started evaluating, captured as this
4
+ * module's first statement (#1374 host-boot vs extension-eval split).
5
+ *
6
+ * This module is otherwise side-effect-free by design (#1434 S3d review): it
7
+ * does nothing but capture a timestamp. `clients/console-guard-install.ts`
8
+ * imports it as its FIRST import so the capture happens before the guard's
9
+ * own install work, and `clients/startup-timing.ts` imports it too, for the
10
+ * constant alone. Before this split, `startup-timing.ts` imported the
11
+ * constant FROM `console-guard-install.ts` — which meant importing
12
+ * startup-timing anywhere (e.g. a test measuring load time) silently
13
+ * installed the console guard as a side effect. Splitting the constant out
14
+ * means importing this module, or `startup-timing.ts`, never installs
15
+ * anything.
16
+ */
17
+ export const PI_LENS_EVAL_STARTED_MS = performance.now();