@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,10 +15,30 @@ import { getGlobalPiLensDir } from "./file-utils.js";
15
15
  import { findGlobalBinary } from "./package-manager.js";
16
16
  import { safeSpawnAsync } from "./safe-spawn.js";
17
17
  import { biomeConfigArgs } from "./tool-policy.js";
18
- import { resolveManagedToolClient } from "./dispatch/runners/utils/runner-helpers.js";
18
+ import { resolveManagedToolClient, } from "./dispatch/runners/utils/runner-helpers.js";
19
+ import { classifyProbeFailure, createAvailabilityLatch, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
19
20
  // --- Client ---
21
+ const PROBE_TIMEOUT_MS = 10_000;
20
22
  export class BiomeClient {
21
- biomeAvailable = null;
23
+ /**
24
+ * Availability memo, backed by the shared transient-aware latch (#1476).
25
+ *
26
+ * Biome is the hot-path formatter: before this, ANY probe failure — a
27
+ * timeout included — collapsed to `{ outcome: "missing" }`, which both
28
+ * triggered an install and latched `false` for the life of the process. One
29
+ * slow first format then disabled formatting until restart.
30
+ */
31
+ availabilityLatch = createAvailabilityLatch();
32
+ /** Cause of the last probe, read when the resolution comes back. */
33
+ lastProbeCause = null;
34
+ /**
35
+ * Measurements of the last failed probe, held until the latch decides how
36
+ * long the verdict lasts. The decision record is emitted after that, so it
37
+ * can carry `retryAfterMs` — a latch you can see is worth little if the
38
+ * retry schedule is not greppable beside it (#1474).
39
+ */
40
+ lastProbeElapsedMs = 0;
41
+ lastProbeHostStallMs = 0;
22
42
  // Per-cwd cache of the resolved biome binary. Keying by cwd matters in
23
43
  // monorepos where different sub-packages each ship their own biome
24
44
  // installation; sharing one slot across the whole client would cause
@@ -100,10 +120,15 @@ export class BiomeClient {
100
120
  * `ensureInFlight` promise so probing/auto-install isn't duplicated.
101
121
  * Mirrors the dedupe pattern in `SgRunner` / `KnipClient` /
102
122
  * `DependencyChecker`.
123
+ *
124
+ * The memo returns `null` when the last verdict was transient and its
125
+ * cooldown expired, which re-enters the probe: "biome is not installed" is a
126
+ * fact worth caching, "the probe timed out" is a moment worth retrying.
103
127
  */
104
128
  async ensureAvailable() {
105
- if (this.biomeAvailable !== null)
106
- return this.biomeAvailable;
129
+ const memo = this.availabilityLatch.read();
130
+ if (memo !== null)
131
+ return memo;
107
132
  if (this.ensureInFlight)
108
133
  return this.ensureInFlight;
109
134
  this.ensureInFlight = this.doEnsureAvailable();
@@ -114,23 +139,106 @@ export class BiomeClient {
114
139
  this.ensureInFlight = null;
115
140
  }
116
141
  }
142
+ /**
143
+ * Probe `biome --version` and classify the failure through the shared
144
+ * policy. Only a durable verdict (`missing` / `non-installable`) may reach
145
+ * the install branch of `resolveManagedToolClient`; a transient one returns
146
+ * as `transient`, which that seam passes straight back without installing.
147
+ */
148
+ async probeBiome() {
149
+ // The probe budget is enforced by a HOST-side timer, so a stalled event
150
+ // loop expires it while the child is still healthy. Measure the stall
151
+ // that overlapped the window and let the classifier see it (#1467).
152
+ const sampler = startHostStallSampler();
153
+ const startedAt = Date.now();
154
+ let result;
155
+ let hostStallMs;
156
+ try {
157
+ result = await this.spawnBiomeAsync(["--version"], PROBE_TIMEOUT_MS);
158
+ }
159
+ finally {
160
+ hostStallMs = sampler.stop();
161
+ }
162
+ const elapsedMs = Date.now() - startedAt;
163
+ if (!result.error && result.status === 0) {
164
+ this.lastProbeCause = "ok";
165
+ logAvailabilityDecision({
166
+ tool: "biome",
167
+ verdict: "available",
168
+ outcome: "success",
169
+ cause: "ok",
170
+ elapsedMs,
171
+ latched: true,
172
+ hostStallMs,
173
+ budgetMs: PROBE_TIMEOUT_MS,
174
+ });
175
+ return { outcome: "success", value: true };
176
+ }
177
+ // `unclassifiedFailureOutcome: "missing"` preserves the pre-#1476
178
+ // meaning of a plain non-zero exit (npx reporting no biome package):
179
+ // still "missing", still installable. Only the timeout/abort arm changes.
180
+ const { outcome, cause } = classifyProbeFailure(result, {
181
+ hostStallMs,
182
+ unclassifiedFailureOutcome: "missing",
183
+ });
184
+ // `classifyProbeFailure` is typed over the full outcome union but never
185
+ // returns "success" for a failed probe; narrow it for the seam's type.
186
+ const failureOutcome = outcome === "success" ? "non-installable" : outcome;
187
+ this.lastProbeCause = cause;
188
+ this.lastProbeElapsedMs = elapsedMs;
189
+ this.lastProbeHostStallMs = hostStallMs;
190
+ // The record is emitted by `doEnsureAvailable`, once the latch has said how
191
+ // long this verdict holds.
192
+ return { outcome: failureOutcome };
193
+ }
117
194
  async doEnsureAvailable() {
118
- const outcome = await resolveManagedToolClient({
195
+ const resolved = await resolveManagedToolClient({
119
196
  toolId: "biome",
120
197
  cwd: process.cwd(),
121
- probe: async () => {
122
- const result = await this.spawnBiomeAsync(["--version"], 10000);
123
- return !result.error && result.status === 0
124
- ? { outcome: "success", value: true }
125
- : { outcome: "missing" };
126
- },
198
+ probe: () => this.probeBiome(),
127
199
  acceptInstalled: (installedPath) => {
128
200
  this.autoInstalledBinaryPath = installedPath;
129
201
  return true;
130
202
  },
131
203
  });
132
- this.biomeAvailable = outcome.outcome === "success";
133
- return this.biomeAvailable;
204
+ if (resolved.outcome === "success") {
205
+ this.availabilityLatch.noteAvailable();
206
+ // The probe itself logs a clean hit. This arm is the other way to
207
+ // succeed — the install repaired a durable miss — and it needs its own
208
+ // record, or "biome went missing and came back" reads as silence.
209
+ if (this.lastProbeCause !== null && this.lastProbeCause !== "ok") {
210
+ logAvailabilityDecision({
211
+ tool: "biome",
212
+ verdict: "available",
213
+ outcome: "success",
214
+ cause: "ok",
215
+ elapsedMs: this.lastProbeElapsedMs,
216
+ latched: true,
217
+ hostStallMs: this.lastProbeHostStallMs,
218
+ budgetMs: PROBE_TIMEOUT_MS,
219
+ });
220
+ }
221
+ return true;
222
+ }
223
+ // A transient probe expires; a durable verdict is remembered for the
224
+ // session exactly as before.
225
+ const cause = this.lastProbeCause ?? "not-found";
226
+ const retryAfterMs = this.availabilityLatch.noteUnavailable(resolved.outcome, cause);
227
+ logAvailabilityDecision({
228
+ tool: "biome",
229
+ verdict: "unavailable",
230
+ outcome: resolved.outcome,
231
+ cause,
232
+ elapsedMs: this.lastProbeElapsedMs,
233
+ latched: resolved.outcome !== "transient",
234
+ hostStallMs: this.lastProbeHostStallMs,
235
+ ...(retryAfterMs > 0 && { retryAfterMs }),
236
+ budgetMs: PROBE_TIMEOUT_MS,
237
+ });
238
+ if (resolved.outcome === "transient") {
239
+ this.log("biome availability probe timed out; will retry (not installing)");
240
+ }
241
+ return false;
134
242
  }
135
243
  /**
136
244
  * Check if a file is supported by Biome
@@ -1,13 +1,20 @@
1
1
  /**
2
2
  * Persistent NDJSON trace of `pi.events` bus publish attempts
3
3
  * (`pilens:files:touched` #482 / `pilens:diagnostics` #502 /
4
- * `pilens:format:queued` + `pilens:format:start` #673).
4
+ * `pilens:diagnostic:disposition` / `pilens:format:queued` +
5
+ * `pilens:format:start` + `pilens:autofix:start` #673/#684 /
6
+ * `pi-lens/analysis-complete` + `pi-lens/findings` + `pi-lens/turn-findings`
7
+ * #1415) — nine event names across five producers.
5
8
  *
6
- * All four producers (clients/bus-publish.ts, clients/diagnostics-publish.ts,
7
- * clients/format-events-publish.ts) are fire-and-forget: on failure or on a
8
- * structural no-op (never wired, kill switch off) they only invoke an
9
- * optional `dbg` callback, which varies by host and is a documented no-op in
10
- * the MCP host (clients/mcp/session.ts's `dbg: noop`). That leaves
9
+ * All five producers (clients/bus-publish.ts, clients/diagnostics-publish.ts,
10
+ * clients/disposition-publish.ts, clients/format-events-publish.ts,
11
+ * clients/lens-events.ts) are fire-and-forget: on failure or on a structural
12
+ * no-op (never wired, kill switch off) the four #482/#502/#673/#684
13
+ * producers only invoke an optional `dbg` callback, which varies by host and
14
+ * is a documented no-op in the MCP host (clients/mcp/session.ts's
15
+ * `dbg: noop`); `lens-events.ts` (#1415) has no `dbg` param at all — its
16
+ * events are purely observational inter-extension telemetry, so this NDJSON
17
+ * trace is its ONLY failure-visible surface. Either way, that leaves
11
18
  * bus-publish outcomes invisible in exactly the context where they matter
12
19
  * most — same failure shape as the #544 MCP session_start incident this repo
13
20
  * already fixed once.
@@ -26,6 +33,9 @@
26
33
  * spam one identical line per write batch for an entire session with zero
27
34
  * new information after the first. Both are gated log-once-per-process, the
28
35
  * same `hasLoggedFailure` shape the producers already use for emit_failed.
36
+ * `skipped_stale_session` is an info-level per-occurrence outcome: the emit
37
+ * seam intentionally declined a target whose associated ctx was confirmed
38
+ * stale, so it is neither a bus failure nor input for the smells rollup.
29
39
  * The empty-batch branch (`paths.length === 0` / `files.length === 0`) is
30
40
  * NOT logged at all: every call site already guards against invoking these
31
41
  * functions with nothing to report (see clients/pipeline.ts,
@@ -37,17 +47,63 @@ import { isTestMode } from "./env-utils.js";
37
47
  import { getGlobalPiLensDir } from "./file-utils.js";
38
48
  import { createNdjsonLogger } from "./ndjson-logger.js";
39
49
  import { getMaxLogSizeMB } from "./log-cleanup.js";
50
+ import { logLatency } from "./latency-logger.js";
40
51
  const BUS_EVENTS_LOG_FILE = path.join(getGlobalPiLensDir(), "bus-events.log");
41
52
  const writer = createNdjsonLogger({
42
53
  filePath: BUS_EVENTS_LOG_FILE,
43
54
  maxBytes: getMaxLogSizeMB() * 1024 * 1024,
44
55
  });
56
+ const eventRollupCounts = new Map();
57
+ function bumpRollupCount(event, outcome) {
58
+ const existing = eventRollupCounts.get(event) ?? {
59
+ emitted: 0,
60
+ skipped_stale_session: 0,
61
+ emit_failed: 0,
62
+ };
63
+ existing[outcome] += 1;
64
+ eventRollupCounts.set(event, existing);
65
+ }
45
66
  export function logBusEvent(entry) {
67
+ if (entry.outcome === "emitted" ||
68
+ entry.outcome === "skipped_stale_session" ||
69
+ entry.outcome === "emit_failed") {
70
+ bumpRollupCount(entry.event, entry.outcome);
71
+ }
46
72
  if (isTestMode()) {
47
73
  return;
48
74
  }
49
75
  writer.log({ ts: new Date().toISOString(), ...entry });
50
76
  }
77
+ /** Snapshot the current session's rollup, keyed by event name. Non-mutating —
78
+ * pair with {@link resetBusEventRollupCounts} at session end. */
79
+ export function getBusEventRollupCounts() {
80
+ return Object.fromEntries(eventRollupCounts);
81
+ }
82
+ /** Clear the rollup — call once the session-end snapshot has been logged
83
+ * (or from tests) so a new session starts from zero. */
84
+ export function resetBusEventRollupCounts() {
85
+ eventRollupCounts.clear();
86
+ }
87
+ /**
88
+ * Log one `session_end_bus_rollup` latency row per event NAME that had any
89
+ * activity this session, then clear the rollup. Called from index.ts's
90
+ * `session_shutdown` handler (the session-end hook this repo's other
91
+ * teardown work — LSP fleet shutdown, cache-prefix eviction — already runs
92
+ * from). A no-op (logs nothing) when nothing was ever published, matching
93
+ * `formatSmellsSessionStartLine`'s "no noise on an ordinary session" shape.
94
+ */
95
+ export function emitBusEventRollupAtSessionEnd(cwd) {
96
+ for (const [event, counts] of eventRollupCounts) {
97
+ logLatency({
98
+ type: "phase",
99
+ phase: "session_end_bus_rollup",
100
+ filePath: cwd,
101
+ durationMs: 0,
102
+ metadata: { event, ...counts },
103
+ });
104
+ }
105
+ resetBusEventRollupCounts();
106
+ }
51
107
  export function getBusEventsLogPath() {
52
108
  return BUS_EVENTS_LOG_FILE;
53
109
  }
@@ -20,7 +20,7 @@
20
20
  import { logBusEvent } from "./bus-events-logger.js";
21
21
  import { normalizeFilePath } from "./path-utils.js";
22
22
  import { appendRecentTouches } from "./recent-touches.js";
23
- import { createLiveBusEmitter, recordStaleBusFailure, } from "./live-bus-emitter.js";
23
+ import { createLiveBusEmitter, recordStaleBusFailure, resolveLiveBusEmitter, } from "./live-bus-emitter.js";
24
24
  export const BUS_FILES_TOUCHED_EVENT = "pilens:files:touched";
25
25
  export const BUS_FILES_TOUCHED_VERSION = 1;
26
26
  const liveEmitter = createLiveBusEmitter();
@@ -106,8 +106,14 @@ export function publishFilesTouched(args) {
106
106
  }).catch((err) => {
107
107
  args.dbg?.(`bus-publish: recent-touches append failed: ${err}`);
108
108
  });
109
- const busEmit = liveEmitter.get();
110
- if (!busEmit) {
109
+ const resolution = resolveLiveBusEmitter(liveEmitter, () => ({
110
+ event: BUS_FILES_TOUCHED_EVENT,
111
+ cwd: normalizeFilePath(args.cwd),
112
+ reason: args.reason,
113
+ }));
114
+ if (resolution.outcome === "stale-session")
115
+ return;
116
+ if (resolution.outcome === "unwired") {
111
117
  if (!hasLoggedUnwired) {
112
118
  hasLoggedUnwired = true;
113
119
  logBusEvent({
@@ -119,6 +125,7 @@ export function publishFilesTouched(args) {
119
125
  }
120
126
  return;
121
127
  }
128
+ const busEmit = resolution.emit;
122
129
  try {
123
130
  const payload = {
124
131
  v: BUS_FILES_TOUCHED_VERSION,
@@ -150,6 +157,7 @@ export function publishFilesTouched(args) {
150
157
  cwd: normalizeFilePath(args.cwd),
151
158
  reason: args.reason,
152
159
  error: String(err),
160
+ ctxSource: resolution.ctxSource,
153
161
  });
154
162
  if (!hasLoggedFailure) {
155
163
  hasLoggedFailure = true;
@@ -1,10 +1,14 @@
1
+ import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
1
2
  import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
2
3
  export function formatCascadeNeighborDiagnostics(cwd, neighbors, options = {}) {
3
4
  const withErrors = neighbors.filter((n) => n.diagnostics.length > 0);
4
- if (withErrors.length === 0)
5
+ const inconclusive = neighbors.filter((n) => n.inconclusive === true && n.diagnostics.length === 0);
6
+ if (withErrors.length === 0 && inconclusive.length === 0)
5
7
  return "";
6
8
  const noun = options.noun ?? "neighbor";
7
- let out = `📐 Cascade errors in ${withErrors.length} ${noun} file(s) — fix before finishing turn:`;
9
+ let out = withErrors.length > 0
10
+ ? `📐 Cascade errors in ${withErrors.length} ${noun} file(s) — fix before finishing turn:`
11
+ : "";
8
12
  for (const neighbor of withErrors) {
9
13
  const display = toRunnerDisplayPath(cwd, neighbor.filePath);
10
14
  const reason = options.includeReason ? ` reason="${neighbor.reason}"` : "";
@@ -17,5 +21,56 @@ export function formatCascadeNeighborDiagnostics(cwd, neighbors, options = {}) {
17
21
  }
18
22
  out += "\n</diagnostics>";
19
23
  }
24
+ if (inconclusive.length > 0) {
25
+ if (out)
26
+ out += "\n";
27
+ out += `⚠️ Cascade diagnostics inconclusive for ${inconclusive.length} ${noun} file(s) — no clean result was confirmed:`;
28
+ for (const neighbor of inconclusive) {
29
+ out += `\n ${toRunnerDisplayPath(cwd, neighbor.filePath)}`;
30
+ }
31
+ }
20
32
  return out;
21
33
  }
34
+ /**
35
+ * Build the turn-end `CascadeRun` for a neighbour whose diagnostics landed only
36
+ * AFTER its cascade touch skipped the in-lane wait (#1023's `resolved-found`
37
+ * quiet-window outcome; #1444 made native TS7 take that same path). Returns
38
+ * `undefined` when there is nothing agent-facing to say — no ERROR-severity
39
+ * diagnostics, or nothing the formatter renders.
40
+ *
41
+ * Lives here rather than inline in the quiet-window callback so the delivery
42
+ * path (reconcile → run → turn_end) is testable end to end; index.ts only wires
43
+ * it to `runtime.appendCascadeRun`.
44
+ */
45
+ export function buildResolvedFoundCascadeRun(cwd, neighbor) {
46
+ const { filePath } = neighbor;
47
+ const diagnostics = convertLspDiagnostics(neighbor.diagnostics.filter((d) => d.severity === 1), filePath, { tool: "lsp" });
48
+ if (diagnostics.length === 0)
49
+ return undefined;
50
+ const neighbors = [
51
+ { filePath, reason: "references", diagnostics, lspTouched: true },
52
+ ];
53
+ const formatted = formatCascadeNeighborDiagnostics(cwd, neighbors, {
54
+ noun: "cold neighbor",
55
+ });
56
+ if (!formatted)
57
+ return undefined;
58
+ return {
59
+ filePath,
60
+ result: {
61
+ filePath,
62
+ impact: {
63
+ filePath,
64
+ changedSymbols: [],
65
+ directImporters: [],
66
+ directCallers: [],
67
+ neighborFiles: [filePath],
68
+ riskFlags: [],
69
+ },
70
+ neighbors,
71
+ formatted,
72
+ },
73
+ neighborCount: 1,
74
+ diagnosticCount: diagnostics.length,
75
+ };
76
+ }
@@ -1,7 +1,9 @@
1
- import { performance } from "node:perf_hooks";
2
1
  // Capture this before the guard's other module initialization so host boot is
3
- // not charged to extension evaluation (#1374).
4
- export const PI_LENS_EVAL_STARTED_MS = performance.now();
2
+ // not charged to extension evaluation (#1374). Lives in its own
3
+ // side-effect-free module (#1434 S3d) — see eval-timestamp.ts's docstring for
4
+ // why: `startup-timing.ts` needs this constant but must never trigger a
5
+ // console-guard install merely by importing it.
6
+ import { PI_LENS_EVAL_STARTED_MS } from "./eval-timestamp.js";
5
7
  // #1333: install the console guard as an import side-effect so it runs before
6
8
  // any other module's initialization code. Installing inside the extension
7
9
  // factory is too late — by the time the factory runs, every static/transitive
@@ -10,5 +12,15 @@ export const PI_LENS_EVAL_STARTED_MS = performance.now();
10
12
  // MUST remain the first import of index.ts; the enforcement test pins that.
11
13
  // installConsoleGuard() itself is idempotent and no-ops under test mode and
12
14
  // PI_LENS_CONSOLE_GUARD=0.
13
- import { installConsoleGuard } from "./extension-log.js";
15
+ // #1434: the guard captures only while pi-lens owns execution, so the module
16
+ // window must be open for the rest of the import graph. index.ts closes it on
17
+ // its last line; an unref'd backstop closes it if that never runs.
18
+ import { installConsoleGuard, openModuleLoadConsoleWindow, } from "./extension-log.js";
14
19
  installConsoleGuard();
20
+ openModuleLoadConsoleWindow();
21
+ // Not re-exported: nothing should import the timestamp from this module.
22
+ // `clients/startup-timing.ts` imports it straight from `eval-timestamp.ts`,
23
+ // which is what keeps that import side-effect-free (S3d). The `void` keeps
24
+ // the import from looking unused to lint while documenting that this module
25
+ // only needs the ordering (evaluate eval-timestamp.ts first), not the value.
26
+ void PI_LENS_EVAL_STARTED_MS;
@@ -15,6 +15,7 @@ import { createSubsystemLogger } from "./extension-log.js";
15
15
  import * as path from "node:path";
16
16
  import { findNearestMarkerRoot } from "./path-utils.js";
17
17
  import { safeSpawnAsync } from "./safe-spawn.js";
18
+ import { classifyProbeFailure, createAvailabilityLatch, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
18
19
  function emptyResult(language) {
19
20
  return {
20
21
  success: false,
@@ -39,6 +40,21 @@ const VULTURE_EXCLUDES = [
39
40
  "*/__pycache__/*",
40
41
  "*/.eggs/*",
41
42
  ];
43
+ // Decorators whose target is called by a framework, never by name in the tree.
44
+ // vulture cannot see those call sites, so every such symbol is a permanent
45
+ // false positive that reappears in every scan — the fastest way to teach a
46
+ // reader to stop reading the advisory. Glob-matched by vulture itself.
47
+ const VULTURE_IGNORE_DECORATORS = [
48
+ "@pytest.fixture",
49
+ "@pytest.fixture(*",
50
+ "@fixture",
51
+ "@fixture(*",
52
+ "@app.*",
53
+ "@router.*",
54
+ "@celery.task*",
55
+ "@task",
56
+ "@shared_task*",
57
+ ];
42
58
  // vulture line: `path/to/file.py:12: unused function 'foo' (60% confidence)`
43
59
  const VULTURE_LINE = /^(.*?):(\d+): unused (\w[\w ]*?) '([^']+)' \((\d+)% confidence\)\s*$/;
44
60
  /**
@@ -86,7 +102,12 @@ export function parseVultureOutput(output, root) {
86
102
  export class PythonDeadCodeClient {
87
103
  id = "python";
88
104
  language = "Python";
89
- available = null;
105
+ /**
106
+ * Transient-aware memo (#1467): only a durable "vulture is not installed"
107
+ * verdict is remembered for the session. A probe that timed out expires and
108
+ * is re-probed, so vulture cannot be disabled for the process by one stall.
109
+ */
110
+ availabilityLatch = createAvailabilityLatch();
90
111
  resolved = null;
91
112
  ensureInFlight = null;
92
113
  inFlight = new Map();
@@ -103,6 +124,10 @@ export class PythonDeadCodeClient {
103
124
  detect(cwd) {
104
125
  return this.resolveProjectRoot(cwd) !== null;
105
126
  }
127
+ owns(filePath) {
128
+ const ext = path.extname(filePath).toLowerCase();
129
+ return ext === ".py" || ext === ".pyi";
130
+ }
106
131
  /**
107
132
  * Nearest dir with a Python project marker, never at/above $HOME and never
108
133
  * escaping a VCS boundary — same containment rules as KnipClient so a scan
@@ -121,8 +146,9 @@ export class PythonDeadCodeClient {
121
146
  ], { boundaries: [".git", ".hg", ".svn"], homeDir: homeDirOverride });
122
147
  }
123
148
  async ensureAvailable() {
124
- if (this.available !== null)
125
- return this.available;
149
+ const memo = this.availabilityLatch.read();
150
+ if (memo !== null)
151
+ return memo;
126
152
  if (this.ensureInFlight)
127
153
  return this.ensureInFlight;
128
154
  this.ensureInFlight = this.doEnsureAvailable();
@@ -146,19 +172,81 @@ export class PythonDeadCodeClient {
146
172
  { cmd: "python", prefix: ["-m", "vulture"] },
147
173
  { cmd: "python3", prefix: ["-m", "vulture"] },
148
174
  ];
175
+ // A timeout on ANY candidate means the machine, not the tool, answered —
176
+ // the run gets a bounded retry instead of a permanent skip (#1467).
177
+ let sawTransient = false;
178
+ let transientCause = "probe-timeout";
179
+ // Accumulated across ALL candidates, because the failure verdicts below
180
+ // are about the whole sweep rather than any one probe. Reporting zero
181
+ // here would erase the evidence that cracked #1467: four 5s probes and
182
+ // a stalled host look identical to an absent tool without these two
183
+ // numbers.
184
+ const sweepStartedAt = Date.now();
185
+ let sweepHostStallMs = 0;
149
186
  for (const c of candidates) {
150
- const probe = await safeSpawnAsync(c.cmd, [...c.prefix, "--version"], {
151
- timeout: 5000,
152
- });
187
+ const sampler = startHostStallSampler();
188
+ const startedAt = Date.now();
189
+ let probe;
190
+ let hostStallMs;
191
+ try {
192
+ probe = await safeSpawnAsync(c.cmd, [...c.prefix, "--version"], {
193
+ timeout: 5000,
194
+ });
195
+ }
196
+ finally {
197
+ hostStallMs = sampler.stop();
198
+ sweepHostStallMs += hostStallMs;
199
+ }
153
200
  if (!probe.error && probe.status === 0) {
154
201
  this.resolved = c;
155
- this.available = true;
202
+ this.availabilityLatch.noteAvailable();
156
203
  this.log(`vulture found: ${[c.cmd, ...c.prefix].join(" ")}`);
204
+ logAvailabilityDecision({
205
+ tool: "vulture",
206
+ verdict: "available",
207
+ outcome: "success",
208
+ cause: "ok",
209
+ elapsedMs: Date.now() - startedAt,
210
+ latched: true,
211
+ hostStallMs,
212
+ budgetMs: 5000,
213
+ });
157
214
  return true;
158
215
  }
216
+ const classified = classifyProbeFailure(probe, { hostStallMs });
217
+ if (classified.outcome === "transient") {
218
+ sawTransient = true;
219
+ transientCause = classified.cause;
220
+ }
221
+ }
222
+ if (sawTransient) {
223
+ const retryAfterMs = this.availabilityLatch.noteUnavailable("transient", transientCause);
224
+ this.log("vulture probe timed out; will retry (not treated as missing)");
225
+ logAvailabilityDecision({
226
+ tool: "vulture",
227
+ verdict: "unavailable",
228
+ outcome: "transient",
229
+ cause: transientCause,
230
+ elapsedMs: Date.now() - sweepStartedAt,
231
+ hostStallMs: sweepHostStallMs,
232
+ latched: false,
233
+ retryAfterMs,
234
+ budgetMs: 5000,
235
+ });
236
+ return false;
159
237
  }
160
- this.available = false;
238
+ this.availabilityLatch.noteUnavailable("missing", "not-found");
161
239
  this.log("vulture not installed; skipping (no auto-install)");
240
+ logAvailabilityDecision({
241
+ tool: "vulture",
242
+ verdict: "unavailable",
243
+ outcome: "missing",
244
+ cause: "not-found",
245
+ elapsedMs: Date.now() - sweepStartedAt,
246
+ hostStallMs: sweepHostStallMs,
247
+ latched: true,
248
+ budgetMs: 5000,
249
+ });
162
250
  return false;
163
251
  }
164
252
  async analyze(cwd) {
@@ -193,6 +281,7 @@ export class PythonDeadCodeClient {
193
281
  ".",
194
282
  `--min-confidence=${this.minConfidence}`,
195
283
  `--exclude=${VULTURE_EXCLUDES.join(",")}`,
284
+ `--ignore-decorators=${VULTURE_IGNORE_DECORATORS.join(",")}`,
196
285
  ];
197
286
  const result = await safeSpawnAsync(invocation.cmd, args, {
198
287
  timeout: ANALYSIS_TIMEOUT_MS,
@@ -258,30 +347,46 @@ export function deadCodeIssueCount(result) {
258
347
  result.unusedDeps.length +
259
348
  result.unlistedDeps.length);
260
349
  }
350
+ /** Every bucket flattened — delta diffing and diagnostic mapping want one list. */
351
+ export function deadCodeIssues(result) {
352
+ return [
353
+ ...result.unusedExports,
354
+ ...result.unusedFiles,
355
+ ...result.unusedDeps,
356
+ ...result.unlistedDeps,
357
+ ];
358
+ }
261
359
  /**
262
- * Format cached dead-code results into one turn_end advisory, or "" when there
263
- * is nothing to report. Merges multiple languages (polyglot repos) under one
264
- * `[Dead code]` heading so it reads consistently next to the Knip advisory.
265
- * Advisory-only: these are project-wide signals, never blockers.
360
+ * Stable identity for diffing one scan against the previous one.
361
+ *
362
+ * Deliberately excludes the line number. An edit shifts every line below it,
363
+ * and the delta is then filtered to exactly the files the edit touched — so a
364
+ * line in the key turns each shifted pre-existing finding into a "newly unused"
365
+ * report under a heading that blames the agent's own edit for orphaning it.
366
+ * Inserting four lines above one real finding produced four false ones.
266
367
  */
267
- export function formatDeadCodeAdvisory(results, maxPerLang = 10) {
268
- const withFindings = results.filter((r) => r.success && deadCodeIssueCount(r) > 0);
269
- if (withFindings.length === 0)
368
+ export function deadCodeIssueKey(issue) {
369
+ return `${issue.category}:${issue.file ?? ""}:${issue.name}`;
370
+ }
371
+ /**
372
+ * Format this turn's ATTRIBUTABLE delta — symbols that became unused in files
373
+ * the agent just edited — or "" when there is nothing to report. Mirrors the
374
+ * Knip advisory's contract deliberately: the project-wide list is not injected
375
+ * per turn (hundreds of pre-existing findings would drown the blockers and burn
376
+ * context every turn), it stays available on demand via lens_diagnostics.
377
+ */
378
+ export function formatDeadCodeDelta(issues, language, max = 5) {
379
+ if (issues.length === 0)
270
380
  return "";
271
- const lines = [];
272
- for (const r of withFindings) {
273
- const count = deadCodeIssueCount(r);
274
- lines.push(`${r.language}: ${count} unused symbol(s)`);
275
- for (const issue of r.unusedExports.slice(0, maxPerLang)) {
276
- const loc = issue.file
277
- ? ` (${issue.file}${issue.line ? `:${issue.line}` : ""})`
278
- : "";
279
- lines.push(` - unused ${issue.kind} '${issue.name}'${loc}`);
280
- }
281
- if (r.unusedExports.length > maxPerLang) {
282
- lines.push(` … and ${r.unusedExports.length - maxPerLang} more`);
283
- }
381
+ let report = `💀 Newly unused ${language} symbols in files you edited — check if callers need updating (dead-code):\n`;
382
+ for (const issue of issues.slice(0, max)) {
383
+ const loc = issue.file
384
+ ? `${issue.file}${issue.line ? `:${issue.line}` : ""}`
385
+ : "(unknown)";
386
+ report += ` ${loc} unused ${issue.kind} ${issue.name}\n`;
387
+ }
388
+ if (issues.length > max) {
389
+ report += ` and ${issues.length - max} more\n`;
284
390
  }
285
- return ("💀 [Dead code] project-wide unused symbols — verify before removing:\n " +
286
- lines.join("\n "));
391
+ return report;
287
392
  }