pi-lens 3.8.27 → 3.8.28

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 CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to pi-lens will be documented in this file.
4
4
 
5
+ ## [3.8.28] - 2026-04-19
6
+
7
+ ### Fixed
8
+ - **Session startup no longer blocks the Node event loop** — tool availability probes (biome, ast-grep, ruff, knip, jscpd, madge) now run via async `ensureAvailable()` in a fire-and-forget IIFE instead of `setImmediate` + `spawnSync`, eliminating ~8–10 s of main-thread freeze on startup.
9
+ - **Biome binary lookup extended** — `getBiomeBinary()` now checks `~/.pi-lens/tools/node_modules/.bin/biome` so the async probe finds the pre-installed binary without falling back to `npx`.
10
+ - **CSS roots and Windows LSP shims tightened** — improved root resolution for CSS language server on Windows.
11
+ - **Zig compile coverage kept active** — LSP availability check no longer incorrectly disables Zig compile diagnostics.
12
+ - **Ruby LSP startup budgets relaxed** — reduced false-negative LSP attach failures on slower machines.
13
+ - **Kotlin and Zig LSP availability improved** — more reliable server detection across platforms.
14
+ - **Standalone Python and Ruby LSP roots fixed** — correct workspace root used when opening files outside a project directory.
15
+
5
16
  ## [3.8.27] - 2026-04-19
6
17
 
7
18
  ### Added
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import * as fs from "node:fs";
12
+ import * as os from "node:os";
12
13
  import * as path from "node:path";
13
14
  import { isFileKind } from "./file-kinds.js";
14
15
  import { safeSpawn, safeSpawnAsync } from "./safe-spawn.js";
@@ -60,16 +61,29 @@ export class BiomeClient {
60
61
  if (this.localBinaryPath) return { cmd: this.localBinaryPath, args: [] };
61
62
 
62
63
  // Walk up from cwd looking for node_modules/.bin/biome.
64
+ // Also check ~/.pi-lens/tools (where ensureTool("biome") auto-installs),
65
+ // so we avoid the ~1.5s `npx @biomejs/biome --version` fallback when
66
+ // the tool is already installed but not in the project's node_modules.
63
67
  // On Windows prefer .cmd (native batch) over the sh wrapper — 2x faster.
64
68
  const isWin = process.platform === "win32";
69
+ const piLensBin = path.join(
70
+ os.homedir(),
71
+ ".pi-lens",
72
+ "tools",
73
+ "node_modules",
74
+ ".bin",
75
+ );
65
76
  const candidates = isWin
66
77
  ? [
67
78
  path.join(process.cwd(), "node_modules", ".bin", "biome.cmd"),
68
79
  path.join(process.cwd(), "node_modules", ".bin", "biome"),
80
+ path.join(piLensBin, "biome.cmd"),
81
+ path.join(piLensBin, "biome"),
69
82
  ]
70
83
  : [
71
84
  path.join(process.cwd(), "node_modules", ".bin", "biome"),
72
85
  path.join(process.cwd(), "node_modules", ".bin", "biome.cmd"),
86
+ path.join(piLensBin, "biome"),
73
87
  ];
74
88
  for (const p of candidates) {
75
89
  if (fs.existsSync(p)) {
@@ -524,6 +524,24 @@ const TOOLS: ToolDefinition[] = [
524
524
  binaryInArchive: "terraform-ls",
525
525
  },
526
526
  },
527
+ {
528
+ id: "zls",
529
+ name: "zls",
530
+ checkCommand: "zls",
531
+ checkArgs: ["--version"],
532
+ installStrategy: "github",
533
+ binaryName: "zls",
534
+ github: {
535
+ repo: "zigtools/zls",
536
+ assetMatch: (platform, arch) => {
537
+ if (platform === "linux") return arch === "arm64" ? "aarch64-linux.tar.xz" : "x86_64-linux.tar.xz";
538
+ if (platform === "darwin") return arch === "arm64" ? "aarch64-macos.tar.xz" : "x86_64-macos.tar.xz";
539
+ if (platform === "win32") return arch === "arm64" ? "aarch64-windows.zip" : "x86_64-windows.zip";
540
+ return undefined;
541
+ },
542
+ binaryInArchive: "zls",
543
+ },
544
+ },
527
545
  ];
528
546
 
529
547
  const ensureInFlight = new Map<string, Promise<string | undefined>>();
@@ -154,7 +154,7 @@ const PRIMARY_DISPATCH_GROUPS: Partial<Record<FileKind, RunnerGroup>> = {
154
154
  swift: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["swift"] },
155
155
  dart: { mode: "fallback", runnerIds: ["lsp", "dart-analyze"], filterKinds: ["dart"] },
156
156
  lua: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["lua"] },
157
- zig: { mode: "fallback", runnerIds: ["lsp", "zig-check"], filterKinds: ["zig"] },
157
+ zig: { mode: "all", runnerIds: ["lsp", "zig-check"], filterKinds: ["zig"] },
158
158
  haskell: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["haskell"] },
159
159
  elixir: { mode: "fallback", runnerIds: ["lsp", "elixir-check", "credo"], filterKinds: ["elixir"] },
160
160
  gleam: { mode: "fallback", runnerIds: ["lsp", "gleam-check"], filterKinds: ["gleam"] },
@@ -561,10 +561,17 @@ export async function createLSPClient(options: {
561
561
  process: LSPProcess;
562
562
  root: string;
563
563
  initialization?: Record<string, unknown>;
564
+ initializeTimeoutMs?: number;
564
565
  }): Promise<LSPClientInfo> {
565
566
  installCrashGuard();
566
567
 
567
- const { serverId, process: lspProcess, root, initialization } = options;
568
+ const {
569
+ serverId,
570
+ process: lspProcess,
571
+ root,
572
+ initialization,
573
+ initializeTimeoutMs = INITIALIZE_TIMEOUT_MS,
574
+ } = options;
568
575
 
569
576
  const startupState: {
570
577
  exitCode: number | null;
@@ -683,7 +690,7 @@ export async function createLSPClient(options: {
683
690
  },
684
691
  initializationOptions: initialization,
685
692
  }),
686
- INITIALIZE_TIMEOUT_MS,
693
+ initializeTimeoutMs,
687
694
  );
688
695
  } finally {
689
696
  (lspProcess.stderr as NodeJS.ReadableStream).off("data", onStartupStderr);
@@ -133,8 +133,14 @@ export class LSPService {
133
133
  filePath: string,
134
134
  maxWaitMs?: number,
135
135
  ): Promise<SpawnedServer | undefined> {
136
- const withBudget = async (): Promise<SpawnedServer | undefined> => {
137
136
  const servers = getServersForFileWithConfig(filePath);
137
+ const serverWaitOverrideMs = servers.reduce(
138
+ (max, server) => Math.max(max, server.clientWaitTimeoutMs ?? 0),
139
+ 0,
140
+ );
141
+ const effectiveMaxWaitMs = Math.max(maxWaitMs ?? 0, serverWaitOverrideMs);
142
+
143
+ const withBudget = async (): Promise<SpawnedServer | undefined> => {
138
144
  if (servers.length === 0) return undefined;
139
145
 
140
146
  // Try each matching server
@@ -169,14 +175,14 @@ export class LSPService {
169
175
  return undefined;
170
176
  };
171
177
 
172
- if (!maxWaitMs || maxWaitMs <= 0) {
178
+ if (!effectiveMaxWaitMs || effectiveMaxWaitMs <= 0) {
173
179
  return withBudget();
174
180
  }
175
181
 
176
182
  const timeoutResult = await Promise.race<SpawnedServer | undefined>([
177
183
  withBudget(),
178
184
  new Promise<undefined>((resolve) =>
179
- setTimeout(() => resolve(undefined), maxWaitMs),
185
+ setTimeout(() => resolve(undefined), effectiveMaxWaitMs),
180
186
  ),
181
187
  ]);
182
188
 
@@ -185,9 +191,9 @@ export class LSPService {
185
191
  type: "phase",
186
192
  phase: "lsp_client_wait_timeout",
187
193
  filePath,
188
- durationMs: maxWaitMs,
194
+ durationMs: effectiveMaxWaitMs,
189
195
  metadata: {
190
- maxWaitMs,
196
+ maxWaitMs: effectiveMaxWaitMs,
191
197
  },
192
198
  });
193
199
  }
@@ -315,6 +321,7 @@ export class LSPService {
315
321
  process: spawned.process,
316
322
  root,
317
323
  initialization: spawned.initialization,
324
+ initializeTimeoutMs: server.initializeTimeoutMs,
318
325
  });
319
326
  const wsDiag =
320
327
  typeof client.getWorkspaceDiagnosticsSupport === "function"
@@ -32,6 +32,17 @@ export interface LSPServerInfo {
32
32
  name: string;
33
33
  extensions: string[];
34
34
  root: RootFunction;
35
+ /**
36
+ * Optional per-server initialize timeout.
37
+ * Useful for servers like Ruby LSP that do real project bootstrap work
38
+ * before they can answer initialize.
39
+ */
40
+ initializeTimeoutMs?: number;
41
+ /**
42
+ * Optional per-server wait budget for navigation requests that need a client
43
+ * to become ready first.
44
+ */
45
+ clientWaitTimeoutMs?: number;
35
46
  spawn(
36
47
  root: string,
37
48
  options?: LSPSpawnOptions,
@@ -285,15 +296,28 @@ function nodeBinCandidates(root: string, baseName: string): string[] {
285
296
  if (process.platform === "win32") {
286
297
  return [
287
298
  `${localBase}.cmd`,
288
- `${localBase}.ps1`,
289
299
  `${localBase}.exe`,
290
- localBase,
291
300
  baseName,
292
301
  ];
293
302
  }
294
303
  return [localBase, baseName];
295
304
  }
296
305
 
306
+ function normalizeRootKey(root: string): string {
307
+ return process.platform === "win32"
308
+ ? path.resolve(root).toLowerCase()
309
+ : path.resolve(root);
310
+ }
311
+
312
+ function IgnoreHomeRoot(primary: RootFunction): RootFunction {
313
+ const homeKey = normalizeRootKey(os.homedir());
314
+ return async (file: string): Promise<string | undefined> => {
315
+ const root = await primary(file);
316
+ if (!root) return undefined;
317
+ return normalizeRootKey(root) === homeKey ? undefined : root;
318
+ };
319
+ }
320
+
297
321
  function rubyBinCandidates(baseName: string): string[] {
298
322
  const candidates: string[] = [];
299
323
  const userProfile = process.env.USERPROFILE;
@@ -729,15 +753,17 @@ export const PythonServer: LSPServerInfo = {
729
753
  id: "python",
730
754
  name: "Pyright Language Server",
731
755
  extensions: [".py", ".pyi"],
732
- root: createRootDetector([
733
- ".git",
734
- "pyproject.toml",
735
- "setup.py",
736
- "setup.cfg",
737
- "requirements.txt",
738
- "Pipfile",
739
- "poetry.lock",
740
- ]),
756
+ root: RootWithFallback(
757
+ createRootDetector([
758
+ ".git",
759
+ "pyproject.toml",
760
+ "setup.py",
761
+ "setup.cfg",
762
+ "requirements.txt",
763
+ "Pipfile",
764
+ "poetry.lock",
765
+ ]),
766
+ ),
741
767
  async spawn(root, options) {
742
768
  const path = await import("node:path");
743
769
  const fs = await import("node:fs/promises");
@@ -838,15 +864,17 @@ export const PythonPylspServer: LSPServerInfo = {
838
864
  id: "python-pylsp",
839
865
  name: "Python LSP Server (pylsp)",
840
866
  extensions: [".py", ".pyi"],
841
- root: createRootDetector([
842
- ".git",
843
- "pyproject.toml",
844
- "setup.py",
845
- "setup.cfg",
846
- "requirements.txt",
847
- "Pipfile",
848
- "poetry.lock",
849
- ]),
867
+ root: RootWithFallback(
868
+ createRootDetector([
869
+ ".git",
870
+ "pyproject.toml",
871
+ "setup.py",
872
+ "setup.cfg",
873
+ "requirements.txt",
874
+ "Pipfile",
875
+ "poetry.lock",
876
+ ]),
877
+ ),
850
878
  async spawn(root) {
851
879
  try {
852
880
  const proc = await launchLSP("pylsp", [], { cwd: root });
@@ -909,7 +937,11 @@ export const RubyServer: LSPServerInfo = {
909
937
  id: "ruby",
910
938
  name: "Ruby LSP",
911
939
  extensions: [".rb", ".rake", ".gemspec", ".ru"],
912
- root: PriorityRoot([["Gemfile", ".ruby-version"], [".git"]]),
940
+ root: RootWithFallback(PriorityRoot([["Gemfile", ".ruby-version"], [".git"]])),
941
+ // Ruby LSP may need extra time to finish composed-bundle setup before it can
942
+ // answer initialize/documentSymbol on cold start.
943
+ initializeTimeoutMs: 30_000,
944
+ clientWaitTimeoutMs: 30_000,
913
945
  async spawn(root, options) {
914
946
  // Try ruby-lsp first, then solargraph, then rubocop --lsp
915
947
  // Each has different args so we can't use a single resolveAndLaunch call
@@ -947,7 +979,7 @@ export const RubySolargraphServer: LSPServerInfo = {
947
979
  id: "ruby-solargraph",
948
980
  name: "Solargraph",
949
981
  extensions: [".rb", ".rake", ".gemspec", ".ru"],
950
- root: PriorityRoot([["Gemfile", ".ruby-version"], [".git"]]),
982
+ root: RootWithFallback(PriorityRoot([["Gemfile", ".ruby-version"], [".git"]])),
951
983
  async spawn(root) {
952
984
  for (const command of ["solargraph", ...rubyBinCandidates("solargraph")]) {
953
985
  try {
@@ -1028,14 +1060,24 @@ export const JavaServer = createInteractiveServer({
1028
1060
  command: () => process.env.JDTLS_PATH || "jdtls",
1029
1061
  });
1030
1062
 
1031
- export const KotlinServer = createInteractiveServer({
1063
+ export const KotlinServer: LSPServerInfo = {
1032
1064
  id: "kotlin",
1033
1065
  name: "Kotlin Language Server",
1034
1066
  extensions: [".kt", ".kts"],
1035
1067
  root: RootWithFallback(createRootDetector(["build.gradle.kts", "build.gradle", "pom.xml"])),
1036
- language: "kotlin",
1037
- command: "kotlin-language-server",
1038
- });
1068
+ async spawn(root, options) {
1069
+ // Prefer the newer official Kotlin LSP CLI when available, but keep
1070
+ // compatibility with the older fwcd kotlin-language-server command.
1071
+ return resolveAndLaunch(
1072
+ {
1073
+ candidates: ["kotlin-lsp", "kotlin-language-server"],
1074
+ args: [],
1075
+ cwd: root,
1076
+ },
1077
+ options?.allowInstall,
1078
+ );
1079
+ },
1080
+ };
1039
1081
 
1040
1082
  export const SwiftServer = createInteractiveServer({
1041
1083
  id: "swift",
@@ -1080,14 +1122,23 @@ export const CppServer = createInteractiveServer({
1080
1122
  args: ["--background-index"],
1081
1123
  });
1082
1124
 
1083
- export const ZigServer = createInteractiveServer({
1125
+ export const ZigServer: LSPServerInfo = {
1084
1126
  id: "zig",
1085
1127
  name: "ZLS",
1086
1128
  extensions: [".zig", ".zon"],
1087
1129
  root: RootWithFallback(createRootDetector(["build.zig"])),
1088
- language: "zig",
1089
- command: "zls",
1090
- });
1130
+ spawn(root, options) {
1131
+ return resolveAndLaunch(
1132
+ {
1133
+ candidates: ["zls"],
1134
+ args: [],
1135
+ cwd: root,
1136
+ managedToolId: "zls",
1137
+ },
1138
+ options?.allowInstall,
1139
+ );
1140
+ },
1141
+ };
1091
1142
 
1092
1143
  export const HaskellServer = createInteractiveServer({
1093
1144
  id: "haskell",
@@ -1230,7 +1281,9 @@ export const HtmlServer: LSPServerInfo = {
1230
1281
  id: "html",
1231
1282
  name: "VSCode HTML Language Server",
1232
1283
  extensions: [".html", ".htm"],
1233
- root: PriorityRoot([["package.json", "index.html", "vite.config.ts"], [".git"]]),
1284
+ root: RootWithFallback(
1285
+ IgnoreHomeRoot(PriorityRoot([["package.json", "index.html", "vite.config.ts"]])),
1286
+ ),
1234
1287
  spawn(root, options) {
1235
1288
  return resolveAndLaunch(
1236
1289
  { candidates: nodeBinCandidates(root, "vscode-html-language-server"), args: ["--stdio"], cwd: root, managedToolId: "vscode-html-languageserver-bin" },
@@ -1334,7 +1387,11 @@ export const CssServer: LSPServerInfo = {
1334
1387
  id: "css",
1335
1388
  name: "CSS Language Server",
1336
1389
  extensions: [".css", ".scss", ".sass", ".less"],
1337
- root: PriorityRoot([["package.json", "postcss.config.js", "tailwind.config.js", "vite.config.ts"], [".git"]]),
1390
+ root: RootWithFallback(
1391
+ IgnoreHomeRoot(
1392
+ PriorityRoot([["package.json", "postcss.config.js", "tailwind.config.js", "vite.config.ts"]]),
1393
+ ),
1394
+ ),
1338
1395
  spawn(root, options) {
1339
1396
  return resolveAndLaunch(
1340
1397
  { candidates: nodeBinCandidates(root, "vscode-css-language-server"), args: ["--stdio"], cwd: root, managedToolId: "vscode-css-languageserver" },
@@ -1353,7 +1410,6 @@ export const LSP_SERVERS: LSPServerInfo[] = [
1353
1410
  GoServer,
1354
1411
  RustServer,
1355
1412
  RubyServer,
1356
- RubySolargraphServer,
1357
1413
  PHPServer,
1358
1414
  // PowerShellServer — not included; no viable LSP binary, coverage notice fires instead
1359
1415
  CSharpServer,
@@ -376,7 +376,9 @@ function runErrorDebtBaseline(
376
376
  SessionStartDeps,
377
377
  "testRunnerClient" | "cacheManager" | "notify" | "dbg" | "runtime"
378
378
  >,
379
- detectedRunner: ReturnType<SessionStartDeps["testRunnerClient"]["detectRunner"]>,
379
+ detectedRunner: ReturnType<
380
+ SessionStartDeps["testRunnerClient"]["detectRunner"]
381
+ >,
380
382
  analysisRoot: string,
381
383
  allowBootstrapTasks: boolean,
382
384
  getFlag: SessionStartDeps["getFlag"],
@@ -499,20 +501,38 @@ export async function handleSessionStart(
499
501
  const tools: string[] = [];
500
502
  if (getFlag("lens-lsp") && !getFlag("no-lsp")) tools.push("LSP Service");
501
503
 
502
- // Warm npm-based tool availability caches after startup returns. These sync
503
- // subprocess calls (biome --version, npx knip --version, etc.) take ~500ms-2s
504
- // each and were blocking the critical startup path with no functional benefit —
505
- // each runner re-checks availability lazily when it first runs.
506
- setImmediate(() => {
507
- const b = biomeClient.isAvailable();
508
- const a = astGrepClient.isAvailable();
509
- const r = ruffClient.isAvailable();
510
- knipClient.isAvailable();
511
- depChecker.isAvailable();
512
- jscpdClient.isAvailable();
513
- typeCoverageClient.isAvailable();
514
- dbg(`session_start tools (deferred probes complete): biome=${b} ast-grep=${a} ruff=${r}`);
515
- });
504
+ // Warm tool availability caches off the critical startup path. The previous
505
+ // version used `setImmediate` + sync `isAvailable()`, which still blocked
506
+ // the Node event loop (each `isAvailable()` runs `spawnSync` and six of
507
+ // the seven probes fall back to `npx <tool> --version` at ~1.5-2s each,
508
+ // summing to ~8-10s of main-thread freeze during session_start).
509
+ //
510
+ // We now run each probe through the client's async `ensureAvailable()`
511
+ // (which uses a fast bare-name PATH probe, falling back to `ensureTool`
512
+ // async install) inside a fire-and-forget IIFE. No main-thread blocking.
513
+ //
514
+ // Notes:
515
+ // - `typeCoverageClient` has no async probe and is only used by
516
+ // `/lens-booboo`, so we let it probe lazily when first needed.
517
+ // - `ensureAvailable()` can auto-install missing tools into `~/.pi-lens/tools`.
518
+ // This matches `firePreinstallDefaults`' existing behaviour for biome /
519
+ // typescript-language-server.
520
+ void (async () => {
521
+ const warmStart = Date.now();
522
+ const [biomeReady, sgReady, ruffReady] = await Promise.all([
523
+ biomeClient.ensureAvailable().catch(() => false),
524
+ astGrepClient.ensureAvailable().catch(() => false),
525
+ ruffClient.ensureAvailable().catch(() => false),
526
+ ]);
527
+ await Promise.allSettled([
528
+ knipClient.ensureAvailable().catch(() => false),
529
+ depChecker.ensureAvailable().catch(() => false),
530
+ jscpdClient.ensureAvailable().catch(() => false),
531
+ ]);
532
+ dbg(
533
+ `session_start tools (deferred probes complete, ${Date.now() - warmStart}ms): biome=${biomeReady} ast-grep=${sgReady} ruff=${ruffReady}`,
534
+ );
535
+ })();
516
536
 
517
537
  if (allowBootstrapTasks && getFlag("lens-lsp") && !getFlag("no-lsp")) {
518
538
  const cleaned = cleanStaleTsBuildInfo(ctxCwd ?? process.cwd());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "3.8.27",
3
+ "version": "3.8.28",
4
4
  "type": "module",
5
5
  "description": "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo",
6
6
  "repository": {