pi-lens 3.8.62 → 3.8.64

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 (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +12 -9
  3. package/dist/clients/deadline-utils.js +93 -0
  4. package/dist/clients/installer/index.js +50 -35
  5. package/dist/clients/log-cleanup.js +36 -13
  6. package/dist/clients/lsp/client.js +156 -61
  7. package/dist/clients/lsp/index.js +218 -33
  8. package/dist/clients/lsp/interactive-install.js +14 -4
  9. package/dist/clients/lsp/launch.js +31 -102
  10. package/dist/clients/lsp/server.js +29 -22
  11. package/dist/clients/mcp/review.js +7 -4
  12. package/dist/clients/module-report-lsp.js +1 -16
  13. package/dist/clients/package-manager.js +223 -0
  14. package/dist/clients/pipeline.js +113 -39
  15. package/dist/clients/project-metadata.js +3 -23
  16. package/dist/clients/read-expansion.js +1 -14
  17. package/dist/clients/safe-spawn.js +9 -0
  18. package/dist/clients/tree-sitter-client.js +72 -14
  19. package/dist/index.js +16 -0
  20. package/dist/mcp/server.js +4 -2
  21. package/dist/tools/ast-grep-search.js +10 -6
  22. package/dist/tools/lens-diagnostics.js +43 -10
  23. package/dist/tools/lsp-diagnostics.js +23 -4
  24. package/dist/tools/scan-progress.js +38 -0
  25. package/grammars/tree-sitter-bash.wasm +0 -0
  26. package/grammars/tree-sitter-bash.wasm.json +5 -0
  27. package/grammars/tree-sitter-css.wasm +0 -0
  28. package/grammars/tree-sitter-css.wasm.json +5 -0
  29. package/grammars/tree-sitter-go.wasm +0 -0
  30. package/grammars/tree-sitter-go.wasm.json +5 -0
  31. package/grammars/tree-sitter-html.wasm +0 -0
  32. package/grammars/tree-sitter-html.wasm.json +5 -0
  33. package/grammars/tree-sitter-java.wasm +0 -0
  34. package/grammars/tree-sitter-java.wasm.json +5 -0
  35. package/grammars/tree-sitter-javascript.wasm +0 -0
  36. package/grammars/tree-sitter-javascript.wasm.json +5 -0
  37. package/grammars/tree-sitter-json.wasm +0 -0
  38. package/grammars/tree-sitter-json.wasm.json +5 -0
  39. package/grammars/tree-sitter-python.wasm +0 -0
  40. package/grammars/tree-sitter-python.wasm.json +5 -0
  41. package/grammars/tree-sitter-rust.wasm +0 -0
  42. package/grammars/tree-sitter-rust.wasm.json +5 -0
  43. package/grammars/tree-sitter-tsx.wasm +0 -0
  44. package/grammars/tree-sitter-tsx.wasm.json +5 -0
  45. package/grammars/tree-sitter-typescript.wasm +0 -0
  46. package/grammars/tree-sitter-typescript.wasm.json +5 -0
  47. package/grammars/tree-sitter-yaml.wasm +0 -0
  48. package/grammars/tree-sitter-yaml.wasm.json +5 -0
  49. package/package.json +7 -5
  50. package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-js-test.yml +21 -0
  51. package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-test.yml +21 -0
  52. package/rules/ast-grep-rules/rules/incomplete-string-escaping-js.yml +19 -0
  53. package/rules/ast-grep-rules/rules/incomplete-string-escaping.yml +19 -0
  54. package/scripts/analyze-pi-lens-logs.mjs +101 -3
  55. package/scripts/download-grammars.js +171 -30
  56. package/scripts/grammars.lock.json +32 -0
  57. package/skills/write-ast-grep-rule/SKILL.md +36 -26
package/CHANGELOG.md CHANGED
@@ -10,6 +10,43 @@ All notable changes to pi-lens will be documented in this file.
10
10
 
11
11
  ### Fixed
12
12
 
13
+ ## [3.8.64] - 2026-07-04
14
+
15
+ ### Added
16
+
17
+ - **Opt-in `workspace/diagnostic` pull for the full scan — one request per server instead of N file opens (#387 Item 2)** — where a language server advertises `workspace/diagnostic` (e.g. TypeScript), `lens_diagnostics mode=full` can now issue a single project-wide pull per server instead of opening every file, detected via a new `workspaceDiagnostics` capability flag (distinct from per-document pull) and a `requestWorkspaceDiagnostics` client method. Gated behind `PI_LENS_LSP_WORKSPACE_PULL=1` (default off) and used per server-group only when the server advertises it and no file in the group has an auxiliary scanner; **any** miss (unsupported / dead / timeout / auxiliary present) falls back to the per-server-serial per-file path from #387 Item 1. Off by default because a **cold** server can answer a workspace pull with an empty/partial report that would read as a false "all clean", and the pull covers only the primary server — so it stays opt-in pending real-server validation before becoming the default. Completes the capability side of #387 (Item 1 shipped in #388).
18
+ - **Progress bar for the long full-mode diagnostic scans (`lens_diagnostics mode=full`, `lsp_diagnostics` batch/directory)** — these scans can run for seconds to minutes and were previously opaque until they returned. They now stream a throttled progress bar (`Scanning… [████░░░░░░] 45/123 (37%)`) to the tool's `onUpdate` callback — at most ~4×/s plus a guaranteed final tick — so the agent/user sees movement. The data already existed (the sweep's per-file completion count); this just surfaces it. Shared `tools/scan-progress.ts` (`renderScanProgress` + `makeProgressReporter`); `runWorkspaceDiagnostics` and `mapWithConcurrency` gained an optional `onProgress(completed, total)`.
19
+
20
+ - **Tree-sitter grammar provenance — sha256 sidecars + a committed manifest + a CI guard (#177)** — the grammar downloader now verifies every fetched `.wasm` against a committed provenance manifest (`scripts/grammars.lock.json`: package, version, per-grammar sha256) and writes a `<grammar>.wasm.json` sidecar recording what was installed. Two integrity gaps close: (1) fetched bytes are checked against the pinned hash — a grammar whose bytes don't match is never written (guards CDN corruption/tampering); (2) the old **skip-if-exists** behavior is replaced by **skip-if-verified** — on a version bump or hash mismatch the stale grammar is re-downloaded instead of silently persisting, so an ABI-mismatched `.wasm` can't survive against the deliberately pinned `web-tree-sitter`. A new `npm run check:grammars` (`scripts/check-grammar-provenance.mjs`, wired into CI like `check:lockfile`) re-hashes the installed grammars' bytes and fails on any drift from the manifest. Regenerate on a deliberate tree-sitter-wasms bump with `node scripts/download-grammars.js --write-manifest`.
21
+ - **Package-manager detection — pi-lens no longer hardcodes npm/npx (#374)** — new `clients/package-manager.ts` is the single source of truth for *which* Node package manager to use (npm/pnpm/yarn/bun) and *how* to spell each command (run-script / install / global-install / exec / global-bin). Resolution: the project's lockfile or corepack `packageManager` field when that manager is installed, else the first installed by preference (npm → pnpm → yarn → bun), else npm. Routed through it: tool auto-install and global-bin discovery (`clients/installer`), LSP global binary lookup (`clients/lsp/launch.ts`), the interactive LSP-server global install (`clients/lsp/interactive-install.ts`), the MCP `pilens_rebuild` (`runRebuild` reports which manager it used), and project run-command hints. This makes pi-lens work on hosts that ship bun/pnpm/yarn instead of npm.
22
+
23
+ ### Changed
24
+
25
+ - **Tree-sitter grammars: bundle a core set + fix cross-manager install** — grammars were fetched only by the `postinstall` script, which **npm** runs but **pnpm/bun block by default** (and **yarn** couldn't install at all — see below), so non-npm users depended entirely on a runtime CDN fetch and got *no* tree-sitter offline. Now the 12 core grammars (ts, tsx, js, python, go, rust, json, yaml, bash, html, css, java) are **downloaded at `prepare` time and shipped in the tarball** (`grammars/`, in `files[]`; ~+1 MB to the `.tgz` since wasm gzips well, +9 MB unpacked), so the common languages parse **offline on every package manager**. The long-tail grammars still lazy-fetch on first use, and a failed fetch now emits a **visible, actionable warning** instead of a silent debug line. Also removed the `tree-sitter-wasms: "npm:null@^0.11.0"` optional-dependency sentinel — npm/pnpm/bun skipped it as a failing optional but **yarn classic hard-errored** on it, so `yarn add pi-lens` failed outright; it's now installable under yarn.
26
+
27
+ ### Fixed
28
+
29
+ - **The LSP notify write is now bounded at the source, so a wedged server can't ride an edit to the 30 s dispatcher timeout** — the pre-dispatch sync fix bounded one call site, but *every* `touchFile` caller (the dispatch LSP runner and the workspace sweep too) `await`ed `notify.open`'s `didChange`/`didOpen` write, which backpressures indefinitely on a server whose stdin isn't drained. So edits on a wedged server still took ~31 s — the dispatch LSP runner hung on that write until the coarse 30 s per-runner ceiling killed it (`Runner lsp timed out after 30000ms`). The write is now bounded inside `touchFile` itself (`PI_LENS_LSP_NOTIFY_BUDGET_MS`, default 2 s): on a wedged server it degrades to "no fresh diagnostics" (logged as `lsp_notify_timeout` / `notifyWriteTimedOut`) instead of hanging, for **all** callers.
30
+ - **`lens_diagnostics`/`lsp_diagnostics` full sweep no longer floods a single-threaded server (#387)** — the sweep ran a flat 8-wide worker pool that was server-agnostic, so on a single-language repo all 8 concurrent touches hit one `tsserver`. It's single-threaded per project: they don't parallelize, they queue — inflating the working set (each `didOpen` can force a project recheck) and **cascading per-file-budget timeouts by queue position** (observed: 51 of 123 files "timed out" purely from being behind others, the count climbing 0→51 as the queue deepened). The sweep now **groups files by their primary server, serializes touches within a server (one in flight), and parallelizes across distinct servers** — real parallelism in a mixed TS+Python repo, no flooding in a single-language one. This uses the universal per-file `didOpen` path, so it works for every server regardless of `workspace/diagnostic` support (a single-request workspace pull for servers that advertise it remains the #387 follow-up).
31
+ - **An edit could hang indefinitely (and ignore Escape) on a wedged language server — the pre-dispatch LSP sync was unbounded** — after an edit, pi-lens syncs the new content to the language server (a `didChange`/`didOpen`) before dispatching lint. Client *acquisition* was capped, but the notify *write* was not: when the server's stdin isn't being drained (a CPU-bound/wedged server — e.g. TypeScript mid-recheck), that write backpressures forever, hanging the whole edit with **no per-call bound and no log at all** (the first instrumented phase, `read_file`, sits *after* the sync, so the stall left zero trace). Observed live: an edit wedged 8+ minutes with the server timing out on every request. Now the sync is raced against a hard budget (`PI_LENS_LSP_SYNC_BUDGET_MS`, default 3 s) **and** the turn's abort signal, so Escape cancels it and a slow server can't park the pipeline — the edit proceeds (the dispatch LSP runner, with its own 30 s cap, still tries). Escape now reaches this path because the turn signal is exposed for in-process LSP awaits (`getAmbientAbortSignal`), not just child-process spawns. Two observability gaps closed too: an abandoned sync logs an `lsp_sync_abandoned` phase (timeout vs aborted), and a new `tool_result_received` marker fires the instant pi-lens receives an edit — so a future stall is localizable (present-then-silent = in-pipeline; absent = upstream) instead of invisible.
32
+ - **Log retention was silently broken — rotated backups never got deleted, and three logs never rotated** — the 10 MB rotation worked, but the 7-day retention sweep that's supposed to reap the backups used a pattern (`/\.log\./`) that only matched the *legacy* `name.log.<ts>` shape, never the *current* `name.<ts>.log` that rotation actually produces — so every backup ever rotated accumulated forever (observed: ~200 MB of `~/.pi-lens` backups dating back 2.5 months against a 7-day policy). Separately, three of the eight global logs — `actionable-warnings.log`, `ast-grep-tools.log`, `dead-code.log` — were absent from the rotation list and grew unbounded. Both are fixed: the deletion pattern now matches both backup shapes (and never an active log), and all eight logs are managed from one shared `MANAGED_LOG_FILES` list so rotation and the storage summary can't drift apart again. Because `runLogCleanup` already runs unconditionally on every session start, the corrected sweep **self-heals existing backlogs** — the next launch after upgrade reaps each user's accumulated `>7 day` backups with no migration step.
33
+ - **`lens_diagnostics mode=full` could hang indefinitely and didn't cancel on Escape** — an unattended session was observed wedged for ~8 hours on a small repo. Two root causes, both fixed: **(1) Escape didn't cancel** — the tool honored only the tool-call `signal` positional, not `ctx.signal` (the turn-wired abort Escape fires for a registered extension tool), so the sweep ran on. It now combines both (`combineAbortSignals`, shared in `deadline-utils`) and threads the result through the LSP sweep and project-runner scan. **(2) It could hang forever** — per-file diagnostic waits were bounded, but *client acquisition* in the sweep was not, so a language server hanging on spawn/`initialize` parked a worker permanently. Now (a) each file gets a per-file wall-clock budget (`PI_LENS_LSP_WORKSPACE_PER_FILE_MS`, default 15 s) so a worker always returns to its abort check, and (b) the whole scan has a hard wall-clock ceiling (`PI_LENS_LENS_DIAGNOSTICS_FULL_TIMEOUT_MS`, default 3 min) that aborts it to partial results rather than never returning. The sweep also now emits **start + periodic heartbeat** latency logs (with `completed/total`, `timedOutFiles`) so a future stall is debuggable instead of silent, and the `scripts/analyze-pi-lens-logs.mjs` smell report now consumes them — surfacing sweeps that started but never completed (the hang/kill signature, with the last heartbeat's `completed X/Y`), sweeps whose files hit the per-file budget, and any `*_timeout` phases. The same pass repairs the analyzer's `slow-background-tasks` detector, whose regex had drifted from the runtime's `session_start task … success runMs=<n>` format and was silently matching **zero** of ~2k rows — it now flags the real multi-second startup tasks (`call-graph`, `knip`, `project-index`) again, and accepts both the current `runMs=` and the older `(<n>ms)` shapes. The same `ctx.signal` cancellation gap is fixed in the sibling tools `lsp_diagnostics` (batch/directory scans) and `ast_grep_search` (which was already abort-aware but checked the wrong signal).
34
+
35
+ ## [3.8.63] - 2026-07-01
36
+
37
+ ### Added
38
+
39
+ ### Changed
40
+
41
+ - **Consolidated the timeout-race helpers into one shared `clients/deadline-utils.ts` (#366)** — the "race a promise against a timer" pattern had drifted into three near-identical copies (`withTimeout` in the LSP client, `withBudget` in read-expansion, `withinRemaining` in module-report). They're now thin adapters over one `withDeadline` core, which also fixes two latent bugs the copies carried: `withBudget` didn't suppress the loser promise's late rejection (an unhandled rejection when the timer won first), and `withinRemaining` never cleared its timer. Behaviour at every call site is unchanged (covered by the consumer suites); the core's semantics are locked by dedicated tests including an explicit late-rejection-suppression probe.
42
+
43
+ ### Fixed
44
+
45
+ - **Bounded the remaining unbounded LSP requests — `workspace/symbol`, `textDocument/codeAction`, `workspace/executeCommand` (#365)** — like the pull-diagnostics fix (#364), these were sent via `safeSendRequest` with no `withTimeout` ceiling, so a language server that accepts a request but never replies (alive but hung) would make the `await` never resolve — hanging symbol search (`pilens_symbol_search`), code-action lookups, and server-command execution. `workspace/symbol` and `textDocument/codeAction` now route through the shared `navRequest` helper (its `withTimeout` ceiling + single-file stale-drop), timing out to `[]`. `workspace/executeCommand` — which is mutating and legitimately long-running — gets a separate, generous 30s anti-deadlock backstop (`PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS`) that returns an honest `executed:false, reason:"…may still be applying server-side"` rather than truncating valid work or pretending it ran; its allowlist-by-advertisement and server-edit-window hardening are preserved. Real (non-timeout) errors still propagate throughout.
46
+ - **Autofix project-snapshot walk no longer freezes the TUI on large repos (#368)** — `snapshotProjectFiles` (the `tool_result` autofix side-effect detection that snapshots the project tree before/after a formatter or fixer runs, to catch files it changed as a side effect) was a fully synchronous `readdirSync`/`statSync` walk bounded only by a 5,000-file cap — a ~130ms event-loop block at the cap (2–4× that under load), stalling keystrokes on large projects while autofix ran. It now walks asynchronously and yields to the event loop every 500 files, so it holds the loop only for a short chunk. The scan cap and directory-exclusion/confinement behavior are unchanged; a cap-scale (~5k-file) event-loop occupancy guard asserts the walk keeps yielding.
47
+ - **Bounded LSP pull-diagnostics request — a hung server can no longer hang `lens_diagnostics` (#349, #364)** — the pull path in `clientWaitForDiagnostics` awaited a `textDocument/diagnostic` request via `safeSendRequest`, which only settles on a reply or a *destroyed* stream. A pull-mode server that is alive but hung (accepts the request, never replies) made that `await` never resolve, hanging the diagnostics wait → the `dispatch_lint` pipeline phase → `flushDebouncedToolResults` → `lens_diagnostics`, forever (and `safeSpawnAsync`'s 30s cap doesn't apply — it's a pipe request, not a spawn). Unlike the navigation/init/shutdown callers, this request had no `withTimeout` ceiling; the `timeoutMs` passed into `clientWaitForDiagnostics` only bounded the push backstop and the pull *retry interval*, never the individual request. The request is now wrapped in the existing `withTimeout` helper, bounded by `min(PULL_REQUEST_TIMEOUT_MS, remaining caller budget)` (env `PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS`, default 10s, mirroring `NAV_REQUEST_TIMEOUT_MS`). On timeout the request is caught as `unavailable`, which per #240 is not read as clean and falls through to the already-bounded push backstop. Explains the intermittent repro — it only fires for pull-mode servers (rust-analyzer being the classic) stalling mid-analysis. Regression test: a pull server whose `sendRequest` never resolves now resolves within the caller's budget instead of hanging.
48
+ - **POSIX LSP teardown now cleans up the whole process tree, not just the direct child (#362, #363)** — on POSIX, LSP servers launched through wrappers (npm shims, shell/node launchers) could leave descendants alive after pi-lens reset or shut down an LSP service; observed most visibly as `vscode-html-language-server` processes accumulating across long-lived zellij sessions and pressuring memory. LSP servers are now spawned detached into their own process group and teardown signals the group (`process.kill(-pid, ...)`) before falling back to the direct child, bringing POSIX cleanup in line with the existing Windows `taskkill /T` behavior. Windows teardown is unchanged (`taskkill /T` mid-session, handle-only kill for `processExiting`). Guarded by the `pid <= 0` check so a group signal can never degrade into `process.kill(-0)` against pi-lens's own process group.
49
+
13
50
  ## [3.8.62] - 2026-06-28
14
51
 
15
52
  ### Added
package/README.md CHANGED
@@ -71,7 +71,7 @@ Thanks goes to these wonderful people:
71
71
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/anh-chu"><img src="https://avatars.githubusercontent.com/u/34973633?v=4" width="100px;" alt=""/><br /><sub><b>Anh Chu</b></sub></a><br /><a href="#code-anh-chu" title="Code">💻</a></td>
72
72
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Istar-Eldritch"><img src="https://avatars.githubusercontent.com/u/3746468?v=4" width="100px;" alt=""/><br /><sub><b>Ruben Paz</b></sub></a><br /><a href="#code-Istar-Eldritch" title="Code">💻</a> <a href="#bug-Istar-Eldritch" title="Bug reports">🐛</a></td>
73
73
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/tifandotme"><img src="https://avatars.githubusercontent.com/u/33323177?v=4" width="100px;" alt=""/><br /><sub><b>Tifan Dwi Avianto</b></sub></a><br /><a href="#code-tifandotme" title="Code">💻</a></td>
74
- </tr>
74
+ </tr><br />
75
75
  <tr>
76
76
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/ricardoraposo"><img src="https://avatars.githubusercontent.com/u/50217712?v=4" width="100px;" alt=""/><br /><sub><b>Ricardo Raposo</b></sub></a><br /><a href="#code-ricardoraposo" title="Code">💻</a></td>
77
77
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/cjunxiang"><img src="https://avatars.githubusercontent.com/u/26619858?v=4" width="100px;" alt=""/><br /><sub><b>C.Junxiang</b></sub></a><br /><a href="#code-cjunxiang" title="Code">💻</a> <a href="#bug-cjunxiang" title="Bug reports">🐛</a></td>
@@ -80,7 +80,7 @@ Thanks goes to these wonderful people:
80
80
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/fractiunate"><img src="https://avatars.githubusercontent.com/u/78024279?v=4" width="100px;" alt=""/><br /><sub><b>Fractiunate // David Rahäuser</b></sub></a><br /><a href="#code-fractiunate" title="Code">💻</a></td>
81
81
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/bloodf"><img src="https://avatars.githubusercontent.com/u/1626923?v=4" width="100px;" alt=""/><br /><sub><b>Heitor Ramon Ribeiro</b></sub></a><br /><a href="#code-bloodf" title="Code">💻</a></td>
82
82
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/JayceFreeman"><img src="https://avatars.githubusercontent.com/u/92962110?v=4" width="100px;" alt=""/><br /><sub><b>JayceFreeman</b></sub></a><br /><a href="#code-JayceFreeman" title="Code">💻</a></td>
83
- </tr>
83
+ </tr><br />
84
84
  <tr>
85
85
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/shaDmx"><img src="https://avatars.githubusercontent.com/u/91132641?v=4" width="100px;" alt=""/><br /><sub><b>Max L.</b></sub></a><br /><a href="#code-shaDmx" title="Code">💻</a></td>
86
86
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Roman-Galeev"><img src="https://avatars.githubusercontent.com/u/40388226?v=4" width="100px;" alt=""/><br /><sub><b>Roman Galeev</b></sub></a><br /><a href="#code-Roman-Galeev" title="Code">💻</a></td>
@@ -89,7 +89,7 @@ Thanks goes to these wonderful people:
89
89
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/flowing-abyss"><img src="https://avatars.githubusercontent.com/u/98622217?v=4" width="100px;" alt=""/><br /><sub><b>flowing-abyss</b></sub></a><br /><a href="#code-flowing-abyss" title="Code">💻</a></td>
90
90
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/jerryfan"><img src="https://avatars.githubusercontent.com/u/2540814?v=4" width="100px;" alt=""/><br /><sub><b>jerryfan</b></sub></a><br /><a href="#code-jerryfan" title="Code">💻</a></td>
91
91
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/loss-and-quick"><img src="https://avatars.githubusercontent.com/u/39405619?v=4" width="100px;" alt=""/><br /><sub><b>minicx</b></sub></a><br /><a href="#code-loss-and-quick" title="Code">💻</a></td>
92
- </tr>
92
+ </tr><br />
93
93
  <tr>
94
94
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/spyrosbazios"><img src="https://avatars.githubusercontent.com/u/37960233?v=4" width="100px;" alt=""/><br /><sub><b>spyrosbazios</b></sub></a><br /><a href="#code-spyrosbazios" title="Code">💻</a></td>
95
95
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/trvon"><img src="https://avatars.githubusercontent.com/u/6031322?v=4" width="100px;" alt=""/><br /><sub><b>Trevon</b></sub></a><br /><a href="#code-trvon" title="Code">💻</a></td>
@@ -98,7 +98,7 @@ Thanks goes to these wonderful people:
98
98
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/mdbooth"><img src="https://avatars.githubusercontent.com/u/1318691?v=4" width="100px;" alt=""/><br /><sub><b>Matthew Booth</b></sub></a><br /><a href="#bug-mdbooth" title="Bug reports">🐛</a></td>
99
99
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Wiedzmin89"><img src="https://avatars.githubusercontent.com/u/61706855?v=4" width="100px;" alt=""/><br /><sub><b>Wiedzmin89</b></sub></a><br /><a href="#ideas-Wiedzmin89" title="Ideas & Planning">🤔</a></td>
100
100
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/grodingo"><img src="https://avatars.githubusercontent.com/u/244184972?v=4" width="100px;" alt=""/><br /><sub><b>Virgile</b></sub></a><br /><a href="#bug-grodingo" title="Bug reports">🐛</a></td>
101
- </tr>
101
+ </tr><br />
102
102
  <tr>
103
103
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/finnvyrn"><img src="https://avatars.githubusercontent.com/u/90801772?v=4" width="100px;" alt=""/><br /><sub><b>Finn</b></sub></a><br /><a href="#ideas-finnvyrn" title="Ideas & Planning">🤔</a></td>
104
104
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/ywh555hhh"><img src="https://avatars.githubusercontent.com/u/121592812?v=4" width="100px;" alt=""/><br /><sub><b>Wayne E</b></sub></a><br /><a href="#bug-ywh555hhh" title="Bug reports">🐛</a></td>
@@ -107,7 +107,7 @@ Thanks goes to these wonderful people:
107
107
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/pvtri96"><img src="https://avatars.githubusercontent.com/u/28696888?v=4" width="100px;" alt=""/><br /><sub><b>Tri Van Pham</b></sub></a><br /><a href="#bug-pvtri96" title="Bug reports">🐛</a></td>
108
108
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/BingWuJ"><img src="https://avatars.githubusercontent.com/u/117666511?v=4" width="100px;" alt=""/><br /><sub><b>BingWuJ</b></sub></a><br /><a href="#bug-BingWuJ" title="Bug reports">🐛</a></td>
109
109
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/tackleberrybey"><img src="https://avatars.githubusercontent.com/u/156954032?v=4" width="100px;" alt=""/><br /><sub><b>tackleberrybey</b></sub></a><br /><a href="#bug-tackleberrybey" title="Bug reports">🐛</a></td>
110
- </tr>
110
+ </tr><br />
111
111
  <tr>
112
112
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/TaterDoge"><img src="https://avatars.githubusercontent.com/u/121467933?v=4" width="100px;" alt=""/><br /><sub><b>Mariann Abshire</b></sub></a><br /><a href="#bug-TaterDoge" title="Bug reports">🐛</a></td>
113
113
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/ProbabilityEngineer"><img src="https://avatars.githubusercontent.com/u/38498804?v=4" width="100px;" alt=""/><br /><sub><b>ProbabilityEngineer</b></sub></a><br /><a href="#ideas-ProbabilityEngineer" title="Ideas & Planning">🤔</a></td>
@@ -116,7 +116,7 @@ Thanks goes to these wonderful people:
116
116
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/rstacruz"><img src="https://avatars.githubusercontent.com/u/74385?v=4" width="100px;" alt=""/><br /><sub><b>Rico Sta. Cruz</b></sub></a><br /><a href="#ideas-rstacruz" title="Ideas & Planning">🤔</a></td>
117
117
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/acidnik"><img src="https://avatars.githubusercontent.com/u/1227955?v=4" width="100px;" alt=""/><br /><sub><b>Nikita Bilous</b></sub></a><br /><a href="#bug-acidnik" title="Bug reports">🐛</a></td>
118
118
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/av1155"><img src="https://avatars.githubusercontent.com/u/117413846?v=4" width="100px;" alt=""/><br /><sub><b>Andrea Arturo Venti Fuentes</b></sub></a><br /><a href="#bug-av1155" title="Bug reports">🐛</a></td>
119
- </tr>
119
+ </tr><br />
120
120
  <tr>
121
121
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/dacec354"><img src="https://avatars.githubusercontent.com/u/90093629?v=4" width="100px;" alt=""/><br /><sub><b>dacec354</b></sub></a><br /><a href="#bug-dacec354" title="Bug reports">🐛</a></td>
122
122
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/xuli500177"><img src="https://avatars.githubusercontent.com/u/62830942?v=4" width="100px;" alt=""/><br /><sub><b>Xu Yili</b></sub></a><br /><a href="#bug-xuli500177" title="Bug reports">🐛</a></td>
@@ -125,7 +125,7 @@ Thanks goes to these wonderful people:
125
125
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/asolopovas"><img src="https://avatars.githubusercontent.com/u/6893216?v=4" width="100px;" alt=""/><br /><sub><b>Andrius Solopovas</b></sub></a><br /><a href="#bug-asolopovas" title="Bug reports">🐛</a></td>
126
126
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/GrahamJenkins"><img src="https://avatars.githubusercontent.com/u/6607975?v=4" width="100px;" alt=""/><br /><sub><b>Graham Jenkins</b></sub></a><br /><a href="#bug-GrahamJenkins" title="Bug reports">🐛</a></td>
127
127
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/wings1848"><img src="https://avatars.githubusercontent.com/u/120104016?v=4" width="100px;" alt=""/><br /><sub><b>Wings Butterfly</b></sub></a><br /><a href="#bug-wings1848" title="Bug reports">🐛</a></td>
128
- </tr>
128
+ </tr><br />
129
129
  <tr>
130
130
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/JohannesKlauss"><img src="https://avatars.githubusercontent.com/u/6214415?v=4" width="100px;" alt=""/><br /><sub><b>Johannes Klauss</b></sub></a><br /><a href="#ideas-JohannesKlauss" title="Ideas & Planning">🤔</a></td>
131
131
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/tan-yong-sheng"><img src="https://avatars.githubusercontent.com/u/64836390?v=4" width="100px;" alt=""/><br /><sub><b>Tan Yong Sheng</b></sub></a><br /><a href="#ideas-tan-yong-sheng" title="Ideas & Planning">🤔</a></td>
@@ -134,7 +134,7 @@ Thanks goes to these wonderful people:
134
134
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Viterkim"><img src="https://avatars.githubusercontent.com/u/17838985?v=4" width="100px;" alt=""/><br /><sub><b>Viktor</b></sub></a><br /><a href="#bug-Viterkim" title="Bug reports">🐛</a></td>
135
135
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/ortonomy"><img src="https://avatars.githubusercontent.com/u/6688676?v=4" width="100px;" alt=""/><br /><sub><b>Gregory Orton</b></sub></a><br /><a href="#bug-ortonomy" title="Bug reports">🐛</a></td>
136
136
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/jimallen"><img src="https://avatars.githubusercontent.com/u/868773?v=4" width="100px;" alt=""/><br /><sub><b>Jim Allen</b></sub></a><br /><a href="#bug-jimallen" title="Bug reports">🐛</a></td>
137
- </tr>
137
+ </tr><br />
138
138
  <tr>
139
139
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/quantfiction"><img src="https://avatars.githubusercontent.com/u/49965454?v=4" width="100px;" alt=""/><br /><sub><b>quantfiction</b></sub></a><br /><a href="#bug-quantfiction" title="Bug reports">🐛</a></td>
140
140
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Artawower"><img src="https://avatars.githubusercontent.com/u/16963833?v=4" width="100px;" alt=""/><br /><sub><b>Art</b></sub></a><br /><a href="#bug-Artawower" title="Bug reports">🐛</a></td>
@@ -143,10 +143,13 @@ Thanks goes to these wonderful people:
143
143
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/stark-bit"><img src="https://avatars.githubusercontent.com/u/44064758?v=4" width="100px;" alt=""/><br /><sub><b>Rei Starks</b></sub></a><br /><a href="#bug-stark-bit" title="Bug reports">🐛</a></td>
144
144
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/robertoecf"><img src="https://avatars.githubusercontent.com/u/54923863?v=4" width="100px;" alt=""/><br /><sub><b>Roberto Freitas</b></sub></a><br /><a href="#bug-robertoecf" title="Bug reports">🐛</a></td>
145
145
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/tmttodd"><img src="https://avatars.githubusercontent.com/u/160077416?v=4" width="100px;" alt=""/><br /><sub><b>tmttodd</b></sub></a><br /><a href="#bug-tmttodd" title="Bug reports">🐛</a></td>
146
- </tr>
146
+ </tr><br />
147
147
  <tr>
148
148
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/smaharnav"><img src="https://avatars.githubusercontent.com/u/263328627?v=4" width="100px;" alt=""/><br /><sub><b>smaharnav</b></sub></a><br /><a href="#bug-smaharnav" title="Bug reports">🐛</a></td>
149
149
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/MzaGuille"><img src="https://avatars.githubusercontent.com/u/151482698?v=4" width="100px;" alt=""/><br /><sub><b>Mza.Guille</b></sub></a><br /><a href="#bug-MzaGuille" title="Bug reports">🐛</a></td>
150
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/cmptr"><img src="https://avatars.githubusercontent.com/u/32746889?v=4" width="100px;" alt=""/><br /><sub><b>Aaron Bell</b></sub></a><br /><a href="#code-cmptr" title="Code">💻</a> <a href="#bug-cmptr" title="Bug reports">🐛</a></td>
151
+ <td align="center" valign="top" width="14.28%"><a href="http://jasonrimmer.com/"><img src="https://avatars.githubusercontent.com/u/629?v=4" width="100px;" alt=""/><br /><sub><b>Jason Rimmer</b></sub></a><br /><a href="#bug-jrimmer" title="Bug reports">🐛</a></td>
152
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/moj02090"><img src="https://avatars.githubusercontent.com/u/57255166?v=4" width="100px;" alt=""/><br /><sub><b>moj02090</b></sub></a><br /><a href="#code-moj02090" title="Code">💻</a></td>
150
153
  </tr>
151
154
  </tbody>
152
155
  </table>
@@ -0,0 +1,93 @@
1
+ /**
2
+ * One implementation of the "race a promise against a timer" pattern that had
3
+ * drifted into three near-identical copies (#366): `withTimeout` (clients/lsp),
4
+ * `withBudget` (read-expansion), and `withinRemaining` (module-report-lsp).
5
+ *
6
+ * The differences between them were real — timeout can *reject* or *resolve
7
+ * undefined*, and the raced promise's own rejection can *propagate* or be
8
+ * *swallowed* — so they are kept as named adapters over one core. Consolidating
9
+ * fixes two latent bugs the copies had: `withBudget` did not suppress the loser
10
+ * promise's late rejection (an unhandled rejection if the timer won first), and
11
+ * `withinRemaining` never cleared its timer.
12
+ */
13
+ /**
14
+ * Combine multiple abort signals into one that aborts when ANY of them does.
15
+ * Returns the single signal unchanged when only one is live, and `undefined`
16
+ * when none are — so callers can pass it straight through. Used so a tool honors
17
+ * both its tool-call `signal` positional and the turn-wired `ctx.signal` (Escape),
18
+ * and to fold in a wall-clock ceiling via `AbortSignal.timeout`.
19
+ */
20
+ export function combineAbortSignals(...signals) {
21
+ const live = signals.filter((s) => s !== undefined);
22
+ if (live.length <= 1)
23
+ return live[0];
24
+ if (typeof AbortSignal.any === "function")
25
+ return AbortSignal.any(live);
26
+ const controller = new AbortController();
27
+ for (const s of live) {
28
+ if (s.aborted) {
29
+ controller.abort(s.reason);
30
+ break;
31
+ }
32
+ s.addEventListener("abort", () => controller.abort(s.reason), { once: true });
33
+ }
34
+ return controller.signal;
35
+ }
36
+ export function withDeadline(promise, options) {
37
+ const onTimeout = options.onTimeout ?? "reject";
38
+ const onReject = options.onReject ?? "propagate";
39
+ const ms = options.ms ??
40
+ (options.deadlineAt !== undefined ? options.deadlineAt - Date.now() : 0);
41
+ // Past deadline / non-positive budget: settle immediately, no timer.
42
+ if (ms <= 0) {
43
+ return onTimeout === "undefined"
44
+ ? Promise.resolve(undefined)
45
+ : Promise.reject(new Error(`Timeout after ${Math.max(0, ms)}ms`));
46
+ }
47
+ // Base promise with rejection handled per `onReject`. In propagate mode we
48
+ // still attach a no-op catch so that if the timer wins the race, the loser
49
+ // promise's later rejection does not surface as an unhandled rejection.
50
+ const base = onReject === "undefined" ? promise.catch(() => undefined) : promise;
51
+ if (onReject === "propagate")
52
+ promise.catch(() => { });
53
+ let timer;
54
+ const timeoutPromise = new Promise((resolve, reject) => {
55
+ timer = setTimeout(() => {
56
+ if (onTimeout === "undefined")
57
+ resolve(undefined);
58
+ else
59
+ reject(new Error(`Timeout after ${ms}ms`));
60
+ }, ms);
61
+ });
62
+ return Promise.race([base, timeoutPromise]).finally(() => {
63
+ if (timer)
64
+ clearTimeout(timer);
65
+ });
66
+ }
67
+ /**
68
+ * Resolve `promise`, or reject with `Error("Timeout after <ms>ms")` once
69
+ * `timeoutMs` elapses. The raced promise's own rejection propagates.
70
+ */
71
+ export function withTimeout(promise, timeoutMs) {
72
+ return withDeadline(promise, { ms: timeoutMs });
73
+ }
74
+ /**
75
+ * Resolve `promise`, or `undefined` once `budgetMs` elapses (a non-positive
76
+ * budget resolves `undefined` immediately). The raced promise's own rejection
77
+ * propagates.
78
+ */
79
+ export function withBudget(promise, budgetMs) {
80
+ return withDeadline(promise, { ms: budgetMs, onTimeout: "undefined" });
81
+ }
82
+ /**
83
+ * Resolve `promise`, or `undefined` once the shared `deadlineAt` passes (a past
84
+ * deadline resolves `undefined` immediately). The raced promise's own rejection
85
+ * is swallowed to `undefined`.
86
+ */
87
+ export function withinRemaining(promise, deadlineAt) {
88
+ return withDeadline(promise, {
89
+ deadlineAt,
90
+ onTimeout: "undefined",
91
+ onReject: "undefined",
92
+ });
93
+ }
@@ -56,6 +56,7 @@ const _installerRequire = createRequire(import.meta.url);
56
56
  import { createGunzip } from "node:zlib";
57
57
  import { isTestMode } from "../env-utils.js";
58
58
  import { getGlobalPiLensDir } from "../file-utils.js";
59
+ import { allAvailableGlobalBinDirs, installArgs, pmBinary, resolveNodePackageManager, } from "../package-manager.js";
59
60
  // Global installation directory for pi-lens tools
60
61
  const TOOLS_DIR = path.join(getGlobalPiLensDir(), "tools");
61
62
  // Directory for GitHub-downloaded binaries
@@ -1150,6 +1151,8 @@ export async function updateProbeCache(toolId, resolvedPath) {
1150
1151
  export function resetProbeCacheStateForTesting() {
1151
1152
  _probeCache = null;
1152
1153
  _probeCacheDirty = false;
1154
+ resolvedPathCache.clear();
1155
+ ensureInFlight.clear();
1153
1156
  if (_probeCacheFlushTimer !== null) {
1154
1157
  clearTimeout(_probeCacheFlushTimer);
1155
1158
  _probeCacheFlushTimer = null;
@@ -1637,19 +1640,10 @@ async function getNpmGlobalBinCandidates() {
1637
1640
  else {
1638
1641
  add(path.join(os.homedir(), ".npm-global", "bin"));
1639
1642
  }
1640
- const pm = process.platform === "win32" ? "npm.cmd" : "npm";
1641
- const prefix = await new Promise((resolve) => {
1642
- const proc = spawn(pm, ["config", "get", "prefix"], {
1643
- stdio: ["ignore", "pipe", "pipe"],
1644
- shell: process.platform === "win32",
1645
- });
1646
- let stdout = "";
1647
- proc.stdout?.on("data", (data) => (stdout += data));
1648
- proc.on("exit", (code) => resolve(code === 0 ? stdout.trim() : ""));
1649
- proc.on("error", () => resolve(""));
1650
- });
1651
- if (prefix) {
1652
- add(process.platform === "win32" ? prefix : path.join(prefix, "bin"));
1643
+ // Global bin dirs for every installed manager (npm/pnpm/yarn/bun) a tool
1644
+ // may have been installed globally via any of them.
1645
+ for (const dir of await allAvailableGlobalBinDirs()) {
1646
+ add(dir);
1653
1647
  }
1654
1648
  return dirs;
1655
1649
  }
@@ -2225,21 +2219,19 @@ async function installNpmTool(packageName, binaryName) {
2225
2219
  catch {
2226
2220
  await fs.writeFile(packageJsonPath, JSON.stringify({ name: "pi-lens-tools", version: "1.0.0" }, null, 2));
2227
2221
  }
2228
- // Install via npm or bun (use .cmd on Windows)
2222
+ // Resolve the package manager for the tools dir and build install args.
2229
2223
  const isWindows = process.platform === "win32";
2230
- let pm = isWindows ? "npm.cmd" : "npm";
2231
- if (process.env.BUN_INSTALL) {
2232
- pm = isWindows ? "bun.exe" : "bun";
2233
- }
2224
+ const pm = await resolveNodePackageManager(TOOLS_DIR);
2225
+ const pmCommand = pmBinary(pm);
2234
2226
  // Use --ignore-scripts unless the package explicitly needs postinstall
2235
2227
  // (e.g. biome downloads a platform-specific native binary via postinstall).
2236
2228
  const needsScripts = NEEDS_POSTINSTALL.has(packageName);
2237
- const baseInstallArgs = needsScripts
2238
- ? ["install", packageName]
2239
- : ["install", "--ignore-scripts", packageName];
2229
+ const baseInstallArgs = installArgs(pm, packageName, {
2230
+ ignoreScripts: !needsScripts,
2231
+ });
2240
2232
  const INSTALL_TIMEOUT_MS = 120_000;
2241
2233
  const runInstallAttempt = async (args) => new Promise((resolve) => {
2242
- const proc = spawn(pm, args, {
2234
+ const proc = spawn(pmCommand, args, {
2243
2235
  cwd: TOOLS_DIR,
2244
2236
  stdio: ["ignore", "pipe", "pipe"],
2245
2237
  shell: isWindows, // Required for .cmd files on Windows
@@ -2263,13 +2255,14 @@ async function installNpmTool(packageName, binaryName) {
2263
2255
  });
2264
2256
  });
2265
2257
  let outcome = await runInstallAttempt(baseInstallArgs);
2266
- const isNpm = pm === "npm" || pm === "npm.cmd";
2258
+ // --legacy-peer-deps is npm-only; retry just npm's ERESOLVE failures.
2267
2259
  const erResolve = outcome.ok === false &&
2268
2260
  /npm\s+error\s+ERESOLVE|\bERESOLVE\b|could not resolve/i.test(outcome.stderr);
2269
- if (isNpm && erResolve) {
2270
- const retryArgs = needsScripts
2271
- ? ["install", "--legacy-peer-deps", packageName]
2272
- : ["install", "--ignore-scripts", "--legacy-peer-deps", packageName];
2261
+ if (pm === "npm" && erResolve) {
2262
+ const retryArgs = installArgs(pm, packageName, {
2263
+ ignoreScripts: !needsScripts,
2264
+ legacyPeerDeps: true,
2265
+ });
2273
2266
  logSessionStart(`auto-install npm ${packageName}: retry with --legacy-peer-deps after ERESOLVE`);
2274
2267
  outcome = await runInstallAttempt(retryArgs);
2275
2268
  }
@@ -2559,8 +2552,17 @@ export async function installTool(toolId) {
2559
2552
  * Ensure a tool is installed (check first, install if missing)
2560
2553
  */
2561
2554
  export async function ensureTool(toolId, opts) {
2555
+ const cacheResolvedPath = (result) => {
2556
+ if (result) {
2557
+ resolvedPathCache.set(toolId, result);
2558
+ void updateProbeCache(toolId, result);
2559
+ }
2560
+ return result;
2561
+ };
2562
2562
  // forceReinstall: nuke caches, download from managed source, skip PATH entirely.
2563
2563
  // Used when a PATH-resolved tool proves broken at launch (e.g. broken symlink).
2564
+ // allowInstall:false wins over forceReinstall: caches are still cleared, but
2565
+ // the function falls back to discovery-only and never downloads.
2564
2566
  if (opts?.forceReinstall) {
2565
2567
  const ensureStartMs = Date.now();
2566
2568
  logSessionStart(`auto-install ensure ${toolId}: force reinstall — clearing caches`);
@@ -2576,6 +2578,10 @@ export async function ensureTool(toolId, opts) {
2576
2578
  catch {
2577
2579
  // best-effort
2578
2580
  }
2581
+ if (opts.allowInstall === false) {
2582
+ logSessionStart(`auto-install ensure ${toolId}: force reinstall blocked — install disabled, discovery only (${Date.now() - ensureStartMs}ms)`);
2583
+ return cacheResolvedPath(await getToolPath(toolId));
2584
+ }
2579
2585
  // Force download
2580
2586
  const installed = await installTool(toolId);
2581
2587
  if (!installed) {
@@ -2583,10 +2589,8 @@ export async function ensureTool(toolId, opts) {
2583
2589
  return undefined;
2584
2590
  }
2585
2591
  // Find the newly installed binary (github-local check now comes before PATH)
2586
- const result = await getToolPath(toolId);
2592
+ const result = cacheResolvedPath(await getToolPath(toolId));
2587
2593
  if (result) {
2588
- resolvedPathCache.set(toolId, result);
2589
- void updateProbeCache(toolId, result);
2590
2594
  logSessionStart(`auto-install ensure ${toolId}: force reinstall success at ${result} (${Date.now() - ensureStartMs}ms)`);
2591
2595
  }
2592
2596
  return result;
@@ -2604,10 +2608,13 @@ export async function ensureTool(toolId, opts) {
2604
2608
  }
2605
2609
  // Coalesce the whole ensure operation, not just installation. Most startup
2606
2610
  // duplicates race while checking already-installed tools, before installTool()
2607
- // would ever run.
2608
- const inFlight = ensureInFlight.get(toolId);
2611
+ // would ever run. The key includes the install policy so a discovery-only
2612
+ // caller cannot accidentally inherit an install-allowed caller's download (or
2613
+ // vice versa).
2614
+ const inFlightKey = opts?.allowInstall === false ? `${toolId}:discovery-only` : toolId;
2615
+ const inFlight = ensureInFlight.get(inFlightKey);
2609
2616
  if (inFlight) {
2610
- logSessionStart(`auto-install ensure ${toolId}: waiting for in-flight ensure`);
2617
+ logSessionStart(`auto-install ensure ${toolId}: waiting for in-flight ensure (${inFlightKey})`);
2611
2618
  return inFlight;
2612
2619
  }
2613
2620
  const ensureStartMs = Date.now();
@@ -2621,6 +2628,14 @@ export async function ensureTool(toolId, opts) {
2621
2628
  logSessionStart(`auto-install ensure ${toolId}: already available at ${existingPath} (${Date.now() - ensureStartMs}ms)`);
2622
2629
  return existingPath;
2623
2630
  }
2631
+ // Discovery and install are SEPARATE concerns. getToolPath() above already
2632
+ // probed PATH / npm-global / managed bin — offline-safe, no download. When the
2633
+ // caller forbids installs (allowInstall:false, e.g. PI_LENS_DISABLE_LSP_INSTALL=1)
2634
+ // we must still return a discovered binary and only skip the actual install.
2635
+ if (opts?.allowInstall === false) {
2636
+ logSessionStart(`auto-install ensure ${toolId}: install disabled — discovery only, not found (${Date.now() - ensureStartMs}ms)`);
2637
+ return undefined;
2638
+ }
2624
2639
  const installed = await installTool(toolId);
2625
2640
  if (!installed) {
2626
2641
  logSessionStart(`auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`);
@@ -2637,12 +2652,12 @@ export async function ensureTool(toolId, opts) {
2637
2652
  }
2638
2653
  return result;
2639
2654
  })();
2640
- ensureInFlight.set(toolId, ensurePromise);
2655
+ ensureInFlight.set(inFlightKey, ensurePromise);
2641
2656
  try {
2642
2657
  return await ensurePromise;
2643
2658
  }
2644
2659
  finally {
2645
- ensureInFlight.delete(toolId);
2660
+ ensureInFlight.delete(inFlightKey);
2646
2661
  }
2647
2662
  }
2648
2663
  // --- Integration Helpers ---
@@ -6,9 +6,11 @@
6
6
  * PI_LENS_MAX_LOG_SIZE_MB - Max size before rotation (default: 10)
7
7
  *
8
8
  * Scope:
9
- * - ~/.pi-lens/*.log (latency.log, sessionstart.log, tree-sitter.log)
9
+ * - ~/.pi-lens/*.log (all 8 global logs — see MANAGED_LOG_FILES: latency,
10
+ * sessionstart, tree-sitter, cascade, read-guard, actionable-warnings,
11
+ * ast-grep-tools, dead-code)
10
12
  * - ~/.pi-lens/logs/*.jsonl (daily diagnostic logs)
11
- * - ~/.pi-lens/*.log.* (rotated backups)
13
+ * - ~/.pi-lens/<name>.<timestamp>.log (rotated backups, and legacy .log.<ts>)
12
14
  *
13
15
  * Excluded (intentionally NOT cleaned - project-scoped or persistent):
14
16
  * - <project-data>/worklog.jsonl - Agent fixable diagnostics
@@ -25,6 +27,30 @@ import * as path from "node:path";
25
27
  import { getGlobalPiLensDir } from "./file-utils.js";
26
28
  const LOG_DIR = getGlobalPiLensDir();
27
29
  const LOGS_SUBDIR = path.join(LOG_DIR, "logs");
30
+ /**
31
+ * Every global `.log` pi-lens writes under ~/.pi-lens. Kept as one list so
32
+ * rotation and the storage summary can't drift out of sync — that drift is
33
+ * exactly what left actionable-warnings/ast-grep-tools/dead-code unrotated and
34
+ * growing unbounded. New global log file? Add it here.
35
+ */
36
+ export const MANAGED_LOG_FILES = [
37
+ "latency.log",
38
+ "sessionstart.log",
39
+ "tree-sitter.log",
40
+ "cascade.log",
41
+ "read-guard.log",
42
+ "actionable-warnings.log",
43
+ "ast-grep-tools.log",
44
+ "dead-code.log",
45
+ ];
46
+ /**
47
+ * Matches a rotated backup, never an active log. Rotation writes
48
+ * `name.<ISO-timestamp>.log` (timestamp before the extension); an older shape
49
+ * was `name.log.<timestamp>`. Match both so every backup — whichever version
50
+ * produced it — is reaped by the retention sweep. The active `name.log` has no
51
+ * timestamp segment and no trailing suffix after `.log`, so it never matches.
52
+ */
53
+ export const ROTATED_BACKUP_RE = /(\.\d{4}-\d{2}-\d{2}T.*\.log|\.log\..+)$/;
28
54
  function getConfig() {
29
55
  return {
30
56
  retentionDays: Math.max(1, Number.parseInt(process.env.PI_LENS_LOG_RETENTION_DAYS ?? "7", 10) || 7),
@@ -123,17 +149,15 @@ export function runLogCleanup(dbg) {
123
149
  // Cleanup old daily diagnostic logs (*.jsonl)
124
150
  const dailyLogs = cleanupOldLogs(LOGS_SUBDIR, /\.jsonl$/, config.retentionDays);
125
151
  results.cleaned += dailyLogs.deleted.length;
126
- // Cleanup old rotated log backups (*.log.*)
127
- const rotatedLogs = cleanupOldLogs(LOG_DIR, /\.log\./, config.retentionDays);
152
+ // Cleanup old rotated log backups. This sweep runs unconditionally on every
153
+ // session start, so correcting the pattern self-heals any pre-existing
154
+ // backlog on the next launch — no separate migration needed. (The prior
155
+ // `/\.log\./` only matched the legacy `name.log.<ts>` shape, never the
156
+ // current `name.<ts>.log`, so backups accumulated indefinitely.)
157
+ const rotatedLogs = cleanupOldLogs(LOG_DIR, ROTATED_BACKUP_RE, config.retentionDays);
128
158
  results.cleaned += rotatedLogs.deleted.length;
129
159
  // Check main logs for rotation
130
- const mainLogs = [
131
- path.join(LOG_DIR, "latency.log"),
132
- path.join(LOG_DIR, "sessionstart.log"),
133
- path.join(LOG_DIR, "tree-sitter.log"),
134
- path.join(LOG_DIR, "cascade.log"),
135
- path.join(LOG_DIR, "read-guard.log"),
136
- ];
160
+ const mainLogs = MANAGED_LOG_FILES.map((name) => path.join(LOG_DIR, name));
137
161
  for (const logFile of mainLogs) {
138
162
  const rotation = rotateLogIfNeeded(logFile, config.maxSizeMB);
139
163
  if (rotation.rotated) {
@@ -171,8 +195,7 @@ export function getLogStorageSummary() {
171
195
  const files = [];
172
196
  let totalMB = 0;
173
197
  // Main logs
174
- const mainLogs = ["latency.log", "sessionstart.log", "tree-sitter.log"];
175
- for (const name of mainLogs) {
198
+ for (const name of MANAGED_LOG_FILES) {
176
199
  const filePath = path.join(LOG_DIR, name);
177
200
  if (fs.existsSync(filePath)) {
178
201
  const sizeMB = getFileSizeMB(filePath);