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.
- package/CHANGELOG.md +39 -0
- package/README.md +57 -40
- package/dist/clients/agent-nudge.js +262 -0
- package/dist/clients/biome-client.js +2 -2
- package/dist/clients/bus-publish.js +110 -0
- package/dist/clients/diagnostic-logger.js +2 -2
- package/dist/clients/diagnostics-publish.js +180 -0
- package/dist/clients/dispatch/dispatcher.js +2 -0
- package/dist/clients/dispatch/integration.js +153 -2
- package/dist/clients/dispatch/runners/ast-grep-napi.js +58 -13
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +68 -19
- package/dist/clients/file-utils.js +20 -7
- package/dist/clients/installer/index.js +32 -1
- package/dist/clients/instance-reaper.js +114 -16
- package/dist/clients/jscpd-client.js +2 -2
- package/dist/clients/lens-config.js +17 -0
- package/dist/clients/lens-engine.js +44 -10
- package/dist/clients/lsp/cascade-tier.js +254 -0
- package/dist/clients/lsp/client.js +5 -1
- package/dist/clients/lsp/index.js +3 -0
- package/dist/clients/lsp/server-strategies.js +10 -0
- package/dist/clients/lsp/server.js +111 -5
- package/dist/clients/mcp/analyze.js +110 -1
- package/dist/clients/module-report.js +163 -18
- package/dist/clients/path-utils.js +25 -0
- package/dist/clients/persist-debounce.js +63 -0
- package/dist/clients/pipeline.js +82 -2
- package/dist/clients/project-diagnostics/extractors.js +30 -4
- package/dist/clients/project-snapshot.js +7 -2
- package/dist/clients/quiet-window.js +168 -0
- package/dist/clients/recent-touches.js +233 -0
- package/dist/clients/runtime-agent-end.js +51 -1
- package/dist/clients/runtime-coordinator.js +21 -0
- package/dist/clients/runtime-session.js +66 -41
- package/dist/clients/runtime-tool-result.js +46 -0
- package/dist/clients/sgconfig.js +230 -52
- package/dist/clients/source-filter.js +44 -9
- package/dist/clients/subagent-mode.js +54 -20
- package/dist/clients/tree-sitter-symbol-extractor.js +108 -0
- package/dist/clients/tui-fit.js +54 -0
- package/dist/clients/turn-summary-render.js +72 -0
- package/dist/clients/turn-summary.js +132 -0
- package/dist/clients/widget-state.js +27 -30
- package/dist/clients/word-index.js +296 -1
- package/dist/index.js +15167 -12942
- package/dist/mcp/build-staleness.js +123 -0
- package/dist/mcp/server.js +377 -43
- package/dist/tools/ast-grep-search.js +1 -1
- package/dist/tools/lens-diagnostics.js +42 -12
- package/dist/tools/lsp-diagnostics.js +117 -4
- package/dist/tools/module-report.js +14 -11
- package/dist/tools/symbol-search.js +110 -0
- package/package.json +1 -1
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-js-test.yml +8 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-test.yml +8 -0
- package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-typeof-undefined-js.yml +9 -7
- package/rules/ast-grep-rules/rules/no-typeof-undefined.yml +9 -7
- package/skills/{ast-grep → pi-lens-ast-grep}/SKILL.md +1 -1
- package/skills/{lsp-navigation → pi-lens-lsp-navigation}/SKILL.md +1 -1
- package/skills/{write-ast-grep-rule → pi-lens-write-ast-grep-rule}/SKILL.md +1 -1
- package/skills/{write-tree-sitter-rule → pi-lens-write-tree-sitter-rule}/SKILL.md +1 -1
- 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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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 (
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
368
|
-
|
|
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
|
|
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(
|
|
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).
|
|
61
|
-
* loads the index from the project snapshot (built by the session
|
|
62
|
-
* either the pi extension or the MCP session), so it works without a
|
|
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
|
-
|
|
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:
|
|
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,
|