@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
@@ -22,11 +22,14 @@ import * as path from "node:path";
22
22
  import { safeSpawnAsync } from "./safe-spawn.js";
23
23
  import { assertInstallAllowed } from "./project-trust.js";
24
24
  import { SecurityScanClient } from "./security-scan-client.js";
25
+ import { classifyProbeFailure, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
25
26
  const EMPTY_RESULT = {
26
27
  success: false,
27
28
  findings: [],
28
29
  };
29
30
  const SCAN_TIMEOUT_MS = 120_000;
31
+ /** Budget for the `go install` auto-install, ms. */
32
+ const INSTALL_TIMEOUT_MS = 60_000;
30
33
  // --- Client ---
31
34
  export class GovulncheckClient extends SecurityScanClient {
32
35
  constructor(verbose = false) {
@@ -54,6 +57,15 @@ export class GovulncheckClient extends SecurityScanClient {
54
57
  this.available = true;
55
58
  return true;
56
59
  }
60
+ if (this.probeWasTransient()) {
61
+ // #1467: a timed-out/killed probe says nothing about whether
62
+ // govulncheck is on PATH. Latching `false` here disabled it for the
63
+ // life of the process; a `go install` here would be a heavyweight
64
+ // reaction to a host hiccup. Record an expiring verdict, retry later.
65
+ this.log("govulncheck probe timed out; retrying later (not installing)");
66
+ this.markTransientlyUnavailable(this.lastProbeCause ?? "probe-timeout");
67
+ return false;
68
+ }
57
69
  if (!assertInstallAllowed("govulncheck go install")) {
58
70
  // Deliberately NOT latching `available = false` (#1350 delta review):
59
71
  // trust denial is policy, not tool absence -- a later trust grant
@@ -67,27 +79,100 @@ export class GovulncheckClient extends SecurityScanClient {
67
79
  // project has a go.mod the user has the Go toolchain by definition.
68
80
  // Same shape as rust-clippy / cargo: lean on the language's own
69
81
  // install mechanism rather than adding a new installer strategy.
70
- const goOnPath = await safeSpawnAsync("go", ["version"], {
71
- timeout: 5000,
72
- });
82
+ const goProbeStartedAt = Date.now();
83
+ const goSampler = startHostStallSampler();
84
+ let goOnPath;
85
+ let goHostStallMs;
86
+ try {
87
+ goOnPath = await safeSpawnAsync("go", ["version"], {
88
+ timeout: 5000,
89
+ });
90
+ }
91
+ finally {
92
+ goHostStallMs = goSampler.stop();
93
+ }
73
94
  if (goOnPath.error || goOnPath.status !== 0) {
95
+ const { outcome, cause } = classifyProbeFailure(goOnPath, {
96
+ hostStallMs: goHostStallMs,
97
+ });
98
+ if (outcome === "transient") {
99
+ this.log("`go version` probe timed out; retrying govulncheck later");
100
+ const retryAfterMs = this.markTransientlyUnavailable(cause);
101
+ logAvailabilityDecision({
102
+ tool: "govulncheck",
103
+ verdict: "unavailable",
104
+ outcome,
105
+ cause,
106
+ elapsedMs: Date.now() - goProbeStartedAt,
107
+ latched: false,
108
+ hostStallMs: goHostStallMs,
109
+ ...(retryAfterMs > 0 && { retryAfterMs }),
110
+ budgetMs: 5000,
111
+ });
112
+ return false;
113
+ }
74
114
  this.log("go binary not on PATH — cannot auto-install govulncheck");
75
115
  this.available = false;
76
116
  return false;
77
117
  }
78
118
  this.log("govulncheck not found, attempting auto-install via go install");
79
- const install = await safeSpawnAsync("go", ["install", "golang.org/x/vuln/cmd/govulncheck@latest"], { timeout: 60_000 });
119
+ const installStartedAt = Date.now();
120
+ const installSampler = startHostStallSampler();
121
+ let install;
122
+ let installHostStallMs;
123
+ try {
124
+ install = await safeSpawnAsync("go", ["install", "golang.org/x/vuln/cmd/govulncheck@latest"], { timeout: INSTALL_TIMEOUT_MS });
125
+ }
126
+ finally {
127
+ installHostStallMs = installSampler.stop();
128
+ }
80
129
  if (install.error || install.status !== 0) {
81
130
  this.log(`govulncheck auto-install failed: ${(install.stderr ?? "").slice(0, 200)}`);
131
+ // #1476: `go install` runs against a 60 s budget over the network. A
132
+ // timed-out or killed install says nothing about whether govulncheck
133
+ // can ever be installed here, so it must not latch the durable
134
+ // "tool is not installed" verdict the way a real install refusal does.
135
+ // A non-zero exit (module not found, compile error) still latches:
136
+ // that IS evidence about this machine, and retrying it every turn
137
+ // would be a `go install` storm.
138
+ const { outcome, cause } = classifyProbeFailure(install, {
139
+ hostStallMs: installHostStallMs,
140
+ });
141
+ if (outcome === "transient") {
142
+ const retryAfterMs = this.markTransientlyUnavailable(cause);
143
+ // One record per decision, so an install that keeps timing out is
144
+ // readable in latency.log the same day (#1467's forensic trail).
145
+ logAvailabilityDecision({
146
+ tool: "govulncheck",
147
+ verdict: "unavailable",
148
+ outcome,
149
+ cause,
150
+ elapsedMs: Date.now() - installStartedAt,
151
+ latched: false,
152
+ hostStallMs: installHostStallMs,
153
+ ...(retryAfterMs > 0 && { retryAfterMs }),
154
+ budgetMs: INSTALL_TIMEOUT_MS,
155
+ });
156
+ return false;
157
+ }
82
158
  this.available = false;
83
159
  return false;
84
160
  }
85
161
  // `go install` writes to `$GOBIN` or `$GOPATH/bin`. The user may not
86
162
  // have that on `$PATH`. Re-probe by name (works when it is on PATH)
87
163
  // then fall back to the canonical bin dirs.
88
- const reprobe = await safeSpawnAsync("govulncheck", ["-version"], {
89
- timeout: 5000,
90
- });
164
+ const reprobeStartedAt = Date.now();
165
+ const reprobeSampler = startHostStallSampler();
166
+ let reprobe;
167
+ let reprobeHostStallMs;
168
+ try {
169
+ reprobe = await safeSpawnAsync("govulncheck", ["-version"], {
170
+ timeout: 5000,
171
+ });
172
+ }
173
+ finally {
174
+ reprobeHostStallMs = reprobeSampler.stop();
175
+ }
91
176
  if (!reprobe.error && reprobe.status === 0) {
92
177
  this.log("govulncheck auto-installed and found on PATH");
93
178
  this.available = true;
@@ -118,6 +203,30 @@ export class GovulncheckClient extends SecurityScanClient {
118
203
  // fall through to next candidate
119
204
  }
120
205
  }
206
+ // The install SUCCEEDED, so the tool is on this machine somewhere. If the
207
+ // re-probe merely timed out, latching "not installed" would be the #1467
208
+ // mistake one step later in the same function (#1476).
209
+ const { outcome, cause } = classifyProbeFailure(reprobe, {
210
+ hostStallMs: reprobeHostStallMs,
211
+ });
212
+ if (outcome === "transient") {
213
+ this.log("govulncheck installed but the re-probe timed out; retrying later");
214
+ const retryAfterMs = this.markTransientlyUnavailable(cause);
215
+ logAvailabilityDecision({
216
+ tool: "govulncheck",
217
+ verdict: "unavailable",
218
+ outcome,
219
+ cause,
220
+ // The re-probe's own wall time. A hard-coded 0 here was the #1474
221
+ // defect verbatim: a duration field that measures nothing.
222
+ elapsedMs: Date.now() - reprobeStartedAt,
223
+ latched: false,
224
+ hostStallMs: reprobeHostStallMs,
225
+ ...(retryAfterMs > 0 && { retryAfterMs }),
226
+ budgetMs: 5000,
227
+ });
228
+ return false;
229
+ }
121
230
  this.log("govulncheck auto-install succeeded but binary not locatable — check $GOBIN / $GOPATH");
122
231
  this.available = false;
123
232
  return false;
@@ -15,7 +15,7 @@ export function createDefaultHostPorts(overrides = {}) {
15
15
  suppressesUserNotify: () => false,
16
16
  },
17
17
  log: { extension: () => { }, debug: () => { }, sink: () => () => { } },
18
- emit: { bus: () => { }, lens: () => { } },
18
+ emit: { bus: () => { } },
19
19
  status: { set: () => { } },
20
20
  spawn: { abortSignal: () => undefined, isAllowed: () => true },
21
21
  render: { invalidate: () => { } },
@@ -241,7 +241,11 @@ export const TOOLS = [
241
241
  checkCommand: "tsc",
242
242
  checkArgs: ["--version"],
243
243
  installStrategy: "npm",
244
- packageName: "typescript",
244
+ // The managed compiler serves the classic typescript-language-server
245
+ // fallback. TypeScript 7 removed lib/tsserver.js and is selected only from
246
+ // project-local installs through the native `tsc --lsp --stdio` path.
247
+ // Revisit when typescript-language-server supports TS 7 — refs #1436.
248
+ packageName: "typescript@5.9.3",
245
249
  binaryName: "tsc",
246
250
  },
247
251
  {
@@ -12,11 +12,11 @@ import * as fs from "node:fs";
12
12
  import { mkdtempSync } from "node:fs";
13
13
  import * as os from "node:os";
14
14
  import * as path from "node:path";
15
- import { getExcludedDirGlobs, getGlobalPiLensDir, getProjectIgnoreGlobs, getProjectIgnoreMatcher, } from "./file-utils.js";
15
+ import { getExcludedDirGlobs, getProjectIgnoreGlobs, getProjectIgnoreMatcher, } from "./file-utils.js";
16
16
  import { findNodeToolBinary } from "./package-manager.js";
17
17
  import { isAtOrAboveHomeDir, isFullyQualified } from "./path-utils.js";
18
18
  import { getJscpdMaxEntriesDerived } from "./project-scale.js";
19
- import { createAvailabilityChecker, resolveAvailableOrInstall } from "./dispatch/runners/utils/runner-helpers.js";
19
+ import { createAvailabilityChecker, findManagedNodeToolBinary, resolveAvailableOrInstall, } from "./dispatch/runners/utils/runner-helpers.js";
20
20
  import { safeSpawnAsync } from "./safe-spawn.js";
21
21
  import { shouldRecurseIntoDir, walkTreeStackSync } from "./source-walker.js";
22
22
  const EMPTY_RESULT = {
@@ -29,13 +29,8 @@ const EMPTY_RESULT = {
29
29
  const SCAN_TIMEOUT_MS = 30_000;
30
30
  const jscpdAvailability = createAvailabilityChecker("jscpd", "", ["--version"], {
31
31
  probeTimeout: 1500,
32
- fastPath: () => {
33
- const localBase = path.join(getGlobalPiLensDir(), "tools", "node_modules", ".bin", "jscpd");
34
- const candidates = process.platform === "win32"
35
- ? [`${localBase}.cmd`, `${localBase}.exe`, localBase]
36
- : [localBase];
37
- return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
38
- },
32
+ // One definition of the managed-shim fast path, shared with knip (#1476).
33
+ fastPath: () => findManagedNodeToolBinary("jscpd"),
39
34
  });
40
35
  // --- Client ---
41
36
  export class JscpdClient {
@@ -14,7 +14,8 @@ import * as path from "node:path";
14
14
  import { getProjectDataDir } from "./file-utils.js";
15
15
  import { findNearestMarkerRoot } from "./path-utils.js";
16
16
  import { safeSpawnAsync } from "./safe-spawn.js";
17
- import { createAvailabilityChecker, getManagedToolEnvironment, resolveAvailableOrInstall, } from "./dispatch/runners/utils/runner-helpers.js";
17
+ import { createAvailabilityChecker, findManagedNodeToolBinary, getManagedToolEnvironment, resolveAvailableOrInstall, } from "./dispatch/runners/utils/runner-helpers.js";
18
+ import { createAvailabilityLatch, describeUnavailability, } from "./dispatch/runners/utils/availability-policy.js";
18
19
  const EMPTY_RESULT = {
19
20
  success: false,
20
21
  issues: [],
@@ -61,8 +62,14 @@ export class KnipClient {
61
62
  knipAvailability = createAvailabilityChecker("knip", ".cmd", ["--version"], {
62
63
  environment: (cwd) => getManagedToolEnvironment("knip", cwd),
63
64
  unclassifiedFailureOutcome: "missing",
65
+ fastPath: () => findManagedNodeToolBinary("knip"),
64
66
  });
65
- knipAvailable = null;
67
+ /**
68
+ * Client-side memo. Only a DURABLE verdict is latched — a transient probe
69
+ * failure expires, so an installed knip becomes available again without a
70
+ * host restart (#1467).
71
+ */
72
+ availabilityLatch = createAvailabilityLatch();
66
73
  knipCommand = "knip";
67
74
  ensureInFlight = null;
68
75
  log;
@@ -104,12 +111,17 @@ export class KnipClient {
104
111
  return findNearestMarkerRoot(startDir, ["package.json", "knip.json", "knip.ts", "knip.config.js", "knip.config.ts"], { boundaries: [".git", ".hg", ".svn"], homeDir: homeDirOverride });
105
112
  }
106
113
  /**
107
- * Check if knip CLI is available, auto-install if not
114
+ * Check if knip CLI is available, auto-install if not.
115
+ *
116
+ * The memo returns `null` when the last verdict was transient and its
117
+ * cooldown has expired, which re-enters the probe. That is the difference
118
+ * between "knip is missing" (a fact worth caching) and "the probe timed out"
119
+ * (a moment worth retrying).
108
120
  */
109
121
  async ensureAvailable() {
110
- // Fast path: already checked
111
- if (this.knipAvailable !== null)
112
- return this.knipAvailable;
122
+ const memo = this.availabilityLatch.read();
123
+ if (memo !== null)
124
+ return memo;
113
125
  if (this.ensureInFlight)
114
126
  return this.ensureInFlight;
115
127
  this.ensureInFlight = this.doEnsureAvailable();
@@ -121,11 +133,39 @@ export class KnipClient {
121
133
  }
122
134
  }
123
135
  async doEnsureAvailable() {
124
- const resolved = await resolveAvailableOrInstall(this.knipAvailability, "knip", process.cwd());
125
- this.knipAvailable = resolved !== null;
126
- if (resolved)
136
+ const cwd = process.cwd();
137
+ const resolved = await resolveAvailableOrInstall(this.knipAvailability, "knip", cwd);
138
+ if (resolved !== null) {
127
139
  this.knipCommand = resolved;
128
- return this.knipAvailable;
140
+ this.availabilityLatch.noteAvailable();
141
+ return true;
142
+ }
143
+ const verdict = this.knipAvailability.getVerdict(cwd);
144
+ this.availabilityLatch.noteUnavailable(verdict.outcome ?? "missing", verdict.cause ?? "not-found");
145
+ return false;
146
+ }
147
+ /**
148
+ * The single place a knip-unavailable result is worded. A transient probe
149
+ * failure must never be reported as "install knip" — knip is on disk.
150
+ */
151
+ unavailableResult() {
152
+ const verdict = this.knipAvailability.getVerdict(process.cwd());
153
+ const transient = verdict.outcome === "transient";
154
+ const retryAfterMs = verdict.retryAtMs
155
+ ? Math.max(0, verdict.retryAtMs - Date.now())
156
+ : undefined;
157
+ return {
158
+ ...EMPTY_RESULT,
159
+ failureKind: transient ? "unavailable-transient" : "unavailable-missing",
160
+ summary: describeUnavailability({
161
+ tool: "Knip",
162
+ installHint: "npm install -D knip",
163
+ outcome: verdict.outcome,
164
+ cause: verdict.cause,
165
+ elapsedMs: verdict.elapsedMs,
166
+ retryAfterMs,
167
+ }),
168
+ };
129
169
  }
130
170
  /**
131
171
  * Run knip analysis on the project.
@@ -152,10 +192,7 @@ export class KnipClient {
152
192
  };
153
193
  }
154
194
  if (!(await this.ensureAvailable())) {
155
- return {
156
- ...EMPTY_RESULT,
157
- summary: "Knip not available. Install with: npm install -D knip",
158
- };
195
+ return this.unavailableResult();
159
196
  }
160
197
  const key = path.resolve(targetDir);
161
198
  const existing = this.inFlight.get(key);
@@ -17,6 +17,55 @@ const writer = createNdjsonLogger({
17
17
  * the test-mode guard so it is deterministic and unit-testable.
18
18
  */
19
19
  let lastPhase;
20
+ /**
21
+ * Phases excluded from `lastPhase` attribution alongside `loop_block`.
22
+ * #1412 L3: `lsp_typescript_project_identity` is the classic-TS first-open
23
+ * project-identity probe (tsserver-sync.ts) — a detached, best-effort
24
+ * telemetry sample fired on every first didOpen, not genuine work. Letting it
25
+ * win `lastPhase` would overwrite the real stall attribution for a
26
+ * loop_block that happens to land right after a first open.
27
+ * #1432 review: `advisory_provenance_decision`,
28
+ * `authoritative_content_attachment_decision`, and
29
+ * `agent_end_deferred_mutation_drain` are the same shape — zero-duration
30
+ * decision telemetry, not genuine work — so they get the same exclusion.
31
+ * `agent_end_deferred_mutation_requeue` (S2d gap 4's per-requeue record) and
32
+ * `session_end_bus_rollup` (S2d gap 5's session-end rollup) are new
33
+ * zero-duration siblings of the same shape, added alongside them.
34
+ *
35
+ * `lsp_aux_wait_outcome` (#1458) is DIFFERENT from every entry above: its
36
+ * `durationMs` is a REAL bounded wait (the post-primary auxiliary grace, up
37
+ * to a few seconds), not zero-duration decision telemetry. It is still
38
+ * excluded because it is a WAIT-OUTCOME RECORD written after the aux wait
39
+ * already completed, not the stall itself — that wait ran inside the
40
+ * `lsp_touch_file`/diagnostics phase surrounding it, so letting this summary
41
+ * row win `lastPhase` would misattribute a `loop_block` to the record instead
42
+ * of to whatever phase is actually stalled when the block fires.
43
+ * #1453: `tool_set_mutation` records an active-tool-set rewrite (zero-duration
44
+ * bookkeeping, and it fires during session_start where a real stall must stay
45
+ * attributed to the work around it), so it is excluded for the same reason.
46
+ * #1467: `availability_decision` records a tool-probe verdict. Its `durationMs`
47
+ * is the child's own probe time (often zero for a fast path or a cached
48
+ * decision) and the record is bookkeeping ABOUT a probe, not host work — a
49
+ * loop_block landing next to one must stay attributed to whatever really
50
+ * stalled the loop, which is frequently the very thing that expired the probe.
51
+ *
52
+ * #1461 slice 1: `finding_dead_path_drop` is the same shape — the record a
53
+ * delivery seam writes when it drops findings whose cited path no longer
54
+ * exists.
55
+ */
56
+ const LAST_PHASE_EXCLUDED = new Set([
57
+ "loop_block",
58
+ "lsp_typescript_project_identity",
59
+ "advisory_provenance_decision",
60
+ "authoritative_content_attachment_decision",
61
+ "agent_end_deferred_mutation_drain",
62
+ "agent_end_deferred_mutation_requeue",
63
+ "session_end_bus_rollup",
64
+ "lsp_aux_wait_outcome",
65
+ "tool_set_mutation",
66
+ "availability_decision",
67
+ "finding_dead_path_drop",
68
+ ]);
20
69
  /**
21
70
  * The last non-`loop_block` phase logged, or undefined if none yet. Carries its
22
71
  * own `ts` so a consumer can gauge staleness: it is intentionally NOT cleared at
@@ -29,7 +78,7 @@ export function getLastLoggedPhase() {
29
78
  }
30
79
  export function logLatency(entry) {
31
80
  const ts = new Date().toISOString();
32
- if (entry.type === "phase" && entry.phase && entry.phase !== "loop_block") {
81
+ if (entry.type === "phase" && entry.phase && !LAST_PHASE_EXCLUDED.has(entry.phase)) {
33
82
  lastPhase = { phase: entry.phase, ts };
34
83
  }
35
84
  if (isTestMode()) {
@@ -1,20 +1,17 @@
1
1
  import { logExtension } from "./extension-log.js";
2
+ import { logBusEvent } from "./bus-events-logger.js";
3
+ import { normalizeFilePath } from "./path-utils.js";
4
+ import { createLiveBusEmitter, recordStaleBusFailure, resolveLiveBusEmitter, } from "./live-bus-emitter.js";
2
5
  export const LENS_EVENT_VERSION = 1;
3
6
  export const LENS_EVENT_NAMES = {
4
7
  analysisComplete: "pi-lens/analysis-complete",
5
8
  findings: "pi-lens/findings",
6
9
  turnFindings: "pi-lens/turn-findings",
7
10
  };
8
- let lensEventBus;
9
- let lensEventBusGetter;
10
- export function initLensEvents(pi) {
11
- lensEventBus = pi.events;
12
- lensEventBusGetter = undefined;
13
- }
11
+ const liveEmitter = createLiveBusEmitter();
14
12
  /** Keep deferred deliveries on the live extension activation. */
15
13
  export function initLensEventsGetter(getter) {
16
- lensEventBusGetter = getter;
17
- lensEventBus = undefined;
14
+ liveEmitter.wireGetter(getter);
18
15
  }
19
16
  function truncateText(value, maxChars) {
20
17
  if (value === undefined)
@@ -38,28 +35,62 @@ function normalizeDiagnostics(diagnostics) {
38
35
  // during startup/replacement windows) but must not be invisible. One line per
39
36
  // process — enough to notice a permanently-missing bus without spamming.
40
37
  let _droppedEmitLogged = false;
38
+ // Emit-failure gating (H1, #1415 review): occurrence-scoped, matching every
39
+ // other producer's `hasLoggedFailure` guard (see clients/bus-publish.ts) and
40
+ // AGENTS.md's "one bus-stale degradation per occurrence" invariant. A success
41
+ // re-arms it, so a later failure records a fresh degradation rather than
42
+ // being silently absorbed by an earlier one.
43
+ let hasLoggedFailure = false;
44
+ /** Test-only: reset module state between test files. */
45
+ export function _resetForTests() {
46
+ liveEmitter.reset();
47
+ _droppedEmitLogged = false;
48
+ hasLoggedFailure = false;
49
+ }
41
50
  function emitLensEvent(eventName, payload) {
42
51
  setImmediate(() => {
43
- try {
44
- const bus = lensEventBusGetter?.() ?? lensEventBus;
45
- const emit = bus?.emit;
46
- if (!emit) {
47
- if (!_droppedEmitLogged) {
48
- _droppedEmitLogged = true;
49
- logExtension({
50
- subsystem: "lens-events",
51
- level: "warn",
52
- message: `lens event dropped (no live bus): ${eventName} — further drops logged once per process`,
53
- metadata: { eventName },
54
- });
55
- }
56
- return;
52
+ const rawCwd = payload.cwd ?? process.cwd();
53
+ // M1: normalizeFilePath is a sync realpathSync.native on Windows — build
54
+ // the resolveLiveBusEmitter log entry lazily so the ready-path (the
55
+ // common case) never pays it. Only the stale-session branch needs it.
56
+ const resolution = resolveLiveBusEmitter(liveEmitter, () => ({
57
+ event: eventName,
58
+ cwd: normalizeFilePath(rawCwd),
59
+ }));
60
+ if (resolution.outcome === "stale-session")
61
+ return;
62
+ if (resolution.outcome === "unwired") {
63
+ if (!_droppedEmitLogged) {
64
+ _droppedEmitLogged = true;
65
+ logExtension({
66
+ subsystem: "lens-events",
67
+ level: "warn",
68
+ message: `lens event dropped (no live bus): ${eventName} — further drops logged once per process`,
69
+ metadata: { eventName },
70
+ });
57
71
  }
58
- emit.call(bus, eventName, payload);
72
+ return;
59
73
  }
60
- catch {
74
+ const cwd = normalizeFilePath(rawCwd);
75
+ try {
76
+ resolution.emit(eventName, payload);
77
+ hasLoggedFailure = false;
78
+ logBusEvent({ event: eventName, outcome: "emitted", cwd });
79
+ }
80
+ catch (err) {
81
+ logBusEvent({
82
+ event: eventName,
83
+ outcome: "emit_failed",
84
+ cwd,
85
+ error: String(err),
86
+ ctxSource: resolution.ctxSource,
87
+ });
88
+ if (!hasLoggedFailure) {
89
+ hasLoggedFailure = true;
90
+ recordStaleBusFailure(eventName, err);
91
+ }
61
92
  // Inter-extension events are observational. A listener must never break
62
- // the pi-lens hook path or delay agent progress with error handling noise.
93
+ // the pi-lens hook path or delay agent progress.
63
94
  }
64
95
  });
65
96
  }
@@ -164,6 +164,14 @@ export const LENS_FLAGS = [
164
164
  default: false,
165
165
  scope: "global",
166
166
  },
167
+ {
168
+ name: "no-lazy-tools",
169
+ description: "Keep all pi-lens tools active to avoid tool-list cache changes. Also via tools.lazy=false in ~/.pi-lens/config.json.",
170
+ configKey: "tools.lazy",
171
+ negated: true,
172
+ default: false,
173
+ scope: "global",
174
+ },
167
175
  ];
168
176
  const byName = new Map(LENS_FLAGS.map((spec) => [spec.name, spec]));
169
177
  export function getLensFlagSpec(name) {
@@ -1,4 +1,6 @@
1
1
  import { recordDegradation } from "./degradation-ledger.js";
2
+ import { probeCtxActive } from "./session-lifecycle.js";
3
+ import { logBusEvent, } from "./bus-events-logger.js";
2
4
  const STALE_CTX_MESSAGE = "This extension ctx is stale after session replacement";
3
5
  /** Ledger a dead activation once, at the publisher's occurrence-window guard. */
4
6
  export function recordStaleBusFailure(subject, error) {
@@ -19,8 +21,26 @@ export function createLiveBusEmitter() {
19
21
  getter = next;
20
22
  emit = undefined;
21
23
  },
22
- get() {
23
- return getter?.() ?? emit;
24
+ resolve() {
25
+ // Invoke the getter for every delivery so session_start rewiring can
26
+ // replace a captured pre-await activation with the current primary. When
27
+ // the current target is nevertheless confirmed stale, never invoke it.
28
+ const target = getter?.() ?? emit;
29
+ if (!target)
30
+ return { outcome: "unwired", ctxSource: "unwired" };
31
+ if (typeof target === "function") {
32
+ return { outcome: "ready", emit: target, ctxSource: "global-fallback" };
33
+ }
34
+ // Object targets are always "own"-sourced (only `wire(fn)`'s bare
35
+ // function arm is "global-fallback", and it never reaches this
36
+ // branch) — compute it once so the ready/stale-session outcomes
37
+ // below both carry the same value instead of restating the
38
+ // literal independently.
39
+ const ctxSource = "own";
40
+ if (probeCtxActive(target.ctx) === false) {
41
+ return { outcome: "stale-session", ctxSource };
42
+ }
43
+ return { outcome: "ready", emit: target.emit, ctxSource };
24
44
  },
25
45
  reset() {
26
46
  emit = undefined;
@@ -28,3 +48,26 @@ export function createLiveBusEmitter() {
28
48
  },
29
49
  };
30
50
  }
51
+ /**
52
+ * Resolve through the shared stale-session guard and record a declined
53
+ * target.
54
+ *
55
+ * `entry` is a THUNK, not a value (M1, #1415 review): building the log entry
56
+ * (every producer's version normalizes `cwd` via `normalizeFilePath`, a sync
57
+ * `realpathSync.native` call on Windows) is real per-publish cost that used
58
+ * to be paid on EVERY call regardless of outcome, even though it is only
59
+ * consumed on the `stale-session` branch. Invoking the thunk only there means
60
+ * the common `ready` path pays nothing for it.
61
+ */
62
+ export function resolveLiveBusEmitter(liveEmitter, entry) {
63
+ const resolution = liveEmitter.resolve();
64
+ if (resolution.outcome === "stale-session") {
65
+ logBusEvent({
66
+ ...entry(),
67
+ outcome: "skipped_stale_session",
68
+ level: "info",
69
+ ctxSource: resolution.ctxSource,
70
+ });
71
+ }
72
+ return resolution;
73
+ }
@@ -26,9 +26,12 @@
26
26
  * receive `options.auxGraceMs` before the race finalises. Aux results within
27
27
  * that window are included; later arrivals are dropped. When no descriptors
28
28
  * carry `role:"auxiliary"` the aux-grace path is never entered.
29
- * @param options.auxGraceMs - Grace period given to auxiliary promises after all
30
- * primary promises have settled. Only relevant when at least one descriptor
31
- * carries `role:"auxiliary"`. Defaults to 500ms.
29
+ * @param options.auxGraceMs - Ceiling on the grace period given to auxiliary
30
+ * promises after all primary promises have settled. Only relevant when at
31
+ * least one descriptor carries `role:"auxiliary"`. Defaults to 500ms. When a
32
+ * still-pending auxiliary's descriptor carries `budgetMs`, the actual window
33
+ * is `min(auxGraceMs, max(budgetMs of still-pending auxiliaries))` — see
34
+ * `PromiseDescriptor.budgetMs`.
32
35
  */
33
36
  export async function raceToCompletion(promises, shouldComplete, options = { timeoutMs: 1500 }) {
34
37
  const results = new Array(promises.length).fill(undefined);
@@ -76,7 +79,30 @@ export async function raceToCompletion(promises, shouldComplete, options = { tim
76
79
  if (auxGraceStarted || completed)
77
80
  return;
78
81
  auxGraceStarted = true;
79
- const graceMs = options.auxGraceMs ?? 500;
82
+ const ceilingMs = options.auxGraceMs ?? 500;
83
+ // #1458 S2: cap the grace window by the still-pending auxiliaries' own
84
+ // declared budgets, not just the flat ceiling — mirrors touchFile's
85
+ // per-server `min(declared, ceiling)`.
86
+ //
87
+ // This is ONE shared timer, not a deadline per promise, so it grants
88
+ // every pending auxiliary the window of its slowest pending sibling.
89
+ // That is safe here only because of an invariant the caller upholds,
90
+ // not because of anything this function enforces: every promise
91
+ // `getDiagnostics` passes in is already self-bounded by the same
92
+ // `strategy.aggregateWaitMs` it declares as `budgetMs`, so a fast
93
+ // auxiliary resolves on its own deadline long before a slow sibling's
94
+ // window expires. A caller that passes a promise NOT self-bounded by
95
+ // its descriptor's `budgetMs` — or one that outlives it, the way
96
+ // `expectSemanticSecondPush` extends a primary — would get a window
97
+ // wider than it declared. `PromiseDescriptor` is public, so treat that
98
+ // as a real constraint on new callers rather than a theoretical one.
99
+ const pendingAuxBudgets = [...auxIndices]
100
+ .filter((i) => results[i] === undefined)
101
+ .map((i) => descriptors[i]?.budgetMs)
102
+ .filter((b) => typeof b === "number" && b >= 0);
103
+ const graceMs = pendingAuxBudgets.length > 0
104
+ ? Math.min(ceilingMs, Math.max(...pendingAuxBudgets))
105
+ : ceilingMs;
80
106
  if (graceMs <= 0) {
81
107
  finalize();
82
108
  return;