pi-lens 3.8.68 → 3.8.69

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 (64) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +57 -40
  3. package/dist/clients/agent-nudge.js +262 -0
  4. package/dist/clients/biome-client.js +2 -2
  5. package/dist/clients/bus-publish.js +110 -0
  6. package/dist/clients/diagnostic-logger.js +2 -2
  7. package/dist/clients/diagnostics-publish.js +180 -0
  8. package/dist/clients/dispatch/dispatcher.js +2 -0
  9. package/dist/clients/dispatch/integration.js +153 -2
  10. package/dist/clients/dispatch/runners/ast-grep-napi.js +58 -13
  11. package/dist/clients/dispatch/runners/yaml-rule-parser.js +68 -19
  12. package/dist/clients/file-utils.js +20 -7
  13. package/dist/clients/installer/index.js +32 -1
  14. package/dist/clients/instance-reaper.js +114 -16
  15. package/dist/clients/jscpd-client.js +2 -2
  16. package/dist/clients/lens-config.js +17 -0
  17. package/dist/clients/lens-engine.js +44 -10
  18. package/dist/clients/lsp/cascade-tier.js +254 -0
  19. package/dist/clients/lsp/client.js +5 -1
  20. package/dist/clients/lsp/index.js +3 -0
  21. package/dist/clients/lsp/server-strategies.js +10 -0
  22. package/dist/clients/lsp/server.js +111 -5
  23. package/dist/clients/mcp/analyze.js +110 -1
  24. package/dist/clients/module-report.js +163 -18
  25. package/dist/clients/path-utils.js +25 -0
  26. package/dist/clients/persist-debounce.js +63 -0
  27. package/dist/clients/pipeline.js +82 -2
  28. package/dist/clients/project-diagnostics/extractors.js +30 -4
  29. package/dist/clients/project-snapshot.js +7 -2
  30. package/dist/clients/quiet-window.js +168 -0
  31. package/dist/clients/recent-touches.js +233 -0
  32. package/dist/clients/runtime-agent-end.js +51 -1
  33. package/dist/clients/runtime-coordinator.js +21 -0
  34. package/dist/clients/runtime-session.js +66 -41
  35. package/dist/clients/runtime-tool-result.js +46 -0
  36. package/dist/clients/sgconfig.js +230 -52
  37. package/dist/clients/source-filter.js +44 -9
  38. package/dist/clients/subagent-mode.js +54 -20
  39. package/dist/clients/tree-sitter-symbol-extractor.js +108 -0
  40. package/dist/clients/tui-fit.js +54 -0
  41. package/dist/clients/turn-summary-render.js +72 -0
  42. package/dist/clients/turn-summary.js +132 -0
  43. package/dist/clients/widget-state.js +27 -30
  44. package/dist/clients/word-index.js +296 -1
  45. package/dist/index.js +15167 -12942
  46. package/dist/mcp/build-staleness.js +123 -0
  47. package/dist/mcp/server.js +377 -43
  48. package/dist/tools/ast-grep-search.js +1 -1
  49. package/dist/tools/lens-diagnostics.js +42 -12
  50. package/dist/tools/lsp-diagnostics.js +117 -4
  51. package/dist/tools/module-report.js +14 -11
  52. package/dist/tools/symbol-search.js +110 -0
  53. package/package.json +1 -1
  54. package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
  55. package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-js-test.yml +8 -0
  56. package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-test.yml +8 -0
  57. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +1 -1
  58. package/rules/ast-grep-rules/rules/no-typeof-undefined-js.yml +9 -7
  59. package/rules/ast-grep-rules/rules/no-typeof-undefined.yml +9 -7
  60. package/skills/{ast-grep → pi-lens-ast-grep}/SKILL.md +1 -1
  61. package/skills/{lsp-navigation → pi-lens-lsp-navigation}/SKILL.md +1 -1
  62. package/skills/{write-ast-grep-rule → pi-lens-write-ast-grep-rule}/SKILL.md +1 -1
  63. package/skills/{write-tree-sitter-rule → pi-lens-write-tree-sitter-rule}/SKILL.md +1 -1
  64. package/dist/clients/tree-sitter-fixer.js +0 -127
@@ -15,7 +15,52 @@
15
15
  * a command-line marker fallback for the case where the pid itself was
16
16
  * recycled or the mid-tree pid link is broken (e.g. a dead node-wrapper whose
17
17
  * native-exe grandchild is still alive under a different, unrecorded pid).
18
+ *
19
+ * #525 root cause: a parent instance was ONLY ever considered dead via
20
+ * `isPidAlive(instance.pid)` — a raw `process.kill(pid, 0)` check. Unlike
21
+ * child pids (which get a command-line/marker identity check via
22
+ * `matchProcess` to guard against a recycled pid), the PARENT pid had no
23
+ * identity verification at all, because `InstanceEntry` never recorded the
24
+ * parent's own command line. Windows recycles pids far more aggressively than
25
+ * POSIX (no zombie/wait-reaping semantics holding a dead pid "reserved"), so
26
+ * over a long enough window (observed: ~13h) a dead parent's pid is very
27
+ * plausibly reassigned to a live, unrelated process — `isPidAlive` then
28
+ * (correctly, per its own conservative contract) reports "alive", and the
29
+ * stale instance is never reaped, no matter how old its heartbeat is.
30
+ *
31
+ * The #525 fix is deliberately ASYMMETRIC by consequence:
32
+ *
33
+ * - **Heartbeat staleness (`STALE_HEARTBEAT_MS`) cleans REGISTRY ENTRIES,
34
+ * never kills.** A stale-heartbeat-but-pid-alive instance goes into
35
+ * `staleInstances` (entry dropped from instances.json) with ZERO process
36
+ * kills and its children still marker-protected. Why: the heartbeat call
37
+ * sites are runtime-turn.ts (per turn end) and quiet-window.ts (per run
38
+ * settle) ONLY — no timer exists. A pi session left OPEN but UNUSED
39
+ * overnight fires neither, so its heartbeat legitimately goes >6h stale
40
+ * while the session and its warm LSP fleet are genuinely alive. Killing on
41
+ * staleness would take that fleet down under the idle session — and
42
+ * `matchProcess` would NOT save it (its children really ARE that
43
+ * instance's LSP servers; identity verification guards against pid reuse,
44
+ * not against misclassifying a live parent). Removing just the entry is
45
+ * safe: the idle session's next turn re-registers via `registerInstance`.
46
+ * - **Process kills require a pid-confirmed-DEAD parent** (`deadInstances`),
47
+ * exactly as before #525. Only then are its children classified for
48
+ * kill/marker-search.
49
+ *
50
+ * This still fixes both observed #525 cases: the 13h-stale test-fixture
51
+ * entry AND the recycled-parent-pid case (a dead parent whose pid now
52
+ * belongs to an unrelated live process — `isPidAlive` lies, but the stale
53
+ * heartbeat gets the ENTRY dropped, which is all the pollution fix needs).
18
54
  */
55
+ /**
56
+ * A pid-ALIVE instance whose heartbeat is older than this gets its registry
57
+ * ENTRY removed — it is NEVER kill-eligible on staleness alone (see the
58
+ * module docstring's asymmetry note: an overnight-idle-but-alive session
59
+ * legitimately exceeds this threshold because heartbeats only fire at turn
60
+ * end / run settle, so staleness must clean records, not kill processes).
61
+ * 6 hours comfortably catches the observed 13h-stale pollution case.
62
+ */
63
+ export const STALE_HEARTBEAT_MS = 6 * 60 * 60 * 1000;
19
64
  import { spawn as nodeSpawn } from "node:child_process";
20
65
  import * as fs from "node:fs";
21
66
  import * as path from "node:path";
@@ -24,9 +69,39 @@ import { isInstanceRegistryEnabled, readInstanceRegistry, } from "./instance-reg
24
69
  import { logLatency } from "./latency-logger.js";
25
70
  const isWindows = process.platform === "win32";
26
71
  /**
27
- * Markers claimed by any LIVE instance's children. A marker search kills by
28
- * command-line match, so a marker that a live session also uses must never
29
- * be searched killing it would take down the live session's server.
72
+ * KILL eligibility: pid-confirmed-dead ONLY. Heartbeat staleness must never
73
+ * make an instance kill-eligible an overnight-idle-but-alive session
74
+ * legitimately goes >STALE_HEARTBEAT_MS stale (heartbeats fire only at turn
75
+ * end / run settle; no timer exists), and killing its genuine live LSP
76
+ * children would pass `matchProcess` identity verification (they really are
77
+ * that instance's servers — the matcher guards against pid reuse, not
78
+ * against misclassifying a live parent). See the module docstring (#525).
79
+ */
80
+ function isInstanceKillEligible(instance, isPidAlive) {
81
+ return !isPidAlive(instance.pid);
82
+ }
83
+ /**
84
+ * REGISTRY-ENTRY staleness: heartbeat older than `STALE_HEARTBEAT_MS` (or
85
+ * unparseable — missing data must never keep a polluted entry alive
86
+ * forever). Drives entry removal ONLY, never kills (#525). This is what
87
+ * cleans the recycled-parent-pid case: `isPidAlive` lies for a long-dead
88
+ * parent whose pid the OS reassigned to an unrelated live process, but the
89
+ * stale heartbeat still gets the ENTRY dropped.
90
+ */
91
+ function isInstanceEntryStale(instance, now) {
92
+ const heartbeatMs = Date.parse(instance.heartbeatAt);
93
+ if (Number.isNaN(heartbeatMs))
94
+ return true;
95
+ return now - heartbeatMs > STALE_HEARTBEAT_MS;
96
+ }
97
+ /**
98
+ * Markers claimed by any pid-ALIVE instance's children. A marker search
99
+ * kills by command-line match, so a marker that a live session also uses
100
+ * must never be searched — killing it would take down the live session's
101
+ * server. Protection is deliberately keyed on pid-liveness ALONE, regardless
102
+ * of heartbeat staleness (#525): a stale-heartbeat-but-alive instance (e.g.
103
+ * overnight-idle) must keep its children protected — protection stays
104
+ * conservative even where entry cleanup does not.
30
105
  * Markers are per-process-unique by construction (sgconfig.ts embeds the
31
106
  * pid), so this is defense in depth against non-unique markers ever
32
107
  * reappearing (#472: the original shared baseline.sgconfig.yml would have
@@ -89,23 +164,38 @@ function classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMa
89
164
  * @param matchProcess - optional identity verification (e.g. confirm the
90
165
  * live pid's command line still matches what we recorded) to guard against
91
166
  * a recycled pid coincidentally matching. If omitted, liveness alone is used.
167
+ * @param now - epoch ms "now", for heartbeat-staleness comparison (#525).
168
+ * Injectable for deterministic tests; defaults to `Date.now()`. The two
169
+ * signals are ASYMMETRIC by consequence: pid-confirmed-dead ⇒
170
+ * `deadInstances` (kill-eligible + entry removal); pid-alive but heartbeat
171
+ * older than `STALE_HEARTBEAT_MS` ⇒ `staleInstances` (entry removal ONLY —
172
+ * never kills, never loses marker protection; the parent may be an
173
+ * overnight-idle-but-alive session). See the module docstring.
92
174
  */
93
- export function decideOrphanReaping(registry, isPidAlive, matchProcess) {
175
+ export function decideOrphanReaping(registry, isPidAlive, matchProcess, now = Date.now()) {
94
176
  const deadInstances = [];
177
+ const staleInstances = [];
95
178
  const childrenToKill = [];
96
179
  const markerSearches = [];
180
+ // Marker protection is pid-liveness ONLY — a stale-heartbeat-but-alive
181
+ // instance keeps its children protected (conservative on the kill side).
97
182
  const liveMarkers = collectLiveMarkers(registry, isPidAlive);
98
183
  for (const instance of registry) {
99
- if (isPidAlive(instance.pid)) {
100
- continue; // parent still alive leave its children alone entirely
184
+ if (isInstanceKillEligible(instance, isPidAlive)) {
185
+ // pid-confirmed-dead: entry removal + children classified for kills.
186
+ deadInstances.push(instance);
187
+ classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, {
188
+ childrenToKill,
189
+ markerSearches,
190
+ });
101
191
  }
102
- deadInstances.push(instance);
103
- classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, {
104
- childrenToKill,
105
- markerSearches,
106
- });
192
+ else if (isInstanceEntryStale(instance, now)) {
193
+ // pid-alive but stale heartbeat: record cleanup only — NO kills.
194
+ staleInstances.push(instance);
195
+ }
196
+ // else: alive + fresh heartbeat — leave it alone entirely.
107
197
  }
108
- return { deadInstances, childrenToKill, markerSearches };
198
+ return { deadInstances, staleInstances, childrenToKill, markerSearches };
109
199
  }
110
200
  // --- Impure liveness / identity / kill helpers ---
111
201
  /** `process.kill(pid, 0)` liveness check: ESRCH ⇒ dead, anything else
@@ -362,10 +452,16 @@ export async function sweepOrphans() {
362
452
  // best-effort — a failed marker search just misses that orphan this sweep
363
453
  }
364
454
  }
365
- if (decision.deadInstances.length > 0) {
455
+ // Entry removal covers BOTH sets: pid-dead instances AND stale-heartbeat
456
+ // (pid-alive) instances — the latter is record cleanup only (#525);
457
+ // nothing belonging to a stale instance was killed above.
458
+ if (decision.deadInstances.length > 0 || decision.staleInstances.length > 0) {
366
459
  try {
367
- const deadPids = new Set(decision.deadInstances.map((i) => i.pid));
368
- await pruneDeadInstances(deadPids);
460
+ const prunePids = new Set([
461
+ ...decision.deadInstances.map((i) => i.pid),
462
+ ...decision.staleInstances.map((i) => i.pid),
463
+ ]);
464
+ await pruneDeadInstances(prunePids);
369
465
  }
370
466
  catch {
371
467
  // best-effort — a stale registry entry is re-evaluated next sweep
@@ -379,6 +475,7 @@ export async function sweepOrphans() {
379
475
  durationMs: Date.now() - startedAt,
380
476
  metadata: {
381
477
  deadInstances: decision.deadInstances.length,
478
+ staleInstances: decision.staleInstances.length,
382
479
  killed: killedCount,
383
480
  serverIds: killedServerIds,
384
481
  markerSearches: decision.markerSearches.length,
@@ -393,7 +490,8 @@ export async function sweepOrphans() {
393
490
  // The sweep must never throw out of session_start.
394
491
  }
395
492
  }
396
- /** Drop dead-parent instances from the registry. Re-reads immediately before
493
+ /** Drop dead-parent AND stale-heartbeat (#525, record-cleanup-only)
494
+ * instances from the registry. Re-reads immediately before
397
495
  * writing (rather than reusing the earlier `readInstanceRegistry()` snapshot)
398
496
  * to narrow — not eliminate — the last-writer-wins race already accepted for
399
497
  * slice 1's read-modify-write model. */
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
11
11
  import { mkdtempSync } from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
- import { getExcludedDirGlobs, getProjectIgnoreGlobs, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
14
+ import { getExcludedDirGlobs, getGlobalPiLensDir, getProjectIgnoreGlobs, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
15
15
  import { findNodeToolBinary } from "./package-manager.js";
16
16
  import { safeSpawnAsync } from "./safe-spawn.js";
17
17
  const EMPTY_RESULT = {
@@ -107,7 +107,7 @@ export class JscpdClient {
107
107
  async doEnsureAvailable() {
108
108
  // Fast path: check local install before any spawn
109
109
  const isWin = process.platform === "win32";
110
- const localBase = path.join(os.homedir(), ".pi-lens", "tools", "node_modules", ".bin", "jscpd");
110
+ const localBase = path.join(getGlobalPiLensDir(), "tools", "node_modules", ".bin", "jscpd");
111
111
  const localCandidates = isWin
112
112
  ? [`${localBase}.cmd`, `${localBase}.exe`, localBase]
113
113
  : [localBase];
@@ -38,6 +38,10 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
38
38
  const contextInjection = contextInjectionRaw && typeof contextInjectionRaw === "object"
39
39
  ? contextInjectionRaw
40
40
  : undefined;
41
+ const turnSummaryRaw = raw.turnSummary;
42
+ const turnSummary = turnSummaryRaw && typeof turnSummaryRaw === "object"
43
+ ? turnSummaryRaw
44
+ : undefined;
41
45
  const formatMode = format?.mode === "immediate" || format?.mode === "deferred"
42
46
  ? format.mode
43
47
  : undefined;
@@ -93,6 +97,13 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
93
97
  : undefined,
94
98
  }
95
99
  : undefined,
100
+ turnSummary: turnSummary
101
+ ? {
102
+ enabled: typeof turnSummary.enabled === "boolean"
103
+ ? turnSummary.enabled
104
+ : undefined,
105
+ }
106
+ : undefined,
96
107
  };
97
108
  }
98
109
  catch {
@@ -114,6 +125,9 @@ export function getGlobalImmediateFormatDefault(configPath) {
114
125
  export function getGlobalContextInjectionEnabled(configPath) {
115
126
  return (loadPiLensGlobalConfig(configPath)?.contextInjection?.enabled !== false);
116
127
  }
128
+ export function getGlobalTurnSummaryEnabled(configPath) {
129
+ return loadPiLensGlobalConfig(configPath)?.turnSummary?.enabled === true;
130
+ }
117
131
  export function resolvePiLensFlag(name, value, config) {
118
132
  if (value)
119
133
  return value;
@@ -138,5 +152,8 @@ export function resolvePiLensFlag(name, value, config) {
138
152
  if (name === "no-lens-context") {
139
153
  return config?.contextInjection?.enabled === false;
140
154
  }
155
+ if (name === "lens-turn-summary") {
156
+ return config?.turnSummary?.enabled === true;
157
+ }
141
158
  return value;
142
159
  }
@@ -17,18 +17,19 @@ import { getDiagnosticTracker } from "./diagnostic-tracker.js";
17
17
  import { getLatencyReports, } from "./dispatch/integration.js";
18
18
  import { initLSPConfig } from "./lsp/config.js";
19
19
  import { getLSPService } from "./lsp/index.js";
20
+ import { getOrLoadWarmWordIndex } from "./mcp/analyze.js";
20
21
  import { scanProjectDiagnostics } from "./project-diagnostics/scanner.js";
21
22
  import * as path from "node:path";
22
23
  import { normalizeMapKey } from "./path-utils.js";
23
24
  import { loadProjectSnapshot } from "./project-snapshot.js";
24
- import { centralityFromReverseDeps, deserializeWordIndex, searchWordIndex, } from "./word-index.js";
25
+ import { centralityFromReverseDeps, deserializeWordIndex, searchWordIndex, triggerBackgroundWordIndexBuild, } from "./word-index.js";
25
26
  // --- Facades (re-exported so adapters import only this module) ---------------
26
27
  export { analyzeFile, } from "./mcp/analyze.js";
27
28
  export { createMcpHost } from "./mcp/host-shim.js";
28
29
  export { ipcPathForCwd, requestWarmAnalyze, } from "./mcp/ipc.js";
29
30
  export { analyzeFileFresh, resolveRebuildScript, runRebuild, summarizeScan, } from "./mcp/review.js";
30
31
  export { runSessionStart, runTurnEnd, } from "./mcp/session.js";
31
- export { moduleReport, readSymbol, } from "./module-report.js";
32
+ export { moduleReport, readEnclosing, readSymbol, renderCompactModuleReport, } from "./module-report.js";
32
33
  // --- Query wrappers (own the remaining internal reach-ins) -------------------
33
34
  /** Recent dispatch latency reports (latency.log schema), newest first. */
34
35
  export function recentLatency(limit = 5, fileFilter) {
@@ -56,24 +57,57 @@ export function diagnosticStats() {
56
57
  export function ensureLspConfig(cwd) {
57
58
  return initLSPConfig(cwd);
58
59
  }
60
+ function toSymbolSearchHit(result) {
61
+ const line = result.lines[0] ?? 1;
62
+ return {
63
+ file: result.file,
64
+ score: result.score,
65
+ hits: result.hits,
66
+ startLine: line,
67
+ endLine: line,
68
+ };
69
+ }
59
70
  /**
60
- * Ranked identifier search over the persisted word index (#162). Stateless:
61
- * loads the index from the project snapshot (built by the session scan, in
62
- * either the pi extension or the MCP session), so it works without a warm
63
- * runtime. Returns `available: false` when no index exists yet.
71
+ * Ranked identifier search over the persisted word index (#162). Mostly
72
+ * stateless: loads the index from the project snapshot (built by the session
73
+ * scan, in either the pi extension or the MCP session), so it works without a
74
+ * warm runtime. Returns `available: false` when no index exists yet — and
75
+ * kicks off a single bounded background build for this workspace (deduped per
76
+ * cwd, never blocking this call) so a retry shortly after succeeds (#348
77
+ * decision 3).
78
+ *
79
+ * #536 rider: prefers the warm in-memory index (`getOrLoadWarmWordIndex`,
80
+ * clients/mcp/analyze.ts) over a fresh disk read when one exists for this
81
+ * cwd — a warm `pilens_analyze` call updates that live copy synchronously but
82
+ * persists it to disk on a debounce (default 1500ms), so without this a query
83
+ * immediately following an analyze in the SAME process would read stale
84
+ * on-disk state until the debounce flushes. Falls back to the stateless disk
85
+ * read exactly as before when no warm copy is cached (nothing has called
86
+ * pilens_analyze yet this process, or #348 phase 2's forward-index isn't
87
+ * available) — this function's public contract (available/hint/results shape)
88
+ * is unchanged either way.
64
89
  */
65
90
  export function symbolSearch(query, cwd, limit = 20) {
66
91
  const snapshot = loadProjectSnapshot(cwd);
67
- const index = deserializeWordIndex(snapshot?.wordIndex);
68
- if (!index)
69
- return { available: false, query, results: [] };
92
+ const index = getOrLoadWarmWordIndex(cwd) ?? deserializeWordIndex(snapshot?.wordIndex);
93
+ if (!index) {
94
+ triggerBackgroundWordIndexBuild(cwd);
95
+ return {
96
+ available: false,
97
+ query,
98
+ results: [],
99
+ hint: "Word index is building in the background for this workspace — retry this query shortly.",
100
+ };
101
+ }
70
102
  // Boost well-connected files using the snapshot's reverse-dependency
71
103
  // (importedBy) counts; snapshot keys are normalized, index keys are raw.
72
104
  const centrality = centralityFromReverseDeps(index, snapshot?.reverseDeps, (file) => normalizeMapKey(path.resolve(file)));
105
+ const results = searchWordIndex(index, query, { limit, centrality });
73
106
  return {
74
107
  available: true,
75
108
  query,
76
- results: searchWordIndex(index, query, { limit, centrality }),
109
+ results: results.map(toSymbolSearchHit),
110
+ snapshotGeneratedAt: snapshot?.generatedAt,
77
111
  };
78
112
  }
79
113
  // symbolImpact was removed (#304 follow-up): the transitive blast radius is now
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Tier-aware cascade-lane wait policy (#458, re-scope №2).
3
+ *
4
+ * The cascade/deferred lane (`computeCascadeForFile`'s neighbor-touch fan-out
5
+ * in `clients/dispatch/integration.ts`) actively opens neighbor files against
6
+ * their LSP client and waits up to a per-touch budget (~1000ms cold-snapshot /
7
+ * 2000ms warm) for `textDocument/publishDiagnostics` before deciding the
8
+ * neighbor is clean. For a Tier-3 server — one that is `push-only` AND known
9
+ * to publish NOTHING on a clean→clean transition (see
10
+ * docs/lsp-capability-matrix.md; today that's typescript-language-server, the
11
+ * lone core-set instance) — that wait can never distinguish "clean" from
12
+ * "still analyzing"; it always burns its full budget. Dogfooding measured
13
+ * ~221 such `lsp_diagnostics_timeout` events/day.
14
+ *
15
+ * pi 0.80.6's `agent_settled` quiet window (#483, `clients/quiet-window.ts`)
16
+ * gives the cascade lane a place to resolve that ambiguity OUT of the
17
+ * per-touch budget: fire the touch (didOpen/didChange still happens, so the
18
+ * server starts real work), record it as outstanding, and reconcile against
19
+ * whatever landed in the client's diagnostics cache by the time the agent run
20
+ * goes idle. A touch nothing arrived for by then is recorded `unresolved` —
21
+ * never silently treated as `clean` (the #240 doctrine: a missing answer is
22
+ * not an affirmative answer).
23
+ *
24
+ * This module is deliberately NOT hardcoded to server names for the "should
25
+ * this file skip its in-lane wait" question: it reads the live capability
26
+ * snapshot's `workspaceDiagnosticsSupport.mode` (from
27
+ * `detectWorkspaceDiagnosticsSupport`, cached at `initialize`) and combines it
28
+ * with the `silentOnClean` marker on that server's `DiagnosticStrategy`
29
+ * (`server-strategies.ts`) — the same per-server behavioral-knowledge table
30
+ * the rest of the LSP layer already uses. A server with no live snapshot yet,
31
+ * or whose mode isn't `push-only`, or that isn't marked `silentOnClean`, is
32
+ * NOT tier-3 — the caller keeps today's full in-lane wait. Fail-safe is
33
+ * always "wait like before".
34
+ *
35
+ * #524/#529/#541: a server id can now be backed by more than one actual
36
+ * binary — "typescript" is classic typescript-language-server OR TS7's
37
+ * native `tsc --lsp --stdio` (PR #526). PR #526 initially routed the
38
+ * native-ts7 variant through the fail-safe "waits" path because
39
+ * `silentOnClean` had only been measured against the classic server. The
40
+ * #529/#540 clean-signal probe (`scripts/probe-clean-signal.mjs`) has since
41
+ * measured native-ts7 directly (2026-07-11, `typescript7-clean` fixture,
42
+ * repeated local runs): silent on clean transitions, same as classic. Per
43
+ * the maintainer's decision (2026-07-11, prefer fast cascade waits),
44
+ * `silentOnClean` now applies to BOTH variants — the snapshot's
45
+ * `launchVariant` marker is no longer consulted here. The safety net for a
46
+ * future TS7 build that starts publishing on clean is the nightly
47
+ * clean-signal drift check (#529/#540/#541), which now compares native rows
48
+ * against this marker too and emits a `marked-not-silent` warning if they
49
+ * diverge.
50
+ */
51
+ import { logCascade } from "../cascade-logger.js";
52
+ import { logLatency } from "../latency-logger.js";
53
+ import { normalizeMapKey } from "../path-utils.js";
54
+ import { registerQuietWindowTask } from "../quiet-window.js";
55
+ import { getServersForFileWithConfig } from "./config.js";
56
+ import { getStrategy } from "./server-strategies.js";
57
+ // --- Kill switch (lazy, memoized — house style per clients/runtime-config.ts /
58
+ // clients/quiet-window.ts's isQuietWindowEnabled) ---
59
+ let _enabledCache;
60
+ /** `PI_LENS_TIER_AWARE_CASCADE=0` disables the whole feature: every cascade
61
+ * touch waits in-lane exactly as it did before #458, no outstanding-touch
62
+ * bookkeeping, no reconcile task registered. */
63
+ export function isTierAwareCascadeEnabled() {
64
+ if (_enabledCache !== undefined)
65
+ return _enabledCache;
66
+ _enabledCache = process.env.PI_LENS_TIER_AWARE_CASCADE !== "0";
67
+ return _enabledCache;
68
+ }
69
+ /** Test-only: clear the memoized kill-switch read. */
70
+ export function _resetTierAwareCascadeEnabledForTests() {
71
+ _enabledCache = undefined;
72
+ }
73
+ /**
74
+ * Classify whether `filePath`'s PRIMARY language server is a cascade-lane
75
+ * Tier-3 (push-only, silent-on-clean) server. Ambiguous or missing capability
76
+ * data is always `"waits"` (today's behavior) — this function must never be
77
+ * the reason a real answer gets missed.
78
+ */
79
+ export function classifyCascadeWaitTier(lspService, filePath, snapshots) {
80
+ void lspService; // kept in the signature for call-site clarity/typing only
81
+ const servers = getServersForFileWithConfig(filePath).filter((s) => s.role !== "auxiliary");
82
+ const primary = servers[0];
83
+ if (!primary)
84
+ return "waits";
85
+ const snapshot = snapshots.find((s) => s.serverId === primary.id);
86
+ if (!snapshot)
87
+ return "waits"; // no live snapshot yet — fail-safe
88
+ const mode = snapshot.workspaceDiagnosticsSupport?.mode;
89
+ if (mode !== "push-only")
90
+ return "waits"; // pull servers are always affirmative
91
+ const strategy = getStrategy(primary.id);
92
+ if (strategy.silentOnClean !== true)
93
+ return "waits"; // 2*/unknown push-only
94
+ // #524/#529/#541: `silentOnClean` on a server-id-keyed strategy used to be
95
+ // proven only against the classic variant; the native-ts7 `tsc --lsp
96
+ // --stdio` binary is now probe-measured silent too (#529/#540, 2026-07-11),
97
+ // so it inherits the same verdict as classic — no `launchVariant` branch
98
+ // needed here anymore. The nightly clean-signal drift check is the
99
+ // regression watch if a future TS7 build changes this.
100
+ return "tier3-silent";
101
+ }
102
+ // Keyed by normalized file path. A later touch for the same file simply
103
+ // replaces the earlier entry (only the most recent touch matters — an
104
+ // older touch's diagnostics, if they ever arrive, are still a strict superset
105
+ // concern the newer touch already re-supersedes via didOpen/didChange).
106
+ const _outstandingTouches = new Map();
107
+ /**
108
+ * Record a Tier-3 cascade touch that skipped its in-lane wait. Called right
109
+ * after the (still-performed) didOpen/didChange notify, before returning
110
+ * without waiting. `touchedAt` must be sampled BEFORE the notify (see the
111
+ * field doc) so the reconcile comparison can never misread a publish that
112
+ * raced the record as pre-touch.
113
+ */
114
+ export function recordOutstandingCascadeTouch(entry) {
115
+ _outstandingTouches.set(normalizeMapKey(entry.filePath), entry);
116
+ }
117
+ /** Test-only: clear the outstanding-touch registry between test cases. */
118
+ export function _resetOutstandingCascadeTouchesForTests() {
119
+ _outstandingTouches.clear();
120
+ }
121
+ /** Test-only: peek at the registry without mutating it. */
122
+ export function _getOutstandingCascadeTouchesForTests() {
123
+ return [..._outstandingTouches.values()];
124
+ }
125
+ /**
126
+ * Reconcile every outstanding Tier-3 touch against the LSP client's current
127
+ * diagnostics cache. For each:
128
+ * - If the client holds a PER-FILE diagnostics entry for the touched file
129
+ * whose publish timestamp (`getAllDiagnostics()`'s `ts` — the max of the
130
+ * push/pull timestamps for that file, client.ts) is newer than the
131
+ * touch's pre-notify `touchedAt`, something published for THAT FILE since
132
+ * the touch — record `resolved-found` (diagnostics present) or
133
+ * `resolved-clean` (empty, but PROVEN empty by an actual publish for that
134
+ * file after the touch). A client-WIDE signal is deliberately not used:
135
+ * it advances on any file's publish, so it could falsely "prove" a silent
136
+ * neighbor clean when a sibling neighbor published (#240).
137
+ * - If nothing published for the file by settle time, record `unresolved` —
138
+ * per the #240 doctrine this is NEVER treated as clean.
139
+ *
140
+ * Client lookup is WARM-ONLY (`getWarmClientForFile`): the quiet window must
141
+ * never resurrect an idle-reaped server (a full tsserver spawn + cold index)
142
+ * just to write a log line. A warm-miss ⇒ `unresolved`.
143
+ *
144
+ * Always drains the whole registry (each entry is independently resolved;
145
+ * one entry's client lookup failing doesn't block the rest) and never
146
+ * throws — callers (the quiet-window task) must be fail-safe.
147
+ */
148
+ export async function reconcileOutstandingCascadeTouches(lspService) {
149
+ const outcomes = [];
150
+ const entries = [..._outstandingTouches.entries()];
151
+ _outstandingTouches.clear();
152
+ for (const [key, touch] of entries) {
153
+ const ageMs = Date.now() - touch.touchedAt;
154
+ try {
155
+ const spawned = await lspService.getWarmClientForFile(touch.filePath);
156
+ if (!spawned || spawned.client.serverId !== touch.serverId) {
157
+ outcomes.push({
158
+ filePath: touch.filePath,
159
+ serverId: touch.serverId,
160
+ outcome: "unresolved",
161
+ ageMs,
162
+ });
163
+ continue;
164
+ }
165
+ const entry = spawned.client
166
+ .getAllDiagnostics()
167
+ .get(normalizeMapKey(touch.filePath));
168
+ if (!entry || entry.ts <= touch.touchedAt) {
169
+ // No per-file publish since the touch (or ever) — a missing answer
170
+ // is not a clean answer.
171
+ outcomes.push({
172
+ filePath: touch.filePath,
173
+ serverId: touch.serverId,
174
+ outcome: "unresolved",
175
+ ageMs,
176
+ });
177
+ continue;
178
+ }
179
+ outcomes.push({
180
+ filePath: touch.filePath,
181
+ serverId: touch.serverId,
182
+ outcome: entry.diags.length > 0 ? "resolved-found" : "resolved-clean",
183
+ ageMs,
184
+ diagnosticCount: entry.diags.length,
185
+ });
186
+ }
187
+ catch (err) {
188
+ outcomes.push({
189
+ filePath: touch.filePath,
190
+ serverId: touch.serverId,
191
+ outcome: "unresolved",
192
+ ageMs,
193
+ });
194
+ logLatency({
195
+ type: "phase",
196
+ phase: "cascade_tier3_reconcile_error",
197
+ filePath: key,
198
+ durationMs: 0,
199
+ metadata: { error: String(err) },
200
+ });
201
+ }
202
+ }
203
+ return outcomes;
204
+ }
205
+ let _reconcileTaskRegistered = false;
206
+ /**
207
+ * Register the Tier-3 reconcile task with the quiet-window scheduler
208
+ * (`clients/quiet-window.ts`). Idempotent — safe to call more than once
209
+ * (e.g. multiple extension activations in tests).
210
+ */
211
+ export function registerCascadeTierReconcileTask(getLspService) {
212
+ if (_reconcileTaskRegistered)
213
+ return;
214
+ _reconcileTaskRegistered = true;
215
+ registerQuietWindowTask("cascade_tier3_reconcile", async () => {
216
+ if (!isTierAwareCascadeEnabled())
217
+ return;
218
+ if (_outstandingTouches.size === 0)
219
+ return;
220
+ const outcomes = await reconcileOutstandingCascadeTouches(getLspService());
221
+ if (outcomes.length === 0)
222
+ return;
223
+ let resolvedFound = 0;
224
+ let resolvedClean = 0;
225
+ let unresolved = 0;
226
+ let ageSumMs = 0;
227
+ for (const o of outcomes) {
228
+ if (o.outcome === "resolved-found")
229
+ resolvedFound++;
230
+ else if (o.outcome === "resolved-clean")
231
+ resolvedClean++;
232
+ else
233
+ unresolved++;
234
+ ageSumMs += o.ageMs;
235
+ }
236
+ const avgAgeMs = Math.round(ageSumMs / outcomes.length);
237
+ logCascade({
238
+ phase: "cascade_tier3_reconcile",
239
+ filePath: "<quiet-window>",
240
+ metadata: {
241
+ count: outcomes.length,
242
+ resolvedFound,
243
+ resolvedClean,
244
+ unresolved,
245
+ avgAgeMs,
246
+ outcomes,
247
+ },
248
+ });
249
+ });
250
+ }
251
+ /** Test-only: undo registerCascadeTierReconcileTask's idempotency guard. */
252
+ export function _resetCascadeTierReconcileRegistrationForTests() {
253
+ _reconcileTaskRegistered = false;
254
+ }
@@ -934,7 +934,7 @@ async function resolveCodeActionBestEffort(state, action) {
934
934
  // --- Client Factory ---
935
935
  export async function createLSPClient(options) {
936
936
  installCrashGuard();
937
- const { serverId, process: lspProcess, root, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, } = options;
937
+ const { serverId, process: lspProcess, root, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, launchVariant, } = options;
938
938
  // #449/#472: register this LSP child in the cross-process instance registry
939
939
  // as soon as we have a live pid — BEFORE `initialize` completes, not after.
940
940
  // Registering early means a child that dies/hangs during initialize (the
@@ -1044,6 +1044,7 @@ export async function createLSPClient(options) {
1044
1044
  advertisedCommands: new Set(),
1045
1045
  serverEditsAllowed: 0,
1046
1046
  serverId,
1047
+ launchVariant,
1047
1048
  root,
1048
1049
  lspProcess,
1049
1050
  // two-phase: the flush closure needs `state` (below)
@@ -1206,6 +1207,9 @@ export async function createLSPClient(options) {
1206
1207
  getRawCapabilityKeys() {
1207
1208
  return state.rawCapabilityKeys ?? [];
1208
1209
  },
1210
+ getLaunchVariant() {
1211
+ return state.launchVariant;
1212
+ },
1209
1213
  async executeCommand(command, args) {
1210
1214
  return runServerCommand(state, command, args);
1211
1215
  },
@@ -611,6 +611,7 @@ export class LSPService {
611
611
  root,
612
612
  initialization: mergedInit,
613
613
  initializeTimeoutMs: server.initializeTimeoutMs,
614
+ launchVariant: spawned.launchVariant,
614
615
  });
615
616
  const wsDiag = typeof client.getWorkspaceDiagnosticsSupport === "function"
616
617
  ? client.getWorkspaceDiagnosticsSupport()
@@ -1245,6 +1246,7 @@ export class LSPService {
1245
1246
  workspaceDiagnosticsSupport: client.getWorkspaceDiagnosticsSupport(),
1246
1247
  advertisedCommands: client.getAdvertisedCommands(),
1247
1248
  rawCapabilityKeys: client.getRawCapabilityKeys?.() ?? [],
1249
+ launchVariant: client.getLaunchVariant?.(),
1248
1250
  });
1249
1251
  }
1250
1252
  return snapshots;
@@ -1261,6 +1263,7 @@ export class LSPService {
1261
1263
  workspaceDiagnosticsSupport: client.getWorkspaceDiagnosticsSupport(),
1262
1264
  advertisedCommands: client.getAdvertisedCommands(),
1263
1265
  rawCapabilityKeys: client.getRawCapabilityKeys?.() ?? [],
1266
+ launchVariant: client.getLaunchVariant?.(),
1264
1267
  });
1265
1268
  }
1266
1269
  return snapshots;
@@ -74,6 +74,16 @@ export const SERVER_DIAGNOSTIC_STRATEGIES = {
74
74
  debounceMs: 50,
75
75
  aggregateWaitMs: 1000,
76
76
  expectSemanticSecondPush: false,
77
+ // Tier 3 (#458): typescript-language-server publishes nothing on a
78
+ // clean→clean edit (docs/lsp-capability-matrix.md). Measured for the
79
+ // classic server manually (2026-07-08) AND for TS7's native
80
+ // `tsc --lsp --stdio` variant (PR #526) via the #529/#540
81
+ // clean-signal probe (2026-07-11, `typescript7-clean` fixture) —
82
+ // both silent. It's the lone core-set tier-3 server, which is
83
+ // exactly why the cascade lane's in-lane wait is worth skipping for
84
+ // it specifically. Applies to BOTH variants (#541); the nightly
85
+ // clean-signal drift check is the regression watch.
86
+ silentOnClean: true,
77
87
  },
78
88
  "rust-analyzer": {
79
89
  seedFirstPush: false,