@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
@@ -32,6 +32,12 @@
32
32
  * NOT tier-3 — the caller keeps today's full in-lane wait. Fail-safe is
33
33
  * always "wait like before".
34
34
  *
35
+ * Native TS7 is the cascade-only exception. It is not silent on clean, but
36
+ * its publication does not settle inside the cold-snapshot budget. Cascade
37
+ * classifies it as `collect-later`, sends the same no-wait touch, and
38
+ * reconciles its later per-file push or pull publication. The shared server
39
+ * policy remains `waits`, so main-lane behavior does not change.
40
+ *
35
41
  * #524/#529/#541/#558: a server id can now be backed by more than one actual
36
42
  * binary — "typescript" is classic typescript-language-server OR TS7's
37
43
  * native `tsc --lsp --stdio` (PR #526). PR #526 originally routed the
@@ -46,9 +52,11 @@
46
52
  * confirmed still silent (`cleanPubs=0(v:0)`) in the same run. This is
47
53
  * therefore an EVIDENCE-BASED revert, not the original unverified caution:
48
54
  * native-ts7's clean-signal behavior IS known, and it is "publishes, not
49
- * silent". The snapshot's `launchVariant` marker again routes a native-ts7
50
- * snapshot through "waits" while the shared `silentOnClean` flag stays
51
- * `true` for classic. `scripts/probe-clean-signal.mjs`'s drift check no
55
+ * silent". The shared classifier still routes a native-ts7 snapshot through
56
+ * `waits`. The cascade-only wrapper routes it through `collect-later` because
57
+ * the measured publication arrives after the in-lane budget. The shared
58
+ * `silentOnClean` flag stays `true` for classic.
59
+ * `scripts/probe-clean-signal.mjs`'s drift check no
52
60
  * longer compares native-ts7 rows against the shared marker (it now expects
53
61
  * `false` for them explicitly) — see that file's header for the regression
54
62
  * watch this sets up for a future TS7 build that becomes silent again.
@@ -57,7 +65,32 @@ import { logCascade } from "../cascade-logger.js";
57
65
  import { logLatency } from "../latency-logger.js";
58
66
  import { normalizeMapKey } from "../path-utils.js";
59
67
  import { registerQuietWindowTask } from "../quiet-window.js";
60
- export { classifyCascadeWaitTier, classifyServerWaitTier, } from "./wait-policy/classification.js";
68
+ import { classifyCascadeWaitTier as classifySharedCascadeWaitTier, classifyServerWaitTier, resolvePrimaryServerForWaitPolicy, } from "./wait-policy/classification.js";
69
+ export { classifyServerWaitTier };
70
+ /**
71
+ * The cascade lane's wait tier for `filePath`. DELEGATES to the shared
72
+ * `wait-policy/classification.ts` rule — this wrapper adds exactly one
73
+ * cascade-only override on top of it (native TS7's push-only snapshot →
74
+ * `collect-later`) and never re-implements the classification itself.
75
+ *
76
+ * #1444 coverage tradeoff: the no-wait touch this tier selects is
77
+ * `clientScope: "primary"`, and the tier itself is decided from the PRIMARY
78
+ * (non-auxiliary) server alone. That tradeoff already existed for
79
+ * `tier3-silent`; the override enlarges the population it applies to — every
80
+ * native-TS7 TypeScript neighbour now takes the no-wait path too, so an
81
+ * auxiliary server configured for those files no longer gets touched in-lane
82
+ * for them (its findings arrive via the next per-edit dispatch, as they
83
+ * already did for classic tier-3 files).
84
+ */
85
+ export function classifyCascadeWaitTier(lspService, filePath, snapshots) {
86
+ const primary = resolvePrimaryServerForWaitPolicy(filePath, snapshots);
87
+ if (primary?.serverId === "typescript" &&
88
+ primary.snapshot?.launchVariant === "native-ts7" &&
89
+ primary.snapshot.workspaceDiagnosticsSupport?.mode === "push-only") {
90
+ return "collect-later";
91
+ }
92
+ return classifySharedCascadeWaitTier(lspService, filePath, snapshots);
93
+ }
61
94
  // --- Kill switch (lazy, memoized — house style per clients/runtime-config.ts /
62
95
  // clients/quiet-window.ts's isQuietWindowEnabled) ---
63
96
  let _enabledCache;
@@ -158,6 +191,7 @@ export async function reconcileOutstandingCascadeTouches(lspService) {
158
191
  outcome: found ? "resolved-found" : "resolved-clean",
159
192
  ageMs,
160
193
  diagnosticCount: entry.diags.length,
194
+ publishedAt: entry.ts,
161
195
  // #1023: carry the diagnostics so the task can re-surface them.
162
196
  ...(found && { diagnostics: entry.diags }),
163
197
  });
@@ -199,17 +233,28 @@ export function registerCascadeTierReconcileTask(getLspService, options = {}) {
199
233
  if (outcomes.length === 0)
200
234
  return;
201
235
  // #1023: re-inject each resolved-found neighbor error so it reaches the
202
- // agent (previously logs-only). Isolated per-outcome — a throwing callback
203
- // must not drop the log line or the sibling re-injections.
236
+ // agent (previously logs-only). #1444: hand each resolved-CLEAN outcome to
237
+ // the footer reconcile for the mirror-image case. Isolated per-outcome — a
238
+ // throwing callback must not drop the log line or the sibling deliveries.
204
239
  for (const o of outcomes) {
205
- if (o.outcome !== "resolved-found" || !o.diagnostics?.length)
206
- continue;
207
240
  try {
208
- options.onResolvedFound?.({
209
- filePath: o.filePath,
210
- serverId: o.serverId,
211
- diagnostics: o.diagnostics,
212
- });
241
+ if (o.outcome === "resolved-found" && o.diagnostics?.length) {
242
+ options.onResolvedFound?.({
243
+ filePath: o.filePath,
244
+ serverId: o.serverId,
245
+ diagnostics: o.diagnostics,
246
+ });
247
+ }
248
+ else if (o.outcome === "resolved-clean" && o.publishedAt != null) {
249
+ // #1444: the stale-footer half of the same honesty problem — the
250
+ // neighbour proved clean, but only after the in-lane wait was
251
+ // skipped, so nothing has cleared its earlier error entries.
252
+ options.onResolvedClean?.({
253
+ filePath: o.filePath,
254
+ serverId: o.serverId,
255
+ publishedAt: o.publishedAt,
256
+ });
257
+ }
213
258
  }
214
259
  catch {
215
260
  // best-effort surfacing; the log below is the durable record.
@@ -24,6 +24,7 @@ import { newLspMutationCorrelationId, } from "../lsp-mutation.js";
24
24
  import { applyWorkspaceEdit, normalizeWorkspaceEditToUtf16, } from "./edits.js";
25
25
  import { recordLspChild, removeLspChild } from "../instance-registry.js";
26
26
  import { normalizeMapKey, uriToPath } from "./path-utils.js";
27
+ import { probeTsserverProjectIdentity } from "./tsserver-sync.js";
27
28
  import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
28
29
  import { getStrategy } from "./wait-policy/index.js";
29
30
  import { WatchedFilesQueue } from "./watch-queue.js";
@@ -151,6 +152,10 @@ const WORKSPACE_PULL_RESULT_CACHE_MAX = 4096;
151
152
  // stops a hung server from blocking the caller forever. On timeout the command
152
153
  // may still be applying server-side; we surface that rather than pretend it ran.
153
154
  const EXECUTE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS", 30_000);
155
+ // #1412 H1: short ceiling for the read-only tsserver project-identity probe.
156
+ // This is a telemetry sample, not a mutation — it must never hold the door
157
+ // open for anything close to EXECUTE_COMMAND_TIMEOUT_MS.
158
+ const PROBE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PROJECT_IDENTITY_PROBE_TIMEOUT_MS", 2_500);
154
159
  const LSP_CRASH_CODES = new Set([
155
160
  "ERR_STREAM_DESTROYED",
156
161
  "ERR_STREAM_WRITE_AFTER_END",
@@ -434,9 +439,16 @@ function getMergedDiagnosticsForPath(state, normalizedPath) {
434
439
  return mergeDiagnosticLists(state.pushDiagnostics?.get(normalizedPath) ??
435
440
  legacy.diagnostics?.get(normalizedPath), state.documentPullDiagnostics?.get(normalizedPath));
436
441
  }
437
- function clearDiagnosticsForPath(state, normalizedPath) {
442
+ /** Exported for tests: the quiet-window timer cancel on clear/resync is the
443
+ * headline #1412 safety property (a stale versionless publication must never
444
+ * land after the document content changed). */
445
+ export function clearDiagnosticsForPath(state, normalizedPath) {
438
446
  const legacy = state;
439
447
  state.pushDiagnostics?.delete(normalizedPath);
448
+ const pending = state.pendingDiagnostics?.get(normalizedPath);
449
+ if (pending)
450
+ clearTimeout(pending);
451
+ state.pendingDiagnostics?.delete(normalizedPath);
440
452
  state.pushDiagnosticTimestamps?.delete(normalizedPath);
441
453
  state.documentPullDiagnostics?.delete(normalizedPath);
442
454
  state.documentPullDiagnosticTimestamps?.delete(normalizedPath);
@@ -454,6 +466,32 @@ function clearDiagnosticsForPath(state, normalizedPath) {
454
466
  legacy.diagnostics?.delete(normalizedPath);
455
467
  legacy.diagnosticTimestamps?.delete(normalizedPath);
456
468
  }
469
+ function logTypeScriptPullSettle(state, normalizedPath) {
470
+ if (state.serverId !== "typescript")
471
+ return;
472
+ const diagnostics = state.documentPullDiagnostics.get(normalizedPath) ?? [];
473
+ const elapsedSinceDidOpenMs = Math.max(0, Date.now() - (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
474
+ const diagnosticCodes = [...new Set(diagnostics
475
+ .map((diagnostic) => diagnostic.code)
476
+ .filter((code) => code !== undefined)
477
+ .map(String))].slice(0, 8);
478
+ logLatency({
479
+ type: "phase",
480
+ phase: "lsp_typescript_diagnostic_sequence",
481
+ filePath: normalizedPath,
482
+ durationMs: elapsedSinceDidOpenMs,
483
+ metadata: {
484
+ launchVariant: state.launchVariant ?? "unknown",
485
+ publicationIndex: state.diagnosticPublicationCounts.get(normalizedPath) ?? 0,
486
+ version: null,
487
+ diagnosticCount: diagnostics.length,
488
+ diagnosticCodes,
489
+ elapsedSinceDidOpenMs,
490
+ settledReturn: true,
491
+ settleSource: "pull",
492
+ },
493
+ });
494
+ }
457
495
  /**
458
496
  * #1095: fingerprint the EXACT didOpen/didChange payload text at SEND time and
459
497
  * tag it with the document version it was sent as, so a later
@@ -558,7 +596,45 @@ export function setupIncomingHandlers(state, initialization) {
558
596
  message: `server=${state.serverId} pubVersion=${docVersion} docVersion=${state.documentVersions?.get(normalizedPath)} diags=${newDiags.length}`,
559
597
  });
560
598
  }
561
- const strategy = getStrategy(state.serverId);
599
+ const strategy = getStrategy(state.serverId, state.launchVariant);
600
+ // Publication counting and code extraction exist only for the
601
+ // TypeScript diagnostic-sequence telemetry; skip the bookkeeping for
602
+ // every other push server on this hot receive path.
603
+ const isTypeScriptTelemetry = state.serverId === "typescript";
604
+ const publicationIndex = isTypeScriptTelemetry
605
+ ? (state.diagnosticPublicationCounts.get(normalizedPath) ?? 0) + 1
606
+ : 0;
607
+ if (isTypeScriptTelemetry) {
608
+ state.diagnosticPublicationCounts.set(normalizedPath, publicationIndex);
609
+ }
610
+ const diagnosticCodes = isTypeScriptTelemetry
611
+ ? [...new Set(newDiags
612
+ .map((diagnostic) => diagnostic.code)
613
+ .filter((code) => code !== undefined)
614
+ .map(String))].slice(0, 8)
615
+ : [];
616
+ const logSequence = (settledReturn, settleSource) => {
617
+ if (state.serverId !== "typescript")
618
+ return;
619
+ const elapsedSinceDidOpenMs = Math.max(0, Date.now() -
620
+ (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
621
+ logLatency({
622
+ type: "phase",
623
+ phase: "lsp_typescript_diagnostic_sequence",
624
+ filePath: normalizedPath,
625
+ durationMs: elapsedSinceDidOpenMs,
626
+ metadata: {
627
+ launchVariant: state.launchVariant ?? "unknown",
628
+ publicationIndex,
629
+ version: docVersion ?? null,
630
+ diagnosticCount: newDiags.length,
631
+ diagnosticCodes,
632
+ elapsedSinceDidOpenMs,
633
+ settledReturn,
634
+ ...(settleSource && { settleSource }),
635
+ },
636
+ });
637
+ };
562
638
  // Record the document version these diagnostics were computed against
563
639
  // (when the server reports it) so waitForDiagnostics can reject results
564
640
  // that lag behind the latest didChange instead of serving them as fresh.
@@ -624,8 +700,10 @@ export function setupIncomingHandlers(state, initialization) {
624
700
  recordDocVersion();
625
701
  state.diagnosticsVersion += 1;
626
702
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
703
+ logSequence(true, "first-push");
627
704
  return;
628
705
  }
706
+ logSequence(false);
629
707
  const existingTimer = state.pendingDiagnostics.get(normalizedPath);
630
708
  if (existingTimer)
631
709
  clearTimeout(existingTimer);
@@ -638,6 +716,7 @@ export function setupIncomingHandlers(state, initialization) {
638
716
  recordDocVersion();
639
717
  state.diagnosticsVersion += 1;
640
718
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
719
+ logSequence(true, "quiet-window");
641
720
  }, strategy.debounceMs);
642
721
  state.pendingDiagnostics.set(normalizedPath, timer);
643
722
  });
@@ -998,10 +1077,12 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
998
1077
  // version baseline (`minVersion === undefined`), so a failed pull returned
999
1078
  // 0 and was read as a fresh clean.
1000
1079
  let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
1001
- if (outcome.status === "found")
1080
+ if (outcome.status === "found") {
1081
+ logTypeScriptPullSettle(state, normalizedPath);
1002
1082
  return;
1083
+ }
1003
1084
  let sawClean = outcome.status === "clean";
1004
- const strategy = getStrategy(state.serverId);
1085
+ const strategy = getStrategy(state.serverId, state.launchVariant);
1005
1086
  const retryBudgetMs = strategy.pullRetryBudgetMs > 0
1006
1087
  ? Math.min(timeoutMs, strategy.pullRetryBudgetMs)
1007
1088
  : 0;
@@ -1016,10 +1097,16 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
1016
1097
  if (outcome.status === "clean")
1017
1098
  sawClean = true;
1018
1099
  }
1019
- if (options.pullOnly)
1100
+ if (options.pullOnly) {
1101
+ if (outcome.status === "found" || sawClean) {
1102
+ logTypeScriptPullSettle(state, normalizedPath);
1103
+ }
1020
1104
  return;
1021
- if (outcome.status === "found" || sawClean)
1105
+ }
1106
+ if (outcome.status === "found" || sawClean) {
1107
+ logTypeScriptPullSettle(state, normalizedPath);
1022
1108
  return;
1109
+ }
1023
1110
  }
1024
1111
  if (hasFreshDiagnostics() &&
1025
1112
  !isVersionStale() &&
@@ -1037,7 +1124,7 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
1037
1124
  clearTimeout(debounceTimer);
1038
1125
  // Adaptive debounce: use time since last push to compute remaining
1039
1126
  // wait instead of always waiting the full debounce window.
1040
- const strategy = getStrategy(state.serverId);
1127
+ const strategy = getStrategy(state.serverId, state.launchVariant);
1041
1128
  const hit = state.pushDiagnosticTimestamps.get(normalizedPath);
1042
1129
  const timeSincePush = hit ? Date.now() - hit : Infinity;
1043
1130
  const remaining = Math.max(0, strategy.debounceMs - timeSincePush);
@@ -1080,7 +1167,7 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
1080
1167
  // Scanners that only re-scan on a fresh open (opengrep ignores didChange):
1081
1168
  // close + reopen so the re-edit actually triggers a re-scan instead of
1082
1169
  // silently publishing nothing.
1083
- if (getStrategy(state.serverId).reopenOnResync) {
1170
+ if (getStrategy(state.serverId, state.launchVariant).reopenOnResync) {
1084
1171
  await safeSendNotification(state.connection, "textDocument/didClose", {
1085
1172
  textDocument: { uri },
1086
1173
  });
@@ -1096,6 +1183,8 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
1096
1183
  // for a stale view (worse than "unknown"). Monotonic versions make that
1097
1184
  // late echo strictly older → dropped by isSupersededPush → never bound.
1098
1185
  state.documentVersions.set(normalizedPath, version);
1186
+ state.documentOpenedAt.set(normalizedPath, Date.now());
1187
+ state.diagnosticPublicationCounts.set(normalizedPath, 0);
1099
1188
  if (!isClientAlive(state))
1100
1189
  return;
1101
1190
  await safeSendNotification(state.connection, "textDocument/didOpen", {
@@ -1115,6 +1204,8 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
1115
1204
  }
1116
1205
  state.pendingOpens.add(normalizedPath);
1117
1206
  state.documentVersions.set(normalizedPath, 0);
1207
+ state.documentOpenedAt.set(normalizedPath, Date.now());
1208
+ state.diagnosticPublicationCounts.set(normalizedPath, 0);
1118
1209
  clearDiagnosticsForPath(state, normalizedPath); // always clear for initial open
1119
1210
  // Send workspace notification first (like opencode does).
1120
1211
  // Skipped in silent mode — cascade reads a file for diagnostics,
@@ -1149,6 +1240,24 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
1149
1240
  state.openDocuments.add(normalizedPath);
1150
1241
  state.closedDocuments?.delete(normalizedPath);
1151
1242
  state.openDocumentUris?.set(normalizedPath, uri);
1243
+ // Telemetry is deliberately detached after didOpen succeeds.
1244
+ // #1412 H1: routed through runReadOnlyServerCommand, NOT runServerCommand —
1245
+ // the probe must never open the serverEditsAllowed/activeMutationContext
1246
+ // mutation-acceptance window; it is a diagnostic sample, not a mutation, and
1247
+ // carries its own short PROBE_COMMAND_TIMEOUT_MS backstop. The probe itself
1248
+ // is classic-only and swallows every failure.
1249
+ void probeTsserverProjectIdentity({
1250
+ serverId: state.serverId,
1251
+ launchVariant: state.launchVariant,
1252
+ clientRoot: state.root,
1253
+ file: filePath,
1254
+ normalizedFile: normalizedPath,
1255
+ probedFiles: state.projectIdentityProbedFiles ??
1256
+ (state.projectIdentityProbedFiles = new Set()),
1257
+ commandChannel: {
1258
+ executeCommand: (command, args) => runReadOnlyServerCommand(state, command, args),
1259
+ },
1260
+ });
1152
1261
  }
1153
1262
  export async function handleNotifyChange(state, filePath, content) {
1154
1263
  if (!isClientAlive(state))
@@ -1162,6 +1271,8 @@ export async function handleNotifyChange(state, filePath, content) {
1162
1271
  textDocument: { uri, languageId: "plaintext", version: 0, text: content },
1163
1272
  });
1164
1273
  state.documentVersions.set(normalizedPath, 0);
1274
+ state.documentOpenedAt.set(normalizedPath, Date.now());
1275
+ state.diagnosticPublicationCounts.set(normalizedPath, 0);
1165
1276
  recordSentContent(state, normalizedPath, 0, content);
1166
1277
  state.openDocuments.add(normalizedPath);
1167
1278
  state.openDocumentUris?.set(normalizedPath, uri);
@@ -1194,6 +1305,13 @@ export async function closeDocument(state, filePath) {
1194
1305
  state.closedDocuments?.add(normalizedPath);
1195
1306
  state.openDocumentUris?.delete(normalizedPath);
1196
1307
  state.documentVersions.delete(normalizedPath);
1308
+ state.documentOpenedAt.delete(normalizedPath);
1309
+ state.diagnosticPublicationCounts.delete(normalizedPath);
1310
+ // #1412 L1: projectIdentityProbedFiles is a claim-once memo scoped to the
1311
+ // document's open lifetime (re-probing a closed-then-reopened file is
1312
+ // harmless and cheap) — mirror openDocuments' own per-close cleanup so it
1313
+ // doesn't grow unbounded across a long session's worth of open/close churn.
1314
+ state.projectIdentityProbedFiles?.delete(normalizedPath);
1197
1315
  clearDiagnosticsForPath(state, normalizedPath);
1198
1316
  }
1199
1317
  export async function clientShutdown(state, options = {}) {
@@ -1208,6 +1326,9 @@ export async function clientShutdown(state, options = {}) {
1208
1326
  state.pendingOpens.clear();
1209
1327
  state.openDocuments.clear();
1210
1328
  state.openDocumentUris?.clear();
1329
+ // #1412 L1: mirror openDocuments' clear — a shut-down/evicted client's
1330
+ // probe memo is moot along with everything else document-scoped.
1331
+ state.projectIdentityProbedFiles?.clear();
1211
1332
  // #271: drop any pending watched-files batch + its timer (a dying client's
1212
1333
  // queued FS changes are moot, and the timer must not outlive the connection).
1213
1334
  state.watchQueue?.cancel();
@@ -1409,6 +1530,43 @@ export async function runServerCommand(state, command, args, timeoutMs = EXECUTE
1409
1530
  state.activeMutationContext = undefined;
1410
1531
  }
1411
1532
  }
1533
+ // #1412 H1/H2: read-only sibling of runServerCommand for telemetry/identity
1534
+ // probes that must NOT participate in the mutation-acceptance window. Unlike
1535
+ // runServerCommand this never touches serverEditsAllowed, activeMutationDepth,
1536
+ // or activeMutationContext — a probe firing mid-flight must leave a concurrent
1537
+ // real executeCommand's mutation context untouched, and must not itself open
1538
+ // the workspace/applyEdit acceptance window (client.ts's applyEdit handler
1539
+ // gates on serverEditsAllowed > 0). Preserves the allowlist-by-advertisement
1540
+ // invariant. Short PROBE_COMMAND_TIMEOUT_MS backstop — this is a diagnostic
1541
+ // sample, not a mutation, and must never hold anything up for anywhere near
1542
+ // EXECUTE_COMMAND_TIMEOUT_MS.
1543
+ export async function runReadOnlyServerCommand(state, command, args, timeoutMs = PROBE_COMMAND_TIMEOUT_MS) {
1544
+ if (!isClientAlive(state)) {
1545
+ return { executed: false, reason: "lsp client not alive" };
1546
+ }
1547
+ if (!state.advertisedCommands.has(command)) {
1548
+ return {
1549
+ executed: false,
1550
+ reason: `command "${command}" is not advertised by the ${state.serverId} server`,
1551
+ };
1552
+ }
1553
+ try {
1554
+ const result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
1555
+ command,
1556
+ arguments: args ?? [],
1557
+ }), timeoutMs);
1558
+ return { executed: true, result };
1559
+ }
1560
+ catch (err) {
1561
+ if (err instanceof Error && err.message.startsWith("Timeout after")) {
1562
+ return {
1563
+ executed: false,
1564
+ reason: `workspace/executeCommand timed out after ${timeoutMs}ms`,
1565
+ };
1566
+ }
1567
+ throw err;
1568
+ }
1569
+ }
1412
1570
  function validateWorkspaceEditVersions(state, edit) {
1413
1571
  for (const change of edit.documentChanges ?? []) {
1414
1572
  if (typeof change !== "object" || change === null || !("textDocument" in change))
@@ -1592,6 +1750,8 @@ export async function createLSPClient(options) {
1592
1750
  documentPullDiagnosticTimestamps: new Map(),
1593
1751
  pullFailureHistory: [],
1594
1752
  pendingDiagnostics: new Map(),
1753
+ diagnosticPublicationCounts: new Map(),
1754
+ documentOpenedAt: new Map(),
1595
1755
  diagnosticEmitter,
1596
1756
  diagnosticsVersion: 0,
1597
1757
  documentVersions: new Map(),
@@ -1604,6 +1764,7 @@ export async function createLSPClient(options) {
1604
1764
  closedDocuments: new Set(),
1605
1765
  openDocumentUris: new Map(),
1606
1766
  pendingOpens: new Set(),
1767
+ projectIdentityProbedFiles: new Set(),
1607
1768
  // these are filled in after initialize — cast to avoid two-phase init
1608
1769
  workspaceDiagnosticsSupport: undefined,
1609
1770
  operationSupport: undefined,
@@ -2,6 +2,35 @@ import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import { PathKeyedMap } from "../path-keyed-map.js";
4
4
  import { normalizeEphemeralMapKey } from "../path-utils.js";
5
+ /**
6
+ * #1470: the single source of truth for "which servers did this touch NOT hear
7
+ * from". Read this instead of comparing `confirmation` to a string literal — a
8
+ * consumer that tests `confirmation === "confirmed"` is correct only by accident
9
+ * (it happens to fail closed for `"partial"`), and one that tests `!inconclusive`
10
+ * is outright wrong, because a partially-confirmed touch is deliberately NOT
11
+ * inconclusive: the primary's answer is still trustworthy and must survive.
12
+ */
13
+ export function touchCoverageGap(result) {
14
+ return result?.unconfirmedServerIds ?? [];
15
+ }
16
+ /**
17
+ * #1470: did this touch complete its configured confirmation policy at all —
18
+ * `"confirmed"` (for every spawned server) or `"partial"` (for every server
19
+ * except the named cut-off auxiliaries)?
20
+ *
21
+ * This is deliberately NOT `confirmation === "confirmed"`. A consumer asking
22
+ * "did the PRIMARY confirm?" must answer yes for a partial touch: `partial`
23
+ * implies neither the notify write nor the diagnostics wait lapsed, so the
24
+ * silent-clean gates ran to completion exactly as they do for a full
25
+ * confirmation. Reading `=== "confirmed"` there looks safely fail-closed but
26
+ * reports "the language server could not confirm clean" when the truth is "the
27
+ * language server confirmed clean and a scanner was cut off" — the same overclaim
28
+ * pointing the other way. Pair this with {@link touchCoverageGap}, which names
29
+ * what the touch does not speak for.
30
+ */
31
+ export function touchCompletedConfirmationPolicy(result) {
32
+ return result?.confirmation !== undefined;
33
+ }
5
34
  /**
6
35
  * Fingerprint the EXACT text handed to didOpen/didChange. sha256 over the raw
7
36
  * string — no normalization — so the disk comparison (which reads the file with