pi-lens 3.8.24 → 3.8.26

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
@@ -4,6 +4,20 @@ All notable changes to pi-lens will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.8.26] - 2026-04-15
8
+
9
+ ### Fixed
10
+ - **Silent crash on unhandled promise rejection** — the LSP crash guard's `unhandledRejection` handler was swallowing all non-ignorable rejections without rethrowing, causing silent process exits. The handler now rethrows so non-ignorable rejections surface as `uncaughtException` and are properly reported. Triggered most visibly when editing JSON files while Biome or another LSP server was active.
11
+
12
+ ## [3.8.25] - 2026-04-13
13
+
14
+ ### Changed
15
+ - **Go LSP PATH augmentation on Windows** — LSP subprocess PATH now includes common Go install directories (`C:\Program Files\Go\bin`, `C:\Go\bin`) to prevent `gopls` startup/runtime failures when `go` is not in inherited shell PATH.
16
+ - **Similarity runner cold-start behavior** — similarity now skips fast when no cached project index exists and for tiny/trivial files, reducing write/edit pipeline tail latency and eliminating frequent 30s timeout noise in scratch-file workflows.
17
+
18
+ ### Fixed
19
+ - **Non-git workspace commit lookup noise** — metrics snapshot commit detection now pre-checks repository context before invoking Git, preventing `fatal: not a git repository` terminal noise in non-repo folders.
20
+
7
21
  ## [3.8.24] - 2026-04-12
8
22
 
9
23
  ### Changed
@@ -47,7 +47,11 @@ const architectRunner: RunnerDefinition = {
47
47
 
48
48
  async run(ctx: DispatchContext): Promise<RunnerResult> {
49
49
  const relPath = path.relative(ctx.cwd, ctx.filePath).replace(/\\/g, "/");
50
- const content = readFileContent(ctx.filePath);
50
+ const contentFromFacts = ctx.facts.getFileFact<string | null>(
51
+ ctx.filePath,
52
+ "file.content",
53
+ );
54
+ const content = contentFromFacts ?? readFileContent(ctx.filePath);
51
55
 
52
56
  if (!content) {
53
57
  return { status: "skipped", diagnostics: [], semantic: "none" };
@@ -423,10 +423,18 @@ const astGrepNapiRunner: RunnerDefinition = {
423
423
  }
424
424
 
425
425
  let content: string;
426
- try {
427
- content = fs.readFileSync(ctx.filePath, "utf-8");
428
- } catch {
429
- return { status: "skipped", diagnostics: [], semantic: "none" };
426
+ const contentFromFacts = ctx.facts.getFileFact<string | null>(
427
+ ctx.filePath,
428
+ "file.content",
429
+ );
430
+ if (contentFromFacts !== undefined && contentFromFacts !== null) {
431
+ content = contentFromFacts;
432
+ } else {
433
+ try {
434
+ content = fs.readFileSync(ctx.filePath, "utf-8");
435
+ } catch {
436
+ return { status: "skipped", diagnostics: [], semantic: "none" };
437
+ }
430
438
  }
431
439
 
432
440
  let root: import("@ast-grep/napi").SgRoot;
@@ -9,8 +9,8 @@ import * as nodeFs from "node:fs";
9
9
  import * as fs from "node:fs/promises";
10
10
  import * as path from "node:path";
11
11
  import * as ts from "typescript";
12
- import { EXCLUDED_DIRS } from "../../file-utils.js";
13
12
  import { NativeRustCoreClient } from "../../native-rust-client.js";
13
+ import { collectSourceFiles } from "../../source-filter.js";
14
14
  import {
15
15
  buildProjectIndex,
16
16
  findSimilarFunctions,
@@ -40,6 +40,7 @@ const CONFIG = {
40
40
  SIMILARITY_THRESHOLD: 0.96, // align with booboo: stricter to reduce boilerplate false positives
41
41
  MIN_TRANSITIONS: 40, // stronger signal floor for structural comparisons
42
42
  MIN_FUNCTION_LINES: 8, // Ignore tiny helpers/wrappers
43
+ MIN_FILE_CHARS: 140, // Skip tiny/trivial files early
43
44
  MAX_TRANSITION_RATIO: 1.8, // Skip pairs with highly mismatched complexity/size
44
45
  MAX_SUGGESTIONS: 3, // Max 3 suggestions per file
45
46
  MAX_PER_TARGET_NAME: 1, // Avoid one-to-many spam for the same target utility
@@ -115,12 +116,26 @@ const similarityRunner: RunnerDefinition = {
115
116
  return { status: "skipped", diagnostics: [], semantic: "none" };
116
117
  }
117
118
 
119
+ const lineCount = content.split(/\r?\n/).length;
120
+ if (
121
+ content.trim().length < CONFIG.MIN_FILE_CHARS ||
122
+ lineCount < CONFIG.MIN_FUNCTION_LINES + 2 ||
123
+ !/(\bfunction\b|=>)/.test(content)
124
+ ) {
125
+ return { status: "skipped", diagnostics: [], semantic: "none" };
126
+ }
127
+
118
128
  // Find project root and load index
119
129
  const projectRoot = await findProjectRoot(filePath);
120
130
  if (!projectRoot) {
121
131
  return { status: "skipped", diagnostics: [], semantic: "none" };
122
132
  }
123
133
 
134
+ const cachedIndex = await loadCachedIndex(projectRoot);
135
+ if (!cachedIndex || cachedIndex.entries.size === 0) {
136
+ return { status: "skipped", diagnostics: [], semantic: "none" };
137
+ }
138
+
124
139
  // ── Rust fast-path ─────────────────────────────────────────────────────
125
140
  // Try Rust for file scanning + similarity detection. If the Rust binary
126
141
  // is available, use it. On any failure, fall through to the pure-TS path.
@@ -139,10 +154,7 @@ const similarityRunner: RunnerDefinition = {
139
154
  }
140
155
  // ── TypeScript fallback ─────────────────────────────────────────────────
141
156
 
142
- const index = await loadOrBuildIndex(projectRoot);
143
- if (!index || index.entries.size === 0) {
144
- return { status: "skipped", diagnostics: [], semantic: "none" };
145
- }
157
+ const index = cachedIndex;
146
158
 
147
159
  // Parse the file
148
160
  const sourceFile = ts.createSourceFile(
@@ -512,30 +524,42 @@ async function loadOrBuildIndex(
512
524
  }
513
525
 
514
526
  // Build new index
515
- const { glob } = await import("glob");
516
- // Build ignore patterns from centralized EXCLUDED_DIRS
517
- const ignorePatterns = [
518
- ...EXCLUDED_DIRS.map((d) => `**/${d}/**`),
519
- "**/*.test.ts",
520
- "**/*.spec.ts",
521
- "**/*.poc.test.ts",
522
- ];
523
- const files = await glob("**/*.ts", {
524
- cwd: projectRoot,
525
- ignore: ignorePatterns,
527
+ const absoluteFiles = collectSourceFiles(projectRoot, {
528
+ extensions: [".ts"],
529
+ }).filter((filePath) => {
530
+ const normalized = filePath.replace(/\\/g, "/");
531
+ return (
532
+ !normalized.endsWith(".test.ts") &&
533
+ !normalized.endsWith(".spec.ts") &&
534
+ !normalized.endsWith(".poc.test.ts")
535
+ );
526
536
  });
527
537
 
528
- if (files.length === 0) {
538
+ if (absoluteFiles.length === 0) {
529
539
  return null;
530
540
  }
531
541
 
532
- const absoluteFiles = files.map((f) => path.join(projectRoot, f));
533
542
  const index = await buildProjectIndex(projectRoot, absoluteFiles);
534
543
 
535
544
  indexCache.set(projectRoot, index);
536
545
  return index;
537
546
  }
538
547
 
548
+ async function loadCachedIndex(projectRoot: string): Promise<ProjectIndex | null> {
549
+ const cached = indexCache.get(projectRoot);
550
+ if (cached) {
551
+ return cached;
552
+ }
553
+
554
+ const existing = await loadIndex(projectRoot);
555
+ if (!existing) {
556
+ return null;
557
+ }
558
+
559
+ indexCache.set(projectRoot, existing);
560
+ return existing;
561
+ }
562
+
539
563
  // ============================================================================
540
564
  // Testing Helper
541
565
  // ============================================================================
@@ -519,13 +519,27 @@ const treeSitterRunner: RunnerDefinition = {
519
519
  });
520
520
 
521
521
  const diagnostics: Diagnostic[] = [];
522
+ const contentFromFacts = ctx.facts.getFileFact<string | null>(
523
+ filePath,
524
+ "file.content",
525
+ );
526
+ const contentOverride =
527
+ contentFromFacts !== undefined && contentFromFacts !== null
528
+ ? contentFromFacts
529
+ : undefined;
522
530
 
523
531
  // Run each query against the file
524
532
  for (const query of effectiveQueries) {
525
533
  try {
526
- const matches = await client.runQueryOnFile(query, filePath, languageId, {
527
- maxResults: 10,
528
- });
534
+ const matches = await client.runQueryOnFile(
535
+ query,
536
+ filePath,
537
+ languageId,
538
+ {
539
+ maxResults: 10,
540
+ },
541
+ contentOverride,
542
+ );
529
543
 
530
544
  for (const match of matches) {
531
545
  // Get line/column from match (already 0-indexed from tree-sitter)
@@ -271,6 +271,7 @@ function installCrashGuard(): void {
271
271
  if (isIgnorableLspRuntimeCrash(reason)) {
272
272
  return;
273
273
  }
274
+ throw reason instanceof Error ? reason : new Error(String(reason));
274
275
  });
275
276
  }
276
277
 
@@ -31,6 +31,20 @@ export interface LSPState {
31
31
  }
32
32
 
33
33
  const BROKEN_RETRY_COOLDOWN_MS = 15_000;
34
+ const OPTIONAL_LSP_RETRY_COOLDOWN_MS = 5 * 60_000;
35
+ const OPTIONAL_LSP_SERVER_IDS = new Set(["biome-lsp"]);
36
+ const NAV_CLIENT_WAIT_TIMEOUT_MS = Math.max(
37
+ 0,
38
+ Number.parseInt(
39
+ process.env.PI_LENS_LSP_NAV_CLIENT_WAIT_MS ?? "700",
40
+ 10,
41
+ ) || 700,
42
+ );
43
+ const TOUCH_DEBOUNCE_MS = Math.max(
44
+ 0,
45
+ Number.parseInt(process.env.PI_LENS_LSP_TOUCH_DEBOUNCE_MS ?? "1500", 10) ||
46
+ 1500,
47
+ );
34
48
  const SESSIONSTART_LOG_DIR = path.join(os.homedir(), ".pi-lens");
35
49
  const SESSIONSTART_LOG = path.join(SESSIONSTART_LOG_DIR, "sessionstart.log");
36
50
 
@@ -57,6 +71,12 @@ export class LSPService {
57
71
  private workspaceProbeLogged = new Set<string>();
58
72
  private warmStartLogged = new Set<string>();
59
73
  private emitConsoleLspErrors = process.env.PI_LENS_CONSOLE_LSP === "1";
74
+ private optionalFailureLogged = new Set<string>();
75
+ private optionalDisabled = new Set<string>();
76
+ private recentTouches = new Map<
77
+ string,
78
+ { fingerprint: string; touchedAt: number; clientScope: "primary" | "all" }
79
+ >();
60
80
 
61
81
  constructor() {
62
82
  this.state = {
@@ -67,11 +87,57 @@ export class LSPService {
67
87
  };
68
88
  }
69
89
 
90
+ private fingerprintContent(content: string): string {
91
+ if (content.length <= 96) {
92
+ return `${content.length}:${content}`;
93
+ }
94
+ return `${content.length}:${content.slice(0, 48)}:${content.slice(-48)}`;
95
+ }
96
+
97
+ private shouldSkipTouch(
98
+ filePath: string,
99
+ content: string,
100
+ clientScope: "primary" | "all",
101
+ waitForDiagnostics: boolean,
102
+ ): boolean {
103
+ if (waitForDiagnostics || TOUCH_DEBOUNCE_MS <= 0) {
104
+ return false;
105
+ }
106
+
107
+ const key = `${normalizeMapKey(filePath)}:${clientScope}`;
108
+ const previous = this.recentTouches.get(key);
109
+ if (!previous) return false;
110
+
111
+ const now = Date.now();
112
+ if (now - previous.touchedAt > TOUCH_DEBOUNCE_MS) {
113
+ return false;
114
+ }
115
+
116
+ return previous.fingerprint === this.fingerprintContent(content);
117
+ }
118
+
119
+ private markTouched(
120
+ filePath: string,
121
+ content: string,
122
+ clientScope: "primary" | "all",
123
+ ): void {
124
+ const key = `${normalizeMapKey(filePath)}:${clientScope}`;
125
+ this.recentTouches.set(key, {
126
+ fingerprint: this.fingerprintContent(content),
127
+ touchedAt: Date.now(),
128
+ clientScope,
129
+ });
130
+ }
131
+
70
132
  /**
71
133
  * Get or create LSP client for a file
72
134
  * Prevents duplicate client creation via in-flight promise tracking
73
135
  */
74
- async getClientForFile(filePath: string): Promise<SpawnedServer | undefined> {
136
+ async getClientForFile(
137
+ filePath: string,
138
+ maxWaitMs?: number,
139
+ ): Promise<SpawnedServer | undefined> {
140
+ const withBudget = async (): Promise<SpawnedServer | undefined> => {
75
141
  const servers = getServersForFileWithConfig(filePath);
76
142
  if (servers.length === 0) return undefined;
77
143
 
@@ -105,6 +171,32 @@ export class LSPService {
105
171
  });
106
172
 
107
173
  return undefined;
174
+ };
175
+
176
+ if (!maxWaitMs || maxWaitMs <= 0) {
177
+ return withBudget();
178
+ }
179
+
180
+ const timeoutResult = await Promise.race<SpawnedServer | undefined>([
181
+ withBudget(),
182
+ new Promise<undefined>((resolve) =>
183
+ setTimeout(() => resolve(undefined), maxWaitMs),
184
+ ),
185
+ ]);
186
+
187
+ if (!timeoutResult) {
188
+ logLatency({
189
+ type: "phase",
190
+ phase: "lsp_client_wait_timeout",
191
+ filePath,
192
+ durationMs: maxWaitMs,
193
+ metadata: {
194
+ maxWaitMs,
195
+ },
196
+ });
197
+ }
198
+
199
+ return timeoutResult;
108
200
  }
109
201
 
110
202
  /**
@@ -131,6 +223,11 @@ export class LSPService {
131
223
 
132
224
  const normalizedRoot = normalizeMapKey(root);
133
225
  const key = `${server.id}:${normalizedRoot}`;
226
+ const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id);
227
+
228
+ if (isOptionalServer && this.optionalDisabled.has(key)) {
229
+ return undefined;
230
+ }
134
231
 
135
232
  const existing = this.state.clients.get(key);
136
233
  if (existing) {
@@ -228,6 +325,7 @@ export class LSPService {
228
325
  filePath: string,
229
326
  allowInstall: boolean,
230
327
  ): Promise<SpawnedServer | undefined> {
328
+ const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id);
231
329
  const startedAt = Date.now();
232
330
  logSessionStart(
233
331
  `lsp spawn ${server.id}: start root=${root} policy=${server.installPolicy ?? "unknown"} install=${allowInstall ? "enabled" : "disabled"} file=${filePath}`,
@@ -258,6 +356,10 @@ export class LSPService {
258
356
  };
259
357
 
260
358
  this.state.clients.set(key, client);
359
+ if (isOptionalServer) {
360
+ this.optionalDisabled.delete(key);
361
+ this.optionalFailureLogged.delete(key);
362
+ }
261
363
  logSessionStart(
262
364
  `lsp spawn ${server.id}: success source=${spawned.source ?? server.installPolicy ?? "unknown"} (${Date.now() - startedAt}ms)`,
263
365
  );
@@ -269,11 +371,16 @@ export class LSPService {
269
371
  }
270
372
  return { client, info: server };
271
373
  } catch (err) {
272
- logSessionStart(
273
- `lsp spawn ${server.id}: failed (${Date.now() - startedAt}ms) error=${err instanceof Error ? err.message : String(err)}`,
274
- );
374
+ if (!isOptionalServer || !this.optionalFailureLogged.has(key)) {
375
+ logSessionStart(
376
+ `lsp spawn ${server.id}: failed (${Date.now() - startedAt}ms) error=${err instanceof Error ? err.message : String(err)}`,
377
+ );
378
+ if (isOptionalServer) {
379
+ this.optionalFailureLogged.add(key);
380
+ }
381
+ }
275
382
  const errorMsg = err instanceof Error ? err.message : String(err);
276
- if (this.emitConsoleLspErrors) {
383
+ if (this.emitConsoleLspErrors && !isOptionalServer) {
277
384
  if (errorMsg.includes("Timeout")) {
278
385
  console.error(
279
386
  `[lsp] ${server.id} timed out during initialization (${errorMsg}). The server may be downloading or the project is large. Skipping.`,
@@ -290,7 +397,13 @@ export class LSPService {
290
397
  console.error(`[lsp] Failed to spawn ${server.id}:`, err);
291
398
  }
292
399
  }
293
- this.state.broken.set(key, Date.now() + BROKEN_RETRY_COOLDOWN_MS);
400
+ this.state.broken.set(
401
+ key,
402
+ Date.now() + (isOptionalServer ? OPTIONAL_LSP_RETRY_COOLDOWN_MS : BROKEN_RETRY_COOLDOWN_MS),
403
+ );
404
+ if (isOptionalServer) {
405
+ this.optionalDisabled.add(key);
406
+ }
294
407
  return undefined;
295
408
  }
296
409
  }
@@ -326,12 +439,14 @@ export class LSPService {
326
439
  waitForDiagnostics = false,
327
440
  source = "unknown",
328
441
  useAllClients = false,
442
+ maxClientWaitMs?: number,
329
443
  ): Promise<void> {
330
444
  const startedAt = Date.now();
331
445
  const normalizedPath = normalizeMapKey(filePath);
446
+ const clientScope = useAllClients ? "all" : "primary";
332
447
  const spawned = useAllClients
333
448
  ? await this.getClientsForFile(filePath)
334
- : await this.getClientForFile(filePath).then((entry) =>
449
+ : await this.getClientForFile(filePath, maxClientWaitMs).then((entry) =>
335
450
  entry ? [entry] : [],
336
451
  );
337
452
  if (spawned.length === 0) {
@@ -342,13 +457,32 @@ export class LSPService {
342
457
  durationMs: Date.now() - startedAt,
343
458
  metadata: {
344
459
  serverCountReady: 0,
345
- clientScope: useAllClients ? "all" : "primary",
460
+ clientScope,
346
461
  failureKind: "no_clients",
347
462
  },
348
463
  });
349
464
  return;
350
465
  }
351
466
 
467
+ if (this.shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics)) {
468
+ logLatency({
469
+ type: "phase",
470
+ phase: "lsp_touch_file",
471
+ filePath: normalizedPath,
472
+ durationMs: Date.now() - startedAt,
473
+ metadata: {
474
+ serverCountReady: spawned.length,
475
+ clientScope,
476
+ waitForDiagnostics,
477
+ source,
478
+ failureKind: "success",
479
+ skipped: true,
480
+ reason: "debounced_unchanged_content",
481
+ },
482
+ });
483
+ return;
484
+ }
485
+
352
486
  const languageId = getLanguageId(filePath) ?? "plaintext";
353
487
  await Promise.all(
354
488
  spawned.map((entry) => entry.client.notify.open(filePath, content, languageId)),
@@ -362,6 +496,8 @@ export class LSPService {
362
496
  );
363
497
  }
364
498
 
499
+ this.markTouched(filePath, content, clientScope);
500
+
365
501
  logLatency({
366
502
  type: "phase",
367
503
  phase: "lsp_touch_file",
@@ -369,7 +505,7 @@ export class LSPService {
369
505
  durationMs: Date.now() - startedAt,
370
506
  metadata: {
371
507
  serverCountReady: spawned.length,
372
- clientScope: useAllClients ? "all" : "primary",
508
+ clientScope,
373
509
  waitForDiagnostics,
374
510
  source,
375
511
  failureKind: "success",
@@ -471,7 +607,10 @@ export class LSPService {
471
607
  * Navigation: go to definition
472
608
  */
473
609
  async definition(filePath: string, line: number, character: number) {
474
- const spawned = await this.getClientForFile(filePath);
610
+ const spawned = await this.getClientForFile(
611
+ filePath,
612
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
613
+ );
475
614
  if (!spawned) return [];
476
615
  return spawned.client.definition(filePath, line, character);
477
616
  }
@@ -485,7 +624,10 @@ export class LSPService {
485
624
  character: number,
486
625
  includeDeclaration = true,
487
626
  ) {
488
- const spawned = await this.getClientForFile(filePath);
627
+ const spawned = await this.getClientForFile(
628
+ filePath,
629
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
630
+ );
489
631
  if (!spawned) return [];
490
632
  return spawned.client.references(
491
633
  filePath,
@@ -499,7 +641,10 @@ export class LSPService {
499
641
  * Navigation: hover info
500
642
  */
501
643
  async hover(filePath: string, line: number, character: number) {
502
- const spawned = await this.getClientForFile(filePath);
644
+ const spawned = await this.getClientForFile(
645
+ filePath,
646
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
647
+ );
503
648
  if (!spawned) return null;
504
649
  return spawned.client.hover(filePath, line, character);
505
650
  }
@@ -508,7 +653,10 @@ export class LSPService {
508
653
  * Navigation: signature help at cursor position
509
654
  */
510
655
  async signatureHelp(filePath: string, line: number, character: number) {
511
- const spawned = await this.getClientForFile(filePath);
656
+ const spawned = await this.getClientForFile(
657
+ filePath,
658
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
659
+ );
512
660
  if (!spawned) return null;
513
661
  return spawned.client.signatureHelp(filePath, line, character);
514
662
  }
@@ -517,7 +665,10 @@ export class LSPService {
517
665
  * Navigation: symbols in document
518
666
  */
519
667
  async documentSymbol(filePath: string) {
520
- const spawned = await this.getClientForFile(filePath);
668
+ const spawned = await this.getClientForFile(
669
+ filePath,
670
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
671
+ );
521
672
  if (!spawned) return [];
522
673
  return spawned.client.documentSymbol(filePath);
523
674
  }
@@ -527,7 +678,10 @@ export class LSPService {
527
678
  */
528
679
  async workspaceSymbol(query: string, filePath?: string) {
529
680
  if (filePath) {
530
- const spawned = await this.getClientForFile(filePath);
681
+ const spawned = await this.getClientForFile(
682
+ filePath,
683
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
684
+ );
531
685
  if (!spawned) return [];
532
686
  return spawned.client.workspaceSymbol(query);
533
687
  }
@@ -592,7 +746,10 @@ export class LSPService {
592
746
  endLine: number,
593
747
  endCharacter: number,
594
748
  ) {
595
- const spawned = await this.getClientForFile(filePath);
749
+ const spawned = await this.getClientForFile(
750
+ filePath,
751
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
752
+ );
596
753
  if (!spawned) return [];
597
754
  return spawned.client.codeAction(
598
755
  filePath,
@@ -612,7 +769,10 @@ export class LSPService {
612
769
  character: number,
613
770
  newName: string,
614
771
  ) {
615
- const spawned = await this.getClientForFile(filePath);
772
+ const spawned = await this.getClientForFile(
773
+ filePath,
774
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
775
+ );
616
776
  if (!spawned) return null;
617
777
  return spawned.client.rename(filePath, line, character, newName);
618
778
  }
@@ -621,7 +781,10 @@ export class LSPService {
621
781
  * Navigation: go to implementation
622
782
  */
623
783
  async implementation(filePath: string, line: number, character: number) {
624
- const spawned = await this.getClientForFile(filePath);
784
+ const spawned = await this.getClientForFile(
785
+ filePath,
786
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
787
+ );
625
788
  if (!spawned) return [];
626
789
  return spawned.client.implementation(filePath, line, character);
627
790
  }
@@ -634,7 +797,10 @@ export class LSPService {
634
797
  line: number,
635
798
  character: number,
636
799
  ) {
637
- const spawned = await this.getClientForFile(filePath);
800
+ const spawned = await this.getClientForFile(
801
+ filePath,
802
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
803
+ );
638
804
  if (!spawned) return [];
639
805
  return spawned.client.prepareCallHierarchy(filePath, line, character);
640
806
  }
@@ -643,7 +809,10 @@ export class LSPService {
643
809
  * Navigation: find incoming calls (callers)
644
810
  */
645
811
  async incomingCalls(item: import("./client.js").LSPCallHierarchyItem) {
646
- const spawned = await this.getClientForFile(uriToPath(item.uri));
812
+ const spawned = await this.getClientForFile(
813
+ uriToPath(item.uri),
814
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
815
+ );
647
816
  if (!spawned) return [];
648
817
  return spawned.client.incomingCalls(item);
649
818
  }
@@ -652,7 +821,10 @@ export class LSPService {
652
821
  * Navigation: find outgoing calls (callees)
653
822
  */
654
823
  async outgoingCalls(item: import("./client.js").LSPCallHierarchyItem) {
655
- const spawned = await this.getClientForFile(uriToPath(item.uri));
824
+ const spawned = await this.getClientForFile(
825
+ uriToPath(item.uri),
826
+ NAV_CLIENT_WAIT_TIMEOUT_MS,
827
+ );
656
828
  if (!spawned) return [];
657
829
  return spawned.client.outgoingCalls(item);
658
830
  }
@@ -35,6 +35,8 @@ function buildAugmentedPath(basePath?: string): string {
35
35
  candidates.push(path.join(userProfile, ".cargo", "bin"));
36
36
  candidates.push(path.join(userProfile, "go", "bin"));
37
37
  }
38
+ candidates.push(path.join("C:\\", "Program Files", "Go", "bin"));
39
+ candidates.push(path.join("C:\\", "Go", "bin"));
38
40
  candidates.push(path.join("C:\\", "Ruby34-x64", "bin"));
39
41
  candidates.push(path.join("C:\\", "Ruby33-x64", "bin"));
40
42
 
@@ -124,23 +124,35 @@ async function launchWithDirectOrPackageManager(
124
124
  args: string[],
125
125
  options: { cwd: string; env?: NodeJS.ProcessEnv; allowInstall?: boolean },
126
126
  ): Promise<{ process: LSPProcess; source: "direct" | "package-manager" } | undefined> {
127
+ let lastDirectError: unknown;
128
+
127
129
  for (const command of directCommands) {
128
130
  try {
129
131
  const process = await launchLSP(command, args, options);
130
132
  return { process, source: "direct" };
131
133
  } catch (error) {
132
- if (!isCommandNotFoundError(error)) {
133
- throw error;
134
- }
134
+ lastDirectError = error;
135
135
  }
136
136
  }
137
137
 
138
- const process = await launchViaPackageManagerWithPolicy(packageName, args, {
139
- cwd: options.cwd,
140
- allowInstall: options.allowInstall,
141
- });
142
- if (!process) return undefined;
143
- return { process, source: "package-manager" };
138
+ try {
139
+ const process = await launchViaPackageManagerWithPolicy(packageName, args, {
140
+ cwd: options.cwd,
141
+ allowInstall: options.allowInstall,
142
+ });
143
+ if (!process) {
144
+ if (lastDirectError && !isCommandNotFoundError(lastDirectError)) {
145
+ throw lastDirectError;
146
+ }
147
+ return undefined;
148
+ }
149
+ return { process, source: "package-manager" };
150
+ } catch (packageManagerError) {
151
+ if (lastDirectError && !isCommandNotFoundError(lastDirectError)) {
152
+ throw lastDirectError;
153
+ }
154
+ throw packageManagerError;
155
+ }
144
156
  }
145
157
 
146
158
  type InitializationConfig = Record<string, unknown>;
@@ -45,10 +45,30 @@ const MAX_HISTORY_PER_FILE = 20;
45
45
 
46
46
  // --- Git Helpers ---
47
47
 
48
+ /**
49
+ * Check whether cwd is inside a Git worktree.
50
+ */
51
+ function isInsideGitRepo(startDir: string): boolean {
52
+ let dir = path.resolve(startDir);
53
+ while (true) {
54
+ if (fs.existsSync(path.join(dir, ".git"))) {
55
+ return true;
56
+ }
57
+ const parent = path.dirname(dir);
58
+ if (parent === dir) break;
59
+ dir = parent;
60
+ }
61
+ return false;
62
+ }
63
+
48
64
  /**
49
65
  * Get current git commit hash (short)
50
66
  */
51
67
  function getCurrentCommit(): string {
68
+ if (!isInsideGitRepo(process.cwd())) {
69
+ return "unknown";
70
+ }
71
+
52
72
  try {
53
73
  return execSync("git rev-parse --short HEAD", {
54
74
  encoding: "utf-8",
@@ -10,13 +10,13 @@ import { getKnipIgnorePatterns } from "./file-utils.js";
10
10
  import type { GoClient } from "./go-client.js";
11
11
  import type { JscpdClient } from "./jscpd-client.js";
12
12
  import type { KnipClient } from "./knip-client.js";
13
+ import { canRunStartupHeavyScans } from "./language-policy.js";
13
14
  import {
14
15
  detectProjectLanguageProfile,
15
16
  getDefaultStartupTools,
16
17
  hasLanguage,
17
18
  isLanguageConfigured,
18
19
  } from "./language-profile.js";
19
- import { canRunStartupHeavyScans } from "./language-policy.js";
20
20
  import type { MetricsClient } from "./metrics-client.js";
21
21
  import {
22
22
  buildProjectIndex,
@@ -64,7 +64,10 @@ interface SessionStartDeps {
64
64
 
65
65
  type StartupMode = "full" | "minimal" | "quick";
66
66
 
67
- function isCommandAvailable(command: string, args: string[] = ["--version"]): boolean {
67
+ function isCommandAvailable(
68
+ command: string,
69
+ args: string[] = ["--version"],
70
+ ): boolean {
68
71
  const result = safeSpawn(command, args, { timeout: 5000 });
69
72
  return !result.error && result.status === 0;
70
73
  }
@@ -97,7 +100,9 @@ function getLanguageInstallHints(
97
100
  };
98
101
 
99
102
  if (hasStrongSignal("go") && !isCommandAvailable("gopls")) {
100
- hints.push("Go detected: install gopls (`go install golang.org/x/tools/gopls@latest`).");
103
+ hints.push(
104
+ "Go detected: install gopls (`go install golang.org/x/tools/gopls@latest`).",
105
+ );
101
106
  }
102
107
  if (hasStrongSignal("rust") && !isCommandAvailable("rust-analyzer")) {
103
108
  hints.push(
@@ -174,6 +179,25 @@ export async function handleSessionStart(
174
179
  delete process.env.PI_LENS_DISABLE_LSP_INSTALL;
175
180
  }
176
181
 
182
+ const hasWorkspaceCwd = typeof ctxCwd === "string" && ctxCwd.length > 0;
183
+ const cwd = ctxCwd ?? process.cwd();
184
+ if (quickMode) {
185
+ runtime.projectRoot = cwd;
186
+ const quickTools: string[] = [];
187
+ if (getFlag("lens-lsp") && !getFlag("no-lsp")) {
188
+ quickTools.push("LSP Service");
189
+ }
190
+ log(`Active tools: ${quickTools.join(", ")}`);
191
+ dbg(
192
+ `session_start tools: ${quickTools.join(", ") || "deferred (quick mode)"}`,
193
+ );
194
+ dbg(
195
+ "session_start: quick mode active - skipping slow tool probes, language profiling, preinstall, scans, and error debt baseline",
196
+ );
197
+ dbg(`session_start total: ${Date.now() - sessionStartMs}ms`);
198
+ return;
199
+ }
200
+
177
201
  const tools: string[] = [];
178
202
  if (getFlag("lens-lsp") && !getFlag("no-lsp")) {
179
203
  tools.push("LSP Service");
@@ -200,17 +224,6 @@ export async function handleSessionStart(
200
224
  }
201
225
  }
202
226
 
203
- const hasWorkspaceCwd = typeof ctxCwd === "string" && ctxCwd.length > 0;
204
- const cwd = ctxCwd ?? process.cwd();
205
- if (quickMode) {
206
- runtime.projectRoot = cwd;
207
- dbg(
208
- "session_start: quick mode active - skipping language profiling, preinstall, scans, and error debt baseline",
209
- );
210
- dbg(`session_start total: ${Date.now() - sessionStartMs}ms`);
211
- return;
212
- }
213
-
214
227
  const startupScan = resolveStartupScanContext(cwd);
215
228
  const scanRoot = startupScan.projectRoot ?? cwd;
216
229
  const useScanRootForSignals =
@@ -236,18 +249,20 @@ export async function handleSessionStart(
236
249
  }
237
250
 
238
251
  const lensLspEnabled = !!getFlag("lens-lsp") && !getFlag("no-lsp");
239
- const startupDefaults = getDefaultStartupTools(languageProfile).filter((tool) => {
240
- if (
241
- (tool === "typescript-language-server" || tool === "pyright") &&
242
- !lensLspEnabled
243
- ) {
244
- return false;
245
- }
246
- if (tool === "ruff" && getFlag("no-autofix-ruff")) {
247
- return false;
248
- }
249
- return true;
250
- });
252
+ const startupDefaults = getDefaultStartupTools(languageProfile).filter(
253
+ (tool) => {
254
+ if (
255
+ (tool === "typescript-language-server" || tool === "pyright") &&
256
+ !lensLspEnabled
257
+ ) {
258
+ return false;
259
+ }
260
+ if (tool === "ruff" && getFlag("no-autofix-ruff")) {
261
+ return false;
262
+ }
263
+ return true;
264
+ },
265
+ );
251
266
 
252
267
  if (!allowBootstrapTasks) {
253
268
  dbg("session_start: skipping tool preinstall (startup mode)");
@@ -369,7 +384,9 @@ export async function handleSessionStart(
369
384
  runtime.markStartupScanInFlight(name, sessionGeneration);
370
385
  void task()
371
386
  .then(() => {
372
- dbg(`session_start task ${name}: success (${Date.now() - startedAt}ms)`);
387
+ dbg(
388
+ `session_start task ${name}: success (${Date.now() - startedAt}ms)`,
389
+ );
373
390
  })
374
391
  .catch((err) => {
375
392
  dbg(`session_start: ${name} background scan failed: ${err}`);
@@ -391,7 +408,9 @@ export async function handleSessionStart(
391
408
  dbg(
392
409
  `session_start: skipping heavy scans (${startupScan.reason ?? "unknown"})`,
393
410
  );
394
- dbg(`session_start: skipping TODO scan (${startupScan.reason ?? "unknown"})`);
411
+ dbg(
412
+ `session_start: skipping TODO scan (${startupScan.reason ?? "unknown"})`,
413
+ );
395
414
  } else {
396
415
  const canRunJsTsHeavyScans = canRunStartupHeavyScans(
397
416
  languageProfile,
@@ -401,9 +420,7 @@ export async function handleSessionStart(
401
420
  if (canRunJsTsHeavyScans) {
402
421
  scanNames.push("knip", "jscpd", "ast-grep exports", "project index");
403
422
  }
404
- dbg(
405
- `session_start: launching background scans (${scanNames.join(", ")})`,
406
- );
423
+ dbg(`session_start: launching background scans (${scanNames.join(", ")})`);
407
424
 
408
425
  runStartupTask("todo", async () => {
409
426
  if (!runtime.isCurrentSession(sessionGeneration)) return;
@@ -458,10 +475,9 @@ export async function handleSessionStart(
458
475
  runStartupTask("jscpd", async () => {
459
476
  if (await jscpdClient.ensureAvailable()) {
460
477
  if (!runtime.isCurrentSession(sessionGeneration)) return;
461
- const cached = cacheManager.readCache<ReturnType<JscpdClient["scan"]>>(
462
- "jscpd",
463
- analysisRoot,
464
- );
478
+ const cached = cacheManager.readCache<
479
+ ReturnType<JscpdClient["scan"]>
480
+ >("jscpd", analysisRoot);
465
481
  if (cached) {
466
482
  if (!runtime.isCurrentSession(sessionGeneration)) return;
467
483
  dbg("session_start jscpd: cache hit");
@@ -527,7 +543,9 @@ export async function handleSessionStart(
527
543
  );
528
544
  } else {
529
545
  if (!runtime.isCurrentSession(sessionGeneration)) return;
530
- dbg(`session_start: skipped project index (${tsFiles.length} files)`);
546
+ dbg(
547
+ `session_start: skipped project index (${tsFiles.length} files)`,
548
+ );
531
549
  }
532
550
  }
533
551
  });
@@ -283,6 +283,7 @@ export class TreeSitterClient {
283
283
  async parseFile(
284
284
  filePath: string,
285
285
  languageId: string,
286
+ contentOverride?: string,
286
287
  ): Promise<TreeSitterTree | null> {
287
288
  this.dbg(`Parsing ${filePath} with language ${languageId}`);
288
289
  const parser = await this.getParser(languageId);
@@ -292,7 +293,7 @@ export class TreeSitterClient {
292
293
  }
293
294
 
294
295
  try {
295
- const content = fs.readFileSync(filePath, "utf-8");
296
+ const content = contentOverride ?? fs.readFileSync(filePath, "utf-8");
296
297
  this.dbg(`File content length: ${content.length}`);
297
298
 
298
299
  // Check cache first
@@ -469,6 +470,7 @@ export class TreeSitterClient {
469
470
  filePath: string,
470
471
  languageId: string,
471
472
  options: { maxResults?: number } = {},
473
+ contentOverride?: string,
472
474
  ): Promise<StructuralMatch[]> {
473
475
  if (!this.initialized) {
474
476
  const ok = await this.init();
@@ -493,6 +495,7 @@ export class TreeSitterClient {
493
495
  queryDef.id,
494
496
  compiled.postFilter,
495
497
  compiled.postFilterParams,
498
+ contentOverride,
496
499
  );
497
500
 
498
501
  const maxResults = options.maxResults ?? 50;
@@ -727,8 +730,9 @@ export class TreeSitterClient {
727
730
  postFilter?: string,
728
731
  // biome-ignore lint/suspicious/noExplicitAny: Post filter params
729
732
  postFilterParams?: any,
733
+ contentOverride?: string,
730
734
  ): Promise<StructuralMatch[]> {
731
- const tree = await this.parseFile(filePath, languageId);
735
+ const tree = await this.parseFile(filePath, languageId, contentOverride);
732
736
  if (!tree) return [];
733
737
 
734
738
  const matches: StructuralMatch[] = [];
@@ -369,33 +369,13 @@ export async function handleBooboo(
369
369
  // Runner 3: Semantic similarity
370
370
  await tracker.run("semantic similarity (Amain)", async () => {
371
371
  try {
372
- const { glob } = await import("glob");
373
- const sourceFiles = await glob("**/*.ts", {
374
- cwd: targetPath,
375
- ignore: [
376
- "**/node_modules/**",
377
- "**/*.test.ts",
378
- "**/*.test.tsx",
379
- "**/*.spec.ts",
380
- "**/*.spec.tsx",
381
- "**/*.poc.test.ts",
382
- "**/*.poc.test.tsx",
383
- "**/test-utils.ts",
384
- "**/test-*.ts",
385
- "**/__tests__/**",
386
- "**/tests/**",
387
- "**/dist/**",
388
- ],
389
- });
372
+ const absoluteFiles = collectSourceFiles(targetPath, {
373
+ extensions: [".ts"],
374
+ }).filter(shouldIncludeFile);
390
375
 
391
- if (sourceFiles.length === 0) {
376
+ if (absoluteFiles.length === 0) {
392
377
  return { findings: 0, status: "done" };
393
378
  }
394
-
395
- // Filter out test files using centralized exclusion
396
- const absoluteFiles = sourceFiles
397
- .map((f) => path.join(targetPath, f))
398
- .filter(shouldIncludeFile);
399
379
  const index = await buildProjectIndex(targetPath, absoluteFiles);
400
380
  const topPairs = findTopSimilarPairs(index, 10);
401
381
 
package/index.ts CHANGED
@@ -13,7 +13,6 @@ import {
13
13
  getLatencyReports,
14
14
  resetDispatchBaselines,
15
15
  } from "./clients/dispatch/integration.js";
16
- import { extractFunctions } from "./clients/dispatch/runners/similarity.js";
17
16
  import { resetFormatService } from "./clients/format-service.js";
18
17
  import { evaluateGitGuard, isGitCommitOrPushAttempt } from "./clients/git-guard.js";
19
18
  import { ensureTool } from "./clients/installer/index.js";
@@ -60,6 +59,11 @@ function dbg(msg: string) {
60
59
  let _verbose = false;
61
60
  const runtime = new RuntimeCoordinator();
62
61
  const _lspConfigInitializedCwds = new Set<string>();
62
+ const LSP_TOOLCALL_NAV_TOUCH_BUDGET_MS = Math.max(
63
+ 0,
64
+ Number.parseInt(process.env.PI_LENS_TOOLCALL_NAV_TOUCH_MS ?? "700", 10) ||
65
+ 700,
66
+ );
63
67
 
64
68
  function log(msg: string) {
65
69
  if (_verbose) console.error(`[pi-lens] ${msg}`);
@@ -506,7 +510,8 @@ pi.on("session_start", async (event, ctx) => {
506
510
  testRunnerClient,
507
511
  goClient,
508
512
  rustClient,
509
- ensureTool,
513
+ ensureTool: async (name: string) =>
514
+ (await import("./clients/installer/index.js")).ensureTool(name),
510
515
  cleanStaleTsBuildInfo,
511
516
  resetDispatchBaselines,
512
517
  resetLSPService,
@@ -576,8 +581,17 @@ pi.on("tool_call", async (event, ctx) => {
576
581
  if (shouldAutoTouch) {
577
582
  try {
578
583
  const fileContent = nodeFs.readFileSync(filePath, "utf-8");
584
+ const maxClientWaitMs =
585
+ toolName === "lsp_navigation" ? LSP_TOOLCALL_NAV_TOUCH_BUDGET_MS : undefined;
579
586
  void getLSPService()
580
- .touchFile(filePath, fileContent, false, `tool_call:${toolName}`)
587
+ .touchFile(
588
+ filePath,
589
+ fileContent,
590
+ false,
591
+ `tool_call:${toolName}`,
592
+ false,
593
+ maxClientWaitMs,
594
+ )
581
595
  .catch((err) => dbg(`lsp auto-touch failed for ${filePath}: ${err}`));
582
596
  } catch {
583
597
  // Best effort only; never block tool calls.
@@ -594,6 +608,9 @@ pi.on("tool_call", async (event, ctx) => {
594
608
  const baseline = complexityClient.analyzeFile(filePath);
595
609
  if (baseline) {
596
610
  runtime.complexityBaselines.set(filePath, baseline);
611
+ const { captureSnapshot } = await import(
612
+ "./clients/metrics-history.js"
613
+ );
597
614
  captureSnapshot(filePath, {
598
615
  maintainabilityIndex: baseline.maintainabilityIndex,
599
616
  cognitiveComplexity: baseline.cognitiveComplexity,
@@ -659,6 +676,8 @@ pi.on("tool_call", async (event, ctx) => {
659
676
  ts.ScriptTarget.Latest,
660
677
  true,
661
678
  );
679
+ const { extractFunctions } = await import("./clients/dispatch/runners/similarity.js");
680
+ const { findSimilarFunctions } = await import("./clients/project-index.js");
662
681
  const newFunctions = extractFunctions(sourceFile, newContent);
663
682
  const simWarnings: string[] = [];
664
683
  let simHintsTruncated = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "3.8.24",
3
+ "version": "3.8.26",
4
4
  "type": "module",
5
5
  "description": "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo",
6
6
  "repository": {