pi-lens 3.8.63 → 3.8.65

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 (49) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +12 -9
  3. package/dist/clients/deadline-utils.js +23 -0
  4. package/dist/clients/installer/index.js +18 -27
  5. package/dist/clients/log-cleanup.js +36 -13
  6. package/dist/clients/lsp/client.js +44 -0
  7. package/dist/clients/lsp/index.js +208 -33
  8. package/dist/clients/lsp/interactive-install.js +14 -4
  9. package/dist/clients/lsp/launch.js +29 -100
  10. package/dist/clients/mcp/review.js +7 -4
  11. package/dist/clients/package-manager.js +223 -0
  12. package/dist/clients/pipeline.js +55 -2
  13. package/dist/clients/project-metadata.js +3 -23
  14. package/dist/clients/safe-spawn.js +9 -0
  15. package/dist/clients/tree-sitter-client.js +72 -14
  16. package/dist/index.js +16 -0
  17. package/dist/mcp/server.js +4 -2
  18. package/dist/tools/ast-grep-search.js +10 -6
  19. package/dist/tools/lens-diagnostics.js +49 -10
  20. package/dist/tools/lsp-diagnostics.js +27 -4
  21. package/dist/tools/scan-progress.js +53 -0
  22. package/grammars/tree-sitter-bash.wasm +0 -0
  23. package/grammars/tree-sitter-bash.wasm.json +5 -0
  24. package/grammars/tree-sitter-css.wasm +0 -0
  25. package/grammars/tree-sitter-css.wasm.json +5 -0
  26. package/grammars/tree-sitter-go.wasm +0 -0
  27. package/grammars/tree-sitter-go.wasm.json +5 -0
  28. package/grammars/tree-sitter-html.wasm +0 -0
  29. package/grammars/tree-sitter-html.wasm.json +5 -0
  30. package/grammars/tree-sitter-java.wasm +0 -0
  31. package/grammars/tree-sitter-java.wasm.json +5 -0
  32. package/grammars/tree-sitter-javascript.wasm +0 -0
  33. package/grammars/tree-sitter-javascript.wasm.json +5 -0
  34. package/grammars/tree-sitter-json.wasm +0 -0
  35. package/grammars/tree-sitter-json.wasm.json +5 -0
  36. package/grammars/tree-sitter-python.wasm +0 -0
  37. package/grammars/tree-sitter-python.wasm.json +5 -0
  38. package/grammars/tree-sitter-rust.wasm +0 -0
  39. package/grammars/tree-sitter-rust.wasm.json +5 -0
  40. package/grammars/tree-sitter-tsx.wasm +0 -0
  41. package/grammars/tree-sitter-tsx.wasm.json +5 -0
  42. package/grammars/tree-sitter-typescript.wasm +0 -0
  43. package/grammars/tree-sitter-typescript.wasm.json +5 -0
  44. package/grammars/tree-sitter-yaml.wasm +0 -0
  45. package/grammars/tree-sitter-yaml.wasm.json +5 -0
  46. package/package.json +9 -7
  47. package/scripts/analyze-pi-lens-logs.mjs +101 -3
  48. package/scripts/download-grammars.js +171 -30
  49. package/scripts/grammars.lock.json +32 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Node.js package-manager resolution and command building.
3
+ *
4
+ * Single source of truth for "which package manager should we use here, and how
5
+ * do we spell each command (run script / install / exec / global bin) for it".
6
+ * Supports npm, pnpm, yarn and bun so pi-lens works on whatever manager the
7
+ * machine actually ships.
8
+ *
9
+ * Resolution order (see `resolveNodePackageManager`):
10
+ * 1. What the project declares — lockfile, then the corepack `packageManager`
11
+ * field — *if that manager is actually installed*.
12
+ * 2. Otherwise the first installed manager in `PREFERENCE` (npm first for
13
+ * maximum compatibility, bun last).
14
+ * 3. `npm` as a final fallback so callers always get a usable value.
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import { isCommandAvailableAsync, safeSpawnAsync } from "./safe-spawn.js";
20
+ /**
21
+ * Fallback preference when nothing is declared (or the declared manager is
22
+ * missing). npm first for maximum compatibility; bun last. A project lockfile
23
+ * always overrides this order.
24
+ */
25
+ const PREFERENCE = ["npm", "pnpm", "yarn", "bun"];
26
+ function onWindows() {
27
+ return process.platform === "win32";
28
+ }
29
+ function isNodePackageManager(value) {
30
+ return (value === "npm" || value === "pnpm" || value === "yarn" || value === "bun");
31
+ }
32
+ // ============================================================================
33
+ // DETECTION
34
+ // ============================================================================
35
+ /**
36
+ * Detect the package manager a Node.js project declares, without checking
37
+ * whether it is installed. Lockfiles win over the corepack `packageManager`
38
+ * field. Returns `undefined` when the project makes no declaration.
39
+ */
40
+ export function detectNodePackageManager(targetPath) {
41
+ if (fs.existsSync(path.join(targetPath, "bun.lockb")) ||
42
+ fs.existsSync(path.join(targetPath, "bun.lock"))) {
43
+ return "bun";
44
+ }
45
+ if (fs.existsSync(path.join(targetPath, "pnpm-lock.yaml"))) {
46
+ return "pnpm";
47
+ }
48
+ if (fs.existsSync(path.join(targetPath, "yarn.lock"))) {
49
+ return "yarn";
50
+ }
51
+ if (fs.existsSync(path.join(targetPath, "package-lock.json"))) {
52
+ return "npm";
53
+ }
54
+ return readPackageManagerField(targetPath);
55
+ }
56
+ /** Read the corepack `"packageManager": "pnpm@8.15.0"` field from package.json. */
57
+ function readPackageManagerField(targetPath) {
58
+ try {
59
+ const pkgPath = path.join(targetPath, "package.json");
60
+ if (!fs.existsSync(pkgPath))
61
+ return undefined;
62
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
63
+ if (typeof pkg.packageManager !== "string")
64
+ return undefined;
65
+ const name = pkg.packageManager.split("@")[0].trim().toLowerCase();
66
+ return isNodePackageManager(name) ? name : undefined;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ // ============================================================================
73
+ // AVAILABILITY (cached per process; reset in tests)
74
+ // ============================================================================
75
+ const availabilityCache = new Map();
76
+ function isAvailable(pm) {
77
+ let cached = availabilityCache.get(pm);
78
+ if (!cached) {
79
+ cached = isCommandAvailableAsync(pm);
80
+ availabilityCache.set(pm, cached);
81
+ }
82
+ return cached;
83
+ }
84
+ /** Clear the process-wide availability cache. Intended for tests. */
85
+ export function _resetPackageManagerCache() {
86
+ availabilityCache.clear();
87
+ }
88
+ // ============================================================================
89
+ // RESOLUTION
90
+ // ============================================================================
91
+ /**
92
+ * Resolve which package manager to use for `cwd`: the project's declared manager
93
+ * if installed, otherwise the first installed manager in `PREFERENCE`, otherwise
94
+ * `npm`.
95
+ */
96
+ export async function resolveNodePackageManager(cwd = process.cwd()) {
97
+ const declared = detectNodePackageManager(cwd);
98
+ if (declared && (await isAvailable(declared))) {
99
+ return declared;
100
+ }
101
+ for (const pm of PREFERENCE) {
102
+ if (await isAvailable(pm))
103
+ return pm;
104
+ }
105
+ return "npm";
106
+ }
107
+ // ============================================================================
108
+ // COMMAND BUILDERS
109
+ // ============================================================================
110
+ /** Platform-specific executable name (`.cmd`/`.exe` on Windows). */
111
+ export function pmBinary(pm) {
112
+ if (!onWindows())
113
+ return pm;
114
+ return pm === "bun" ? "bun.exe" : `${pm}.cmd`;
115
+ }
116
+ /** Args to run a package.json script — `run <script>` works for all managers. */
117
+ export function runScriptArgs(script) {
118
+ return ["run", script];
119
+ }
120
+ /** Human-readable "run script" command for display (bare manager name). */
121
+ export function formatRunScript(pm, script) {
122
+ return `${pm} run ${script}`;
123
+ }
124
+ /**
125
+ * Args to install a single package. npm uses `install`; pnpm/yarn/bun use `add`.
126
+ * `--legacy-peer-deps` is npm-only and silently dropped for other managers.
127
+ */
128
+ export function installArgs(pm, pkg, options = {}) {
129
+ const args = [pm === "npm" ? "install" : "add"];
130
+ if (options.ignoreScripts)
131
+ args.push("--ignore-scripts");
132
+ if (options.legacyPeerDeps && pm === "npm")
133
+ args.push("--legacy-peer-deps");
134
+ args.push(pkg);
135
+ return args;
136
+ }
137
+ /**
138
+ * Args to install a single package **globally** (`-g`). npm/pnpm/bun spell this
139
+ * `install -g` / `add -g`; yarn uses `global add` (yarn classic — Berry removed
140
+ * global installs, but pi-lens's manager resolution prefers npm/pnpm first, so
141
+ * yarn is only chosen when it is the declared/only manager). The resulting
142
+ * binary is found again by `allAvailableGlobalBinDirs`, which covers every
143
+ * manager's global bin dir.
144
+ */
145
+ export function globalInstallArgs(pm, pkg) {
146
+ switch (pm) {
147
+ case "yarn":
148
+ return ["global", "add", pkg];
149
+ case "npm":
150
+ return ["install", "-g", pkg];
151
+ default: // pnpm, bun
152
+ return ["add", "-g", pkg];
153
+ }
154
+ }
155
+ /**
156
+ * Command + args to run a package's binary without a global install — the
157
+ * `npx --no <pkg>` equivalent for each manager (`bun x`, `pnpm dlx`, `yarn dlx`).
158
+ */
159
+ export function execArgs(pm, pkg, args = []) {
160
+ switch (pm) {
161
+ case "bun":
162
+ return { command: pmBinary("bun"), args: ["x", pkg, ...args] };
163
+ case "pnpm":
164
+ return { command: pmBinary("pnpm"), args: ["dlx", pkg, ...args] };
165
+ case "yarn":
166
+ return { command: pmBinary("yarn"), args: ["dlx", pkg, ...args] };
167
+ default:
168
+ // --no prevents silently downloading an uncached package.
169
+ return {
170
+ command: onWindows() ? "npx.cmd" : "npx",
171
+ args: ["--no", pkg, ...args],
172
+ };
173
+ }
174
+ }
175
+ // ============================================================================
176
+ // GLOBAL BIN DISCOVERY
177
+ // ============================================================================
178
+ /** Directories where a given manager installs global binaries. */
179
+ async function globalBinDirsFor(pm) {
180
+ if (pm === "bun") {
181
+ // bun has no per-call query cost — the global bin dir is deterministic.
182
+ const base = process.env.BUN_INSTALL || path.join(os.homedir(), ".bun");
183
+ return [path.join(base, "bin")];
184
+ }
185
+ const query = pm === "npm"
186
+ ? ["config", "get", "prefix"]
187
+ : pm === "pnpm"
188
+ ? ["bin", "-g"]
189
+ : ["global", "bin"]; // yarn
190
+ const res = await safeSpawnAsync(pmBinary(pm), query, { timeout: 5000 });
191
+ if (res.status !== 0 || res.error)
192
+ return [];
193
+ const out = res.stdout.trim();
194
+ if (!out)
195
+ return [];
196
+ // npm reports a prefix; binaries live in `<prefix>/bin` on Unix, `<prefix>`
197
+ // on Windows. pnpm/yarn already print the bin dir directly.
198
+ if (pm === "npm") {
199
+ return [onWindows() ? out : path.join(out, "bin")];
200
+ }
201
+ return [out];
202
+ }
203
+ /**
204
+ * Global bin directories for every installed manager, deduped. Used to locate a
205
+ * globally-installed tool binary when PATH is stale (e.g. right after an
206
+ * `install -g`) or when the tool was installed via a non-npm manager.
207
+ */
208
+ export async function allAvailableGlobalBinDirs() {
209
+ const dirs = [];
210
+ const seen = new Set();
211
+ for (const pm of PREFERENCE) {
212
+ if (!(await isAvailable(pm)))
213
+ continue;
214
+ for (const dir of await globalBinDirsFor(pm)) {
215
+ const normalized = path.resolve(dir);
216
+ if (seen.has(normalized))
217
+ continue;
218
+ seen.add(normalized);
219
+ dirs.push(normalized);
220
+ }
221
+ }
222
+ return dirs;
223
+ }
@@ -30,12 +30,25 @@ import { emitLensAnalysisComplete } from "./lens-events.js";
30
30
  import { getLSPService } from "./lsp/index.js";
31
31
  import { clearGraphCache } from "./review-graph/builder.js";
32
32
  import { RUNTIME_CONFIG } from "./runtime-config.js";
33
- import { safeSpawnAsync } from "./safe-spawn.js";
33
+ import { getAmbientAbortSignal, safeSpawnAsync } from "./safe-spawn.js";
34
+ import { combineAbortSignals } from "./deadline-utils.js";
34
35
  import { getAutofixPolicyForFile, getPreferredAutofixTools, getRubocopCommand, hasBiomeConfig, hasDetektConfig, hasEslintConfig, hasGolangciConfig, hasKtfmtConfig, hasOxlintConfig, hasRubocopConfig, hasSqlfluffConfig, hasStylelintConfig, } from "./tool-policy.js";
35
36
  const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
36
37
  const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
37
38
  const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
38
39
  const AUTOFIX_CHANGED_FILE_SCAN_LIMIT = 5000;
40
+ /**
41
+ * Hard ceiling for the pre-dispatch LSP sync (`resyncLspFile`). The sync sends a
42
+ * didChange/didOpen; that write can backpressure indefinitely when the language
43
+ * server's stdin isn't being drained (a wedged/CPU-bound server), which would
44
+ * hang the whole edit with no per-call bound — client acquisition is capped, but
45
+ * the notify write is not. So the sync is abandoned after this budget (the edit
46
+ * proceeds; the dispatch LSP runner, which has its own 30s cap, still tries).
47
+ */
48
+ function lspSyncBudgetMs() {
49
+ const raw = Number(process.env.PI_LENS_LSP_SYNC_BUDGET_MS);
50
+ return Number.isFinite(raw) && raw > 0 ? raw : 3000;
51
+ }
39
52
  // Scan one directory's entries into `snapshot`, pushing walkable subdirs onto
40
53
  // `stack`. Extracted from the walk loop to keep each function's cognitive
41
54
  // complexity low. Excluded/ignored dirs are not descended; ignored/vanished
@@ -615,12 +628,52 @@ export async function resyncLspFile(filePath, fileContent, needsContentRefresh,
615
628
  // the cache clear (no preserveDiagnostics) so the wait resolves on fresh,
616
629
  // correctly-positioned diagnostics rather than stale pre-edit ones — the
617
630
  // didChange triggers a server recompute regardless of cache preservation.
618
- await lspService.touchFile(filePath, fileContent, {
631
+ //
632
+ // The touch is client-wait-capped, but its didChange/didOpen *write* can
633
+ // backpressure forever on a wedged server (stdin not drained), which would
634
+ // hang the whole edit with no bound and — until this — no log. Race it
635
+ // against a hard budget + the turn's abort signal (Escape): whichever wins,
636
+ // the edit proceeds. A wedged server no longer parks the pipeline.
637
+ const budgetMs = lspSyncBudgetMs();
638
+ const abort = getAmbientAbortSignal();
639
+ if (abort?.aborted)
640
+ return;
641
+ const bail = combineAbortSignals(abort, AbortSignal.timeout(budgetMs));
642
+ const startedAt = Date.now();
643
+ const touch = lspService
644
+ .touchFile(filePath, fileContent, {
619
645
  diagnostics: "none",
620
646
  source: "lsp_sync",
621
647
  clientScope: "primary",
622
648
  maxClientWaitMs: LSP_SPAWN_BUDGET_MS,
649
+ })
650
+ .then(() => "done")
651
+ .catch((err) => {
652
+ dbg(`LSP resync after autofix error: ${err}`);
653
+ return "done";
623
654
  });
655
+ const bailed = new Promise((resolve) => {
656
+ if (!bail || bail.aborted)
657
+ return resolve("bailed");
658
+ bail.addEventListener("abort", () => resolve("bailed"), { once: true });
659
+ });
660
+ const outcome = await Promise.race([touch, bailed]);
661
+ if (outcome === "bailed") {
662
+ // Abandon the still-pending write; the edit continues. Log it so this
663
+ // stall — previously an invisible hang — is queryable in latency.log.
664
+ logLatency({
665
+ type: "phase",
666
+ phase: "lsp_sync_abandoned",
667
+ filePath,
668
+ durationMs: Date.now() - startedAt,
669
+ metadata: {
670
+ source: "lsp_sync",
671
+ reason: abort?.aborted ? "aborted" : "timeout",
672
+ budgetMs,
673
+ },
674
+ });
675
+ dbg(`LSP resync ${abort?.aborted ? "aborted (Escape)" : `timed out after ${budgetMs}ms`}; server slow/wedged for ${filePath}`);
676
+ }
624
677
  }
625
678
  }
626
679
  catch (err) {
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
+ import { detectNodePackageManager, formatRunScript, } from "./package-manager.js";
14
15
  // --- Detection Functions ---
15
16
  /**
16
17
  * Detect project metadata from a target directory.
@@ -204,24 +205,6 @@ function detectNodeProject(targetPath) {
204
205
  }
205
206
  return metadata;
206
207
  }
207
- /**
208
- * Detect Node.js package manager from lockfiles
209
- */
210
- function detectNodePackageManager(targetPath) {
211
- if (fs.existsSync(path.join(targetPath, "bun.lockb")) || fs.existsSync(path.join(targetPath, "bun.lock"))) {
212
- return "bun";
213
- }
214
- if (fs.existsSync(path.join(targetPath, "pnpm-lock.yaml"))) {
215
- return "pnpm";
216
- }
217
- if (fs.existsSync(path.join(targetPath, "yarn.lock"))) {
218
- return "yarn";
219
- }
220
- if (fs.existsSync(path.join(targetPath, "package-lock.json"))) {
221
- return "npm";
222
- }
223
- return undefined;
224
- }
225
208
  /**
226
209
  * Detect Python project from pyproject.toml, setup.py, requirements.txt
227
210
  */
@@ -663,13 +646,10 @@ export function getAvailableCommands(metadata) {
663
646
  for (const priority of scriptPriority) {
664
647
  const matching = Object.entries(metadata.scripts).find(([name]) => name.toLowerCase().includes(priority));
665
648
  if (matching) {
666
- const runCmd = metadata.packageManager === "bun" ? "bun run" :
667
- metadata.packageManager === "pnpm" ? "pnpm" :
668
- metadata.packageManager === "yarn" ? "yarn" :
669
- "npm run";
649
+ const pm = (metadata.packageManager ?? "npm");
670
650
  commands.push({
671
651
  action: priority,
672
- command: `${runCmd} ${matching[0]}`,
652
+ command: formatRunScript(pm, matching[0]),
673
653
  });
674
654
  }
675
655
  }
@@ -35,6 +35,15 @@ let ambientAbortSignal;
35
35
  export function setAmbientAbortSignal(signal) {
36
36
  ambientAbortSignal = signal;
37
37
  }
38
+ /**
39
+ * The current turn's abort signal, for in-process awaits that aren't child
40
+ * spawns (e.g. an LSP JSON-RPC write that can backpressure on a wedged server).
41
+ * Child spawns read the ambient signal internally; this getter lets the
42
+ * interactive pipeline honor Escape on non-spawn LSP calls too.
43
+ */
44
+ export function getAmbientAbortSignal() {
45
+ return ambientAbortSignal;
46
+ }
38
47
  // ============================================================================
39
48
  // INTERNAL HELPERS
40
49
  // ============================================================================
@@ -94,6 +94,53 @@ export class TreeSitterClient {
94
94
  return cwdCandidate;
95
95
  return undefined;
96
96
  }
97
+ /**
98
+ * The `grammars/` dir bundled inside the pi-lens package (the core grammars
99
+ * shipped in the tarball, so common languages parse offline on every package
100
+ * manager). Resolved from the package root; cached. Absent in a source
101
+ * checkout where `prepare` hasn't populated it.
102
+ */
103
+ _bundledGrammarsDir;
104
+ bundledGrammarsDir() {
105
+ // Cache only a positive hit; keep re-checking until it exists (prepare may
106
+ // not have populated it yet at first probe).
107
+ if (this._bundledGrammarsDir)
108
+ return this._bundledGrammarsDir;
109
+ try {
110
+ const dir = resolvePackagePath(import.meta.url, "grammars");
111
+ if (fs.existsSync(dir))
112
+ this._bundledGrammarsDir = dir;
113
+ return this._bundledGrammarsDir;
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ }
119
+ /**
120
+ * All directories that may hold grammar wasms, in precedence order: the
121
+ * bundled core dir, the resolved `this.grammarsDir`, and the web-tree-sitter
122
+ * grammars dir (the lazy-fetch write target). Deduped.
123
+ */
124
+ grammarSourceDirs() {
125
+ const dirs = [];
126
+ const push = (d) => {
127
+ if (d && !dirs.includes(d))
128
+ dirs.push(d);
129
+ };
130
+ push(this.bundledGrammarsDir());
131
+ push(this.grammarsDir || undefined);
132
+ push(this.resolveWebTreeSitterAsset("grammars"));
133
+ return dirs;
134
+ }
135
+ /** Absolute path to `grammarFile` across all source dirs, else undefined. */
136
+ resolveGrammarFile(grammarFile) {
137
+ for (const dir of this.grammarSourceDirs()) {
138
+ const candidate = path.join(dir, grammarFile);
139
+ if (fs.existsSync(candidate))
140
+ return candidate;
141
+ }
142
+ return undefined;
143
+ }
97
144
  /** Find tree-sitter grammar directory */
98
145
  findGrammarsDir() {
99
146
  const grammarsDir = this.resolveWebTreeSitterAsset("grammars");
@@ -101,7 +148,8 @@ export class TreeSitterClient {
101
148
  fs.existsSync(path.join(grammarsDir, "tree-sitter-typescript.wasm"))) {
102
149
  return grammarsDir;
103
150
  }
104
- // Fallback: tree-sitter-wasms package (real installs, not npm:null)
151
+ // Fallback: a real `tree-sitter-wasms` package, if the user installed one
152
+ // (it is not a pi-lens dependency — grammars ship bundled / lazy-fetched).
105
153
  try {
106
154
  const wasmsOut = path.join(path.dirname(_require.resolve("tree-sitter-wasms/package.json")), "out");
107
155
  if (fs.existsSync(wasmsOut))
@@ -145,7 +193,7 @@ export class TreeSitterClient {
145
193
  * throws.
146
194
  */
147
195
  async ensureGrammar(grammarFile) {
148
- if (this.grammarsDir && fs.existsSync(path.join(this.grammarsDir, grammarFile))) {
196
+ if (this.resolveGrammarFile(grammarFile)) {
149
197
  return true;
150
198
  }
151
199
  const inflight = this.grammarEnsurePromises.get(grammarFile);
@@ -166,7 +214,14 @@ export class TreeSitterClient {
166
214
  console.error(`[pi-lens] fetched missing tree-sitter grammar ${grammarFile} at runtime (install scripts were skipped by the package manager)`);
167
215
  }
168
216
  else {
169
- this.dbg(`grammar fetch failed for ${grammarFile}`);
217
+ // Surface the degradation once per grammar (the promise cache dedupes)
218
+ // instead of failing silently — otherwise pnpm/bun users offline get
219
+ // no signal that a language's tree-sitter features are unavailable.
220
+ console.error(`[pi-lens] tree-sitter grammar '${grammarFile}' is unavailable — ` +
221
+ `symbol search, module reports and structural rules for this language will be degraded. ` +
222
+ `The package manager skipped install scripts and the runtime download failed (offline or CDN unreachable). ` +
223
+ `Fix: reinstall with a manager that runs postinstall, allow its build scripts ` +
224
+ `(pnpm approve-builds / bun trustedDependencies), or restore network access.`);
170
225
  }
171
226
  return ok;
172
227
  })();
@@ -238,14 +293,14 @@ export class TreeSitterClient {
238
293
  this.dbg(`No grammar file for ${languageId}`);
239
294
  return null;
240
295
  }
241
- let grammarPath = this.grammarsDir
242
- ? path.join(this.grammarsDir, grammarFile)
243
- : "";
244
- // Lazily fetch the grammar if the postinstall download was skipped
245
- // (pnpm/bun). Only the language actually being parsed is fetched.
246
- if (!grammarPath || !fs.existsSync(grammarPath)) {
296
+ // Look across the bundled core `grammars/` dir and the postinstall/lazy
297
+ // dir. Lazily fetch only if the grammar is in neither (pnpm/bun skip
298
+ // postinstall; the long-tail grammars aren't bundled). Only the language
299
+ // actually being parsed is fetched.
300
+ let grammarPath = this.resolveGrammarFile(grammarFile);
301
+ if (!grammarPath) {
247
302
  if (await this.ensureGrammar(grammarFile)) {
248
- grammarPath = path.join(this.grammarsDir, grammarFile);
303
+ grammarPath = this.resolveGrammarFile(grammarFile);
249
304
  }
250
305
  }
251
306
  this.dbg(`Grammar path: ${grammarPath}, exists: ${grammarPath && fs.existsSync(grammarPath)}`);
@@ -356,14 +411,17 @@ export class TreeSitterClient {
356
411
  this.dbg(`Found ${injections.length} injections in ${filePath}`);
357
412
  return injections;
358
413
  }
359
- /** Check if tree-sitter is available (grammars installed) */
414
+ /** Check if tree-sitter is available (a core grammar resolves somewhere). */
360
415
  isAvailable() {
361
- if (fs.existsSync(this.grammarsDir))
416
+ // Available if the core TS grammar resolves in ANY source dir — the bundled
417
+ // `grammars/` counts even when web-tree-sitter/grammars is empty (no
418
+ // postinstall on pnpm/bun, or a fresh CI checkout).
419
+ if (this.resolveGrammarFile("tree-sitter-typescript.wasm"))
362
420
  return true;
363
- // Re-evaluate in case grammars were installed after process start
421
+ // Re-evaluate the legacy dir in case grammars were installed after start.
364
422
  const dir = this.findGrammarsDir();
365
423
  this.grammarsDir = dir;
366
- return fs.existsSync(dir);
424
+ return !!dir && fs.existsSync(dir);
367
425
  }
368
426
  /** Check if specific language is supported */
369
427
  async isLanguageSupported(languageId) {
package/dist/index.js CHANGED
@@ -1548,6 +1548,22 @@ export default function (pi) {
1548
1548
  // Publish this turn's abort signal so the dispatch's linter/type-check
1549
1549
  // child processes are killed if the agent is interrupted (#197 ctx.signal).
1550
1550
  setAmbientAbortSignal(ctx?.signal);
1551
+ // Earliest possible marker for the edit pipeline: the first instrumented
1552
+ // phase is `read_file` deep inside runPipeline, so a stall before that (or
1553
+ // upstream, before pi-lens even received the event) leaves NO trace — that
1554
+ // is exactly why a wedged-LSP edit hang was invisible in latency.log. This
1555
+ // row means "pi-lens received this edit"; if it is present but nothing
1556
+ // follows, the stall is in the pipeline; if it is absent, it is upstream.
1557
+ const rtToolName = event?.toolName;
1558
+ if (rtToolName === "edit" || rtToolName === "write") {
1559
+ logLatency({
1560
+ type: "phase",
1561
+ phase: "tool_result_received",
1562
+ filePath: event?.input?.path ?? "<unknown>",
1563
+ durationMs: 0,
1564
+ metadata: { toolName: rtToolName },
1565
+ });
1566
+ }
1551
1567
  try {
1552
1568
  const { biomeClient, ruffClient, metricsClient, agentBehaviorClient } = await loadBootstrapClients();
1553
1569
  return await handleToolResult({
@@ -483,13 +483,15 @@ async function callTool(name, args) {
483
483
  }
484
484
  if (name === "pilens_rebuild") {
485
485
  const outcome = await runRebuild(REPO_ROOT, REBUILD_SCRIPT);
486
+ const runCmd = `${outcome.packageManager} run ${outcome.script}`;
486
487
  const headline = outcome.ok
487
- ? `✓ rebuild succeeded (npm run ${outcome.script}, ${outcome.durationMs}ms). Fresh analyses now reflect the latest build.`
488
- : `✗ rebuild FAILED (npm run ${outcome.script}, ${outcome.durationMs}ms).`;
488
+ ? `✓ rebuild succeeded (${runCmd}, ${outcome.durationMs}ms). Fresh analyses now reflect the latest build.`
489
+ : `✗ rebuild FAILED (${runCmd}, ${outcome.durationMs}ms).`;
489
490
  return {
490
491
  ...toolText(outcome.ok ? headline : `${headline}\n\n${outcome.output}`, {
491
492
  ok: outcome.ok,
492
493
  script: outcome.script,
494
+ packageManager: outcome.packageManager,
493
495
  durationMs: outcome.durationMs,
494
496
  repoRoot: REPO_ROOT,
495
497
  }),
@@ -7,6 +7,7 @@ import { Type } from "../clients/deps/typebox.js";
7
7
  import { astGrepRemediationHint, classifyAstGrepError, logAstGrepToolEvent, } from "../clients/ast-grep-tool-logger.js";
8
8
  import { hasStructuralIntent, synthesizeRule, } from "../clients/ast-grep-yaml-synth.js";
9
9
  import { compactRenderResult } from "./render-compact.js";
10
+ import { combineAbortSignals } from "../clients/deadline-utils.js";
10
11
  import { LANGUAGES } from "./shared.js";
11
12
  /**
12
13
  * Build the agent-facing error text, appending a remediation hint derived from
@@ -303,6 +304,9 @@ export function createAstGrepSearchTool(astGrepClient) {
303
304
  })),
304
305
  }),
305
306
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
307
+ // Escape aborts the turn via ctx.signal; the positional signal is the
308
+ // tool-call one. Honor both so a broad search cancels on Escape.
309
+ const abortSignal = combineAbortSignals(_signal, ctx.signal);
306
310
  const startedAt = Date.now();
307
311
  const { paths, selector, context, skip, maxMatches, groupByFile, strictness, rule, insideKind, hasKind, follows, precedes, validateOnly, } = params;
308
312
  const pattern = typeof params.pattern === "string" ? params.pattern : "";
@@ -398,7 +402,7 @@ export function createAstGrepSearchTool(astGrepClient) {
398
402
  details: {},
399
403
  };
400
404
  }
401
- if (_signal.aborted)
405
+ if (abortSignal?.aborted)
402
406
  return abortError();
403
407
  if (!(await astGrepClient.ensureAvailable())) {
404
408
  logOutcome("error", {
@@ -415,7 +419,7 @@ export function createAstGrepSearchTool(astGrepClient) {
415
419
  details: {},
416
420
  };
417
421
  }
418
- if (_signal.aborted)
422
+ if (abortSignal?.aborted)
419
423
  return abortError();
420
424
  if (!hasRawRule && looksLikeRuleYamlOrPlainText(pattern)) {
421
425
  logOutcome("error", {
@@ -503,10 +507,10 @@ export function createAstGrepSearchTool(astGrepClient) {
503
507
  }
504
508
  // Phase 4: raw YAML rule passthrough — routes through sg scan --config
505
509
  if (effectiveRule && effectiveRule.trim().length > 0) {
506
- if (_signal.aborted)
510
+ if (abortSignal?.aborted)
507
511
  return abortError();
508
512
  const ruleResult = await astGrepClient.searchWithRule(effectiveRule, searchPaths);
509
- if (_signal.aborted)
513
+ if (abortSignal?.aborted)
510
514
  return abortError();
511
515
  if (ruleResult.error) {
512
516
  logOutcome("error", { errorRaw: ruleResult.error });
@@ -555,14 +559,14 @@ export function createAstGrepSearchTool(astGrepClient) {
555
559
  },
556
560
  };
557
561
  }
558
- if (_signal.aborted)
562
+ if (abortSignal?.aborted)
559
563
  return abortError();
560
564
  const result = await astGrepClient.search(pattern, lang, searchPaths, {
561
565
  selector,
562
566
  context,
563
567
  strictness,
564
568
  });
565
- if (_signal.aborted)
569
+ if (abortSignal?.aborted)
566
570
  return abortError();
567
571
  if (result.error) {
568
572
  logOutcome("error", { errorRaw: result.error });