@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
@@ -14,9 +14,32 @@
14
14
  */
15
15
  import { createSubsystemLogger } from "./extension-log.js";
16
16
  import { safeSpawnAsync } from "./safe-spawn.js";
17
+ import { classifyProbeFailure, createAvailabilityLatch, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
17
18
  export class SecurityScanClient {
18
19
  toolName;
19
- available = null;
20
+ /**
21
+ * Availability memo, backed by the shared transient-aware latch (#1467).
22
+ *
23
+ * Assigning `false` still means "durable: this machine does not have the
24
+ * tool" — every existing subclass write keeps its meaning. A probe that
25
+ * merely timed out must go through `markTransientlyUnavailable` instead, so
26
+ * it expires and the tool can come back without a host restart.
27
+ */
28
+ availabilityLatch = createAvailabilityLatch();
29
+ get available() {
30
+ return this.availabilityLatch.read();
31
+ }
32
+ set available(value) {
33
+ if (value === true)
34
+ this.availabilityLatch.noteAvailable();
35
+ else if (value === false)
36
+ this.availabilityLatch.noteUnavailable("missing", "not-found");
37
+ else
38
+ this.availabilityLatch.reset();
39
+ }
40
+ /** Outcome/cause of the most recent `probeVersion` call. */
41
+ lastProbeOutcome = null;
42
+ lastProbeCause = null;
20
43
  ensureInFlight = null;
21
44
  inFlight = new Map();
22
45
  binaryPath = null;
@@ -51,18 +74,71 @@ export class SecurityScanClient {
51
74
  }
52
75
  /**
53
76
  * Spawn `toolName <versionArgs>` and report whether it answered cleanly.
54
- * Does NOT mutate `this.available` — callers decide what a hit/miss means.
77
+ * Does NOT mutate `this.available` — callers decide what a hit/miss means —
78
+ * but it does classify the failure (`lastProbeOutcome`/`lastProbeCause`) and
79
+ * emit one availability-decision record, so a probe that keeps timing out is
80
+ * visible in latency.log rather than inferred from silence (#1467).
55
81
  */
56
82
  async probeVersion(versionArgs) {
57
- const probe = await safeSpawnAsync(this.toolName, versionArgs, {
58
- timeout: 5000,
59
- });
83
+ const sampler = startHostStallSampler();
84
+ const startedAt = Date.now();
85
+ let probe;
86
+ let hostStallMs;
87
+ try {
88
+ probe = await safeSpawnAsync(this.toolName, versionArgs, {
89
+ timeout: 5000,
90
+ });
91
+ }
92
+ finally {
93
+ hostStallMs = sampler.stop();
94
+ }
95
+ const elapsedMs = Date.now() - startedAt;
60
96
  if (!probe.error && probe.status === 0) {
97
+ this.lastProbeOutcome = "success";
98
+ this.lastProbeCause = "ok";
61
99
  this.log(`${this.toolName} found: ${probe.stdout.trim().split("\n")[0]}`);
100
+ logAvailabilityDecision({
101
+ tool: this.toolName,
102
+ verdict: "available",
103
+ outcome: "success",
104
+ cause: "ok",
105
+ elapsedMs,
106
+ latched: true,
107
+ hostStallMs,
108
+ budgetMs: 5000,
109
+ });
62
110
  return true;
63
111
  }
112
+ const { outcome, cause } = classifyProbeFailure(probe, { hostStallMs });
113
+ this.lastProbeOutcome = outcome;
114
+ this.lastProbeCause = cause;
115
+ logAvailabilityDecision({
116
+ tool: this.toolName,
117
+ verdict: "unavailable",
118
+ outcome,
119
+ cause,
120
+ elapsedMs,
121
+ latched: outcome !== "transient",
122
+ hostStallMs,
123
+ budgetMs: 5000,
124
+ });
64
125
  return false;
65
126
  }
127
+ /** True when the last probe failed for a reason that is not the tool's fault. */
128
+ probeWasTransient() {
129
+ return this.lastProbeOutcome === "transient";
130
+ }
131
+ /**
132
+ * Record a non-durable unavailability: the verdict expires after a cooldown
133
+ * and the next `ensureAvailable` re-probes.
134
+ *
135
+ * Returns that cooldown in ms so the caller can put it in its decision
136
+ * record. A latch you can read in `latency.log` without the retry schedule
137
+ * beside it only tells you the tool is off, not when it comes back.
138
+ */
139
+ markTransientlyUnavailable(cause = "probe-timeout") {
140
+ return this.availabilityLatch.noteUnavailable("transient", cause);
141
+ }
66
142
  /**
67
143
  * Standard availability path for the GitHub-release tools (gitleaks, trivy):
68
144
  * PATH probe first, then fall back to the pi-lens installer's `ensureTool`.
@@ -73,6 +149,13 @@ export class SecurityScanClient {
73
149
  this.available = true;
74
150
  return true;
75
151
  }
152
+ if (this.probeWasTransient()) {
153
+ // A timed-out probe is not evidence the tool is absent, so it must not
154
+ // trigger an install NOR latch a permanent `false`.
155
+ this.log(`${this.toolName} availability probe timed out; not installing, will retry`);
156
+ this.markTransientlyUnavailable(this.lastProbeCause ?? "probe-timeout");
157
+ return false;
158
+ }
76
159
  this.log(`${this.toolName} not found, attempting auto-install`);
77
160
  const { ensureTool } = await import("./installer/index.js");
78
161
  const installed = await ensureTool(this.toolName);
@@ -12,6 +12,7 @@ import { getSgCommand, resolveManagedToolClient, } from "./dispatch/runners/util
12
12
  import { getProjectIgnoreGlobs } from "./file-utils.js";
13
13
  import { findGlobalBinary } from "./package-manager.js";
14
14
  import { safeSpawnAsync } from "./safe-spawn.js";
15
+ import { classifyProbeFailure, createAvailabilityLatch, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
15
16
  /**
16
17
  * Build the `bash -c` argv that runs `cmd` with `allArgs` as POSITIONAL
17
18
  * parameters. The script is the constant `"$0" "$@"`, so bash re-emits the
@@ -33,6 +34,8 @@ function sgExcludeArgsForProject(rootDir) {
33
34
  ]);
34
35
  }
35
36
  const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
37
+ /** Budget for a single `--version` candidate probe, ms. */
38
+ const PROBE_TIMEOUT_MS = 5_000;
36
39
  const MAX_SG_OUTPUT_BYTES = 8 * 1024 * 1024;
37
40
  /**
38
41
  * Format metavariable captures for display below a match line.
@@ -94,8 +97,31 @@ export class SgRunner {
94
97
  log;
95
98
  sgPath = null;
96
99
  sgArgsPrefix = [];
97
- available = null;
100
+ /**
101
+ * Availability memo, backed by the shared transient-aware latch (#1476).
102
+ *
103
+ * Before this, one failed sweep over every candidate — timeouts included —
104
+ * latched `false` for the life of the process AND ran a full auto-install
105
+ * first. A host stall during warm-up therefore both disabled ast-grep until
106
+ * restart and paid for an install nobody needed.
107
+ */
108
+ availabilityLatch = createAvailabilityLatch();
98
109
  ensureInFlight = null;
110
+ /**
111
+ * Whether a DIRECT candidate — one that would have been ast-grep itself —
112
+ * failed for a transient reason in the current sweep. Only these block the
113
+ * install: a slow `npx` says nothing about whether ast-grep is on this
114
+ * machine, and letting it block turned "ast-grep is genuinely absent on a
115
+ * slow host" into "ast-grep is never installed and npx is re-spawned every
116
+ * sweep, forever".
117
+ */
118
+ sweepSawTransient = false;
119
+ sweepTransientCause = "probe-timeout";
120
+ /** A transient on the npx fallback: not evidence, but not nothing either. */
121
+ sweepFallbackTransient = false;
122
+ sweepFallbackCause = "probe-timeout";
123
+ /** Host stall summed over every probe of the current sweep, ms. */
124
+ sweepHostStallMs = 0;
99
125
  constructor(verbose = false) {
100
126
  this.log = verbose
101
127
  ? createSubsystemLogger("sg-runner")
@@ -110,9 +136,11 @@ export class SgRunner {
110
136
  * `KnipClient.ensureAvailable` and `DependencyChecker.ensureAvailable`.
111
137
  */
112
138
  async ensureAvailable() {
113
- // Fast path: already checked.
114
- if (this.available !== null)
115
- return this.available;
139
+ // Fast path: already decided. `read()` returns null when the last verdict
140
+ // was transient and its cooldown expired, which re-enters the sweep.
141
+ const memo = this.availabilityLatch.read();
142
+ if (memo !== null)
143
+ return memo;
116
144
  if (this.ensureInFlight)
117
145
  return this.ensureInFlight;
118
146
  this.ensureInFlight = this.doEnsureAvailable();
@@ -124,18 +152,26 @@ export class SgRunner {
124
152
  }
125
153
  }
126
154
  async doEnsureAvailable() {
155
+ const startedAt = Date.now();
156
+ this.sweepSawTransient = false;
157
+ this.sweepTransientCause = "probe-timeout";
158
+ this.sweepFallbackTransient = false;
159
+ this.sweepFallbackCause = "probe-timeout";
160
+ this.sweepHostStallMs = 0;
127
161
  // Step 1: PATH — canonical binary names + npx fallback.
128
162
  // Prefer ast-grep over sg on Linux: /usr/bin/sg is util-linux, not ast-grep.
129
163
  const pathCommand = await this.probeCommandCandidates([
130
164
  { cmd: "ast-grep", argsPrefix: [] },
131
165
  { cmd: "sg", argsPrefix: [] },
132
- { cmd: "npx", argsPrefix: ["--no", "--", "ast-grep"] },
166
+ // `npx --no -- ast-grep` starts a Node process before it can answer, so
167
+ // it is the candidate most likely to blow a 5 s budget on a cold or busy
168
+ // box. Marked a fallback so its timeout cannot veto the install.
169
+ { cmd: "npx", argsPrefix: ["--no", "--", "ast-grep"], fallback: true },
133
170
  ]);
134
171
  if (pathCommand) {
135
172
  this.sgPath = pathCommand.cmd;
136
173
  this.sgArgsPrefix = pathCommand.argsPrefix;
137
- this.available = true;
138
- this.log(`ast-grep found on PATH: ${pathCommand.cmd}`);
174
+ this.noteAvailable(startedAt, `ast-grep found on PATH: ${pathCommand.cmd}`);
139
175
  return true;
140
176
  }
141
177
  // Step 1b: any package manager's global bin dir (npm/pnpm/yarn/bun).
@@ -147,8 +183,7 @@ export class SgRunner {
147
183
  if (globalBin && (await this.probeCommand(globalBin, []))) {
148
184
  this.sgPath = globalBin;
149
185
  this.sgArgsPrefix = [];
150
- this.available = true;
151
- this.log(`ast-grep found in global bin: ${globalBin}`);
186
+ this.noteAvailable(startedAt, `ast-grep found in global bin: ${globalBin}`);
152
187
  return true;
153
188
  }
154
189
  }
@@ -159,8 +194,7 @@ export class SgRunner {
159
194
  if (platformBinary) {
160
195
  this.sgPath = platformBinary;
161
196
  this.sgArgsPrefix = [];
162
- this.available = true;
163
- this.log(`ast-grep found via platform package: ${platformBinary}`);
197
+ this.noteAvailable(startedAt, `ast-grep found via platform package: ${platformBinary}`);
164
198
  return true;
165
199
  }
166
200
  // Step 3: Homebrew (macOS only).
@@ -169,11 +203,24 @@ export class SgRunner {
169
203
  if (brewBinary) {
170
204
  this.sgPath = brewBinary;
171
205
  this.sgArgsPrefix = [];
172
- this.available = true;
173
- this.log(`ast-grep found via Homebrew: ${brewBinary}`);
206
+ this.noteAvailable(startedAt, `ast-grep found via Homebrew: ${brewBinary}`);
174
207
  return true;
175
208
  }
176
209
  }
210
+ // A timeout on a DIRECT candidate means the machine answered, not the tool
211
+ // (#1476). Installing ast-grep because the host event loop stalled is a
212
+ // heavyweight reaction to a hiccup, and latching the result disabled the
213
+ // tool until restart. Retry the sweep later instead.
214
+ //
215
+ // The npx fallback is deliberately NOT part of this test. Gating the
216
+ // install on it regressed the very host this change targets: a slow box
217
+ // with no ast-grep timed out `npx --no -- ast-grep` every sweep, so the
218
+ // install below was never reached and the slow npx was re-spawned on each
219
+ // escalating retry instead — worse than the latch it replaced.
220
+ if (this.sweepSawTransient) {
221
+ this.log("ast-grep availability probe timed out; will retry (not installing)");
222
+ return this.noteUnavailable(startedAt, "transient", this.sweepTransientCause);
223
+ }
177
224
  // Step 4: install via the typed shared seam, then validate the returned
178
225
  // absolute binary before publishing it.
179
226
  const installed = await resolveManagedToolClient({
@@ -185,11 +232,51 @@ export class SgRunner {
185
232
  if (installed.outcome === "success") {
186
233
  this.sgPath = installed.value;
187
234
  this.sgArgsPrefix = [];
188
- this.available = true;
189
- this.log(`ast-grep auto-installed: ${installed.value}`);
235
+ this.noteAvailable(startedAt, `ast-grep auto-installed: ${installed.value}`);
190
236
  return true;
191
237
  }
192
- this.available = false;
238
+ // The install failed AND the npx fallback never got a fair hearing, so
239
+ // "ast-grep is not installed" is not a fact yet. Expire the verdict.
240
+ if (this.sweepFallbackTransient) {
241
+ return this.noteUnavailable(startedAt, "transient", this.sweepFallbackCause);
242
+ }
243
+ return this.noteUnavailable(startedAt, "missing", "not-found");
244
+ }
245
+ /** Record a successful sweep: available, latched, one decision record. */
246
+ noteAvailable(startedAt, message) {
247
+ this.availabilityLatch.noteAvailable();
248
+ this.log(message);
249
+ logAvailabilityDecision({
250
+ tool: "ast-grep",
251
+ verdict: "available",
252
+ outcome: "success",
253
+ cause: "ok",
254
+ elapsedMs: Date.now() - startedAt,
255
+ latched: true,
256
+ hostStallMs: this.sweepHostStallMs,
257
+ budgetMs: PROBE_TIMEOUT_MS,
258
+ });
259
+ }
260
+ /**
261
+ * Record a failed sweep. A `transient` outcome expires after a bounded
262
+ * cooldown; anything else is remembered for the session, as before. The
263
+ * elapsed/stall numbers describe the WHOLE sweep, because the verdict is
264
+ * about the sweep rather than any single candidate — reporting zero here
265
+ * would erase the evidence that cracked #1467.
266
+ */
267
+ noteUnavailable(startedAt, outcome, cause) {
268
+ const retryAfterMs = this.availabilityLatch.noteUnavailable(outcome, cause);
269
+ logAvailabilityDecision({
270
+ tool: "ast-grep",
271
+ verdict: "unavailable",
272
+ outcome,
273
+ cause,
274
+ elapsedMs: Date.now() - startedAt,
275
+ latched: outcome !== "transient",
276
+ hostStallMs: this.sweepHostStallMs,
277
+ ...(retryAfterMs > 0 && { retryAfterMs }),
278
+ budgetMs: PROBE_TIMEOUT_MS,
279
+ });
193
280
  return false;
194
281
  }
195
282
  /**
@@ -270,17 +357,48 @@ export class SgRunner {
270
357
  isAstGrepVersionOutput(output) {
271
358
  return /\bast[- ]grep\b/i.test(output);
272
359
  }
273
- async probeCommand(cmd, argsPrefix) {
274
- const result = await safeSpawnAsync(cmd, [...argsPrefix, "--version"], {
275
- timeout: 5000,
276
- });
277
- return (!result.error &&
360
+ /**
361
+ * Probe one candidate. A failure is CLASSIFIED, not merely counted: a
362
+ * timeout/abort marks the whole sweep transient so the caller retries later
363
+ * instead of installing and latching. A command that answers but is not
364
+ * ast-grep (Linux `/usr/bin/sg` is util-linux) is a durable no.
365
+ */
366
+ async probeCommand(cmd, argsPrefix, fallback = false) {
367
+ // Host-side budget: measure the loop stall that overlapped the window so
368
+ // the classifier can tell "the tool is slow" from "the host was busy".
369
+ const sampler = startHostStallSampler();
370
+ let result;
371
+ let hostStallMs;
372
+ try {
373
+ result = await safeSpawnAsync(cmd, [...argsPrefix, "--version"], {
374
+ timeout: PROBE_TIMEOUT_MS,
375
+ });
376
+ }
377
+ finally {
378
+ hostStallMs = sampler.stop();
379
+ this.sweepHostStallMs += hostStallMs;
380
+ }
381
+ if (!result.error &&
278
382
  result.status === 0 &&
279
- this.isAstGrepVersionOutput(`${result.stdout}\n${result.stderr}`));
383
+ this.isAstGrepVersionOutput(`${result.stdout}\n${result.stderr}`)) {
384
+ return true;
385
+ }
386
+ const { outcome, cause } = classifyProbeFailure(result, { hostStallMs });
387
+ if (outcome === "transient") {
388
+ if (fallback) {
389
+ this.sweepFallbackTransient = true;
390
+ this.sweepFallbackCause = cause;
391
+ }
392
+ else {
393
+ this.sweepSawTransient = true;
394
+ this.sweepTransientCause = cause;
395
+ }
396
+ }
397
+ return false;
280
398
  }
281
399
  async probeCommandCandidates(candidates) {
282
400
  for (const candidate of candidates) {
283
- if (await this.probeCommand(candidate.cmd, candidate.argsPrefix)) {
401
+ if (await this.probeCommand(candidate.cmd, candidate.argsPrefix, candidate.fallback ?? false)) {
284
402
  return candidate;
285
403
  }
286
404
  }
@@ -24,14 +24,19 @@
24
24
  * alongside.
25
25
  *
26
26
  * The tail scan is bounded, but the tail can contain prior-session rows. The
27
- * session-start caller supplies the session boundary, and rows are admitted
28
- * only when their own UTC timestamp is at or after that boundary. This keeps
29
- * the same bounded read without reporting historical failures as current, so
30
- * this module does not additionally instrument the write call sites
31
- * (`bus-publish.ts`, `clients/lsp/index.ts`'s respawn path) with parallel
32
- * live counters. That keeps the change contained to one new module + three
33
- * small call sites (session_start, `/lens-health`, `turn_end`) instead of
34
- * touching every producer of the two source logs.
27
+ * `session_start` and `turn_end` callers supply the in-process session
28
+ * boundary (`runtime.sessionStartedAt`), and rows are admitted only when
29
+ * their own UTC timestamp is at or after that boundary. `/lens-health` has
30
+ * no per-session caller to anchor to (it can run at any point, including
31
+ * outside a turn) so it omits `sessionStartMs` and falls back to
32
+ * `SMELLS_ROLLING_WINDOW_MS` (24h) — its rendered line is labeled "last 24h
33
+ * tail-scan" to say so explicitly rather than implying a session-scoped
34
+ * count it isn't. This keeps the same bounded read without reporting
35
+ * historical failures as current, so this module does not additionally
36
+ * instrument the write call sites (`bus-publish.ts`, `clients/lsp/index.ts`'s
37
+ * respawn path) with parallel live counters. That keeps the change contained
38
+ * to one new module + three small call sites (session_start, `/lens-health`,
39
+ * `turn_end`) instead of touching every producer of the two source logs.
35
40
  *
36
41
  * Smells covered (deliberately a SUBSET — the two the issue named as having
37
42
  * gone unnoticed; the full catalogue stays in the manual analyzer):
@@ -53,6 +58,7 @@ import * as path from "node:path";
53
58
  import { getGlobalPiLensDir } from "./file-utils.js";
54
59
  /** Bounded tail-read budget PER source log file — never a full-file scan. */
55
60
  export const SMELLS_TAIL_BYTES_PER_FILE = 64 * 1024;
61
+ export const SMELLS_ROLLING_WINDOW_MS = 24 * 60 * 60_000;
56
62
  /** Re-check cadence at `turn_end`, mirroring `memory-sampler.ts`'s pattern —
57
63
  * cheap enough (bounded ~128KB I/O) not to need finer throttling, but a
58
64
  * fixed interval keeps it from running on every single turn. */
@@ -154,11 +160,12 @@ function isOpengrepRespawn(entry) {
154
160
  * up to ~10MB) source files are. `root` is injectable for tests.
155
161
  */
156
162
  export function countRecentSmells(root = getGlobalPiLensDir(), sessionStartMs) {
163
+ const sinceMs = sessionStartMs ?? Date.now() - SMELLS_ROLLING_WINDOW_MS;
157
164
  const busTail = tailReadText(path.join(root, "bus-events.log"), SMELLS_TAIL_BYTES_PER_FILE);
158
165
  const latencyTail = tailReadText(path.join(root, "latency.log"), SMELLS_TAIL_BYTES_PER_FILE);
159
166
  return {
160
- staleCtxEmitFailed: countMatchingLines(busTail, isStaleCtxEmitFailed, sessionStartMs),
161
- opengrepRespawn: countMatchingLines(latencyTail, isOpengrepRespawn, sessionStartMs),
167
+ staleCtxEmitFailed: countMatchingLines(busTail, isStaleCtxEmitFailed, sinceMs),
168
+ opengrepRespawn: countMatchingLines(latencyTail, isOpengrepRespawn, sinceMs),
162
169
  };
163
170
  }
164
171
  /**
@@ -186,7 +193,7 @@ export function formatSmellsSessionStartLine(counts) {
186
193
  * everything is below threshold).
187
194
  */
188
195
  export function formatSmellsHealthLine(counts) {
189
- return (`Smells (recent tail-scan): stale-ctx emit_failed=${counts.staleCtxEmitFailed}` +
196
+ return (`Smells (last 24h tail-scan): stale-ctx emit_failed=${counts.staleCtxEmitFailed}` +
190
197
  ` · opengrep respawn=${counts.opengrepRespawn}`);
191
198
  }
192
199
  /** `true` on turn 20, 40, 60, ... — never turn 0. Pure so the cadence is
@@ -1,5 +1,11 @@
1
1
  import { performance } from "node:perf_hooks";
2
- import { PI_LENS_EVAL_STARTED_MS } from "./console-guard-install.js";
2
+ // #1434 S3d: import the constant from the side-effect-free module, not from
3
+ // `console-guard-install.ts` — that module installs the console guard as an
4
+ // import side effect, so importing it here (for a single constant, from a
5
+ // module measuring load time) would have silently installed the guard
6
+ // wherever this file is imported, including test-only measurement code that
7
+ // has no business flipping a global switch.
8
+ import { PI_LENS_EVAL_STARTED_MS } from "./eval-timestamp.js";
3
9
  /**
4
10
  * Startup timing for pi-lens.
5
11
  *