pi-lens 3.8.19 → 3.8.21

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.
@@ -25,3 +25,29 @@ export function consumeTurnEndFindings(
25
25
  ],
26
26
  };
27
27
  }
28
+
29
+ export function consumeSessionStartGuidance(
30
+ cacheManager: CacheManager,
31
+ cwd: string,
32
+ ): { messages: Array<{ role: "system"; content: string }> } | undefined {
33
+ const guidance = cacheManager.readCache<{ content: string }>(
34
+ "session-start-guidance",
35
+ cwd,
36
+ );
37
+ if (!guidance?.data?.content) return;
38
+
39
+ cacheManager.writeCache(
40
+ "session-start-guidance",
41
+ null as unknown as { content: string },
42
+ cwd,
43
+ );
44
+
45
+ return {
46
+ messages: [
47
+ {
48
+ role: "system",
49
+ content: `[pi-lens] Session guidance:\n\n${guidance.data.content}`,
50
+ },
51
+ ],
52
+ };
53
+ }
@@ -3,6 +3,7 @@ import type { FileComplexity } from "./complexity-client.js";
3
3
  import type { RuleScanResult } from "./rules-scanner.js";
4
4
  import { RUNTIME_CONFIG } from "./runtime-config.js";
5
5
  import type { ProjectIndex } from "./project-index.js";
6
+ import { normalizeMapKey } from "./path-utils.js";
6
7
 
7
8
  export interface ErrorDebtBaseline {
8
9
  testsPassed: boolean;
@@ -10,11 +11,13 @@ export interface ErrorDebtBaseline {
10
11
  }
11
12
 
12
13
  export class RuntimeCoordinator {
13
- private _projectRoot = process.cwd();
14
+ private _projectRoot = normalizeMapKey(process.cwd());
15
+ private _sessionGeneration = 0;
14
16
  private _errorDebtBaseline: ErrorDebtBaseline | null = null;
15
17
  private _pipelineCrashCounts = new Map<string, number>();
16
18
  private _cachedExports = new Map<string, string>();
17
19
  private _cachedProjectIndex: ProjectIndex | null = null;
20
+ private _startupScansInFlight = new Map<string, number>();
18
21
  private _lastCascadeOutput = "";
19
22
  private _complexityBaselines = new Map<string, FileComplexity>();
20
23
  private _fixedThisTurn = new Set<string>();
@@ -30,10 +33,12 @@ export class RuntimeCoordinator {
30
33
  private _gitGuardSummary = "";
31
34
 
32
35
  resetForSession(): void {
36
+ this._sessionGeneration += 1;
33
37
  this._complexityBaselines.clear();
34
38
  this._pipelineCrashCounts.clear();
35
39
  this._cachedExports.clear();
36
40
  this._cachedProjectIndex = null;
41
+ this._startupScansInFlight.clear();
37
42
  this._lastCascadeOutput = "";
38
43
  this._fixedThisTurn.clear();
39
44
  this._telemetrySessionId =
@@ -111,6 +116,29 @@ export class RuntimeCoordinator {
111
116
  return this._turnIndex;
112
117
  }
113
118
 
119
+ get sessionGeneration(): number {
120
+ return this._sessionGeneration;
121
+ }
122
+
123
+ isCurrentSession(generation: number): boolean {
124
+ return this._sessionGeneration === generation;
125
+ }
126
+
127
+ markStartupScanInFlight(name: string, generation: number): void {
128
+ this._startupScansInFlight.set(name, generation);
129
+ }
130
+
131
+ clearStartupScanInFlight(name: string, generation: number): void {
132
+ const owner = this._startupScansInFlight.get(name);
133
+ if (owner === generation) {
134
+ this._startupScansInFlight.delete(name);
135
+ }
136
+ }
137
+
138
+ isStartupScanInFlight(name: string): boolean {
139
+ return this._startupScansInFlight.has(name);
140
+ }
141
+
114
142
  formatPipelineCrashNotice(filePath: string, err: unknown): string {
115
143
  const key = path.resolve(filePath);
116
144
  const count = (this._pipelineCrashCounts.get(key) ?? 0) + 1;
@@ -140,7 +168,7 @@ export class RuntimeCoordinator {
140
168
  }
141
169
 
142
170
  set projectRoot(value: string) {
143
- this._projectRoot = value;
171
+ this._projectRoot = normalizeMapKey(value);
144
172
  }
145
173
 
146
174
  get errorDebtBaseline(): ErrorDebtBaseline | null {
@@ -6,9 +6,17 @@ import type { BiomeClient } from "./biome-client.js";
6
6
  import type { CacheManager } from "./cache-manager.js";
7
7
  import type { DependencyChecker } from "./dependency-checker.js";
8
8
  import { getDiagnosticTracker } from "./diagnostic-tracker.js";
9
+ import { getKnipIgnorePatterns } from "./file-utils.js";
9
10
  import type { GoClient } from "./go-client.js";
10
11
  import type { JscpdClient } from "./jscpd-client.js";
11
12
  import type { KnipClient } from "./knip-client.js";
13
+ import {
14
+ detectProjectLanguageProfile,
15
+ getDefaultStartupTools,
16
+ hasLanguage,
17
+ isLanguageConfigured,
18
+ } from "./language-profile.js";
19
+ import { canRunStartupHeavyScans } from "./language-policy.js";
12
20
  import type { MetricsClient } from "./metrics-client.js";
13
21
  import {
14
22
  buildProjectIndex,
@@ -16,11 +24,11 @@ import {
16
24
  loadIndex,
17
25
  saveIndex,
18
26
  } from "./project-index.js";
19
- import { scanProjectRules } from "./rules-scanner.js";
20
27
  import type { RuffClient } from "./ruff-client.js";
28
+ import { scanProjectRules } from "./rules-scanner.js";
21
29
  import type { RuntimeCoordinator } from "./runtime-coordinator.js";
22
30
  import type { RustClient } from "./rust-client.js";
23
- import { getKnipIgnorePatterns } from "./file-utils.js";
31
+ import { safeSpawn } from "./safe-spawn.js";
24
32
  import { getSourceFiles } from "./scan-utils.js";
25
33
  import { resolveStartupScanContext } from "./startup-scan.js";
26
34
  import type { TestRunnerClient } from "./test-runner-client.js";
@@ -54,7 +62,43 @@ interface SessionStartDeps {
54
62
  resetLSPService: () => void;
55
63
  }
56
64
 
57
- export async function handleSessionStart(deps: SessionStartDeps): Promise<void> {
65
+ function isCommandAvailable(command: string, args: string[] = ["--version"]): boolean {
66
+ const result = safeSpawn(command, args, { timeout: 5000 });
67
+ return !result.error && result.status === 0;
68
+ }
69
+
70
+ function getLanguageInstallHints(
71
+ languageProfile: ReturnType<typeof detectProjectLanguageProfile>,
72
+ ): string[] {
73
+ const hints: string[] = [];
74
+ const hasStrongSignal = (
75
+ kind: "go" | "rust" | "ruby",
76
+ minCount = 3,
77
+ ): boolean => {
78
+ if (!hasLanguage(languageProfile, kind)) return false;
79
+ if (isLanguageConfigured(languageProfile, kind)) return true;
80
+ return (languageProfile.counts[kind] ?? 0) >= minCount;
81
+ };
82
+
83
+ if (hasStrongSignal("go") && !isCommandAvailable("gopls")) {
84
+ hints.push("Go detected: install gopls (`go install golang.org/x/tools/gopls@latest`).");
85
+ }
86
+ if (hasStrongSignal("rust") && !isCommandAvailable("rust-analyzer")) {
87
+ hints.push(
88
+ "Rust detected: install rust-analyzer (`rustup component add rust-analyzer`).",
89
+ );
90
+ }
91
+ if (hasStrongSignal("ruby") && !isCommandAvailable("ruby-lsp")) {
92
+ hints.push("Ruby detected: install ruby-lsp (`gem install ruby-lsp`).");
93
+ }
94
+
95
+ return hints;
96
+ }
97
+
98
+ export async function handleSessionStart(
99
+ deps: SessionStartDeps,
100
+ ): Promise<void> {
101
+ const sessionStartMs = Date.now();
58
102
  const {
59
103
  ctxCwd,
60
104
  getFlag,
@@ -100,6 +144,13 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
100
144
  delete process.env.PI_LENS_AUTO_INSTALL;
101
145
  }
102
146
 
147
+ if (getFlag("no-lsp-install")) {
148
+ process.env.PI_LENS_DISABLE_LSP_INSTALL = "1";
149
+ dbg("session_start: LSP install disabled (PI_LENS_DISABLE_LSP_INSTALL=1)");
150
+ } else {
151
+ delete process.env.PI_LENS_DISABLE_LSP_INSTALL;
152
+ }
153
+
103
154
  const tools: string[] = [];
104
155
  if (getFlag("lens-lsp") && !getFlag("no-lsp")) {
105
156
  tools.push("LSP Service");
@@ -126,34 +177,76 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
126
177
  }
127
178
  }
128
179
 
129
- if (getFlag("lens-lsp") && !getFlag("no-lsp")) {
130
- dbg("session_start: pre-installing TypeScript LSP...");
131
- ensureTool("typescript-language-server")
132
- .then((toolPath) => {
133
- if (toolPath) {
134
- dbg(`session_start: TypeScript LSP ready at ${toolPath}`);
135
- } else {
136
- console.error("[lens] TypeScript LSP installation failed");
137
- }
138
- })
139
- .catch((err) => {
140
- console.error("[lens] TypeScript LSP pre-install error:", err);
141
- });
142
- }
143
-
180
+ const hasWorkspaceCwd = typeof ctxCwd === "string" && ctxCwd.length > 0;
144
181
  const cwd = ctxCwd ?? process.cwd();
145
182
  const startupScan = resolveStartupScanContext(cwd);
146
183
  const scanRoot = startupScan.projectRoot ?? cwd;
147
- const analysisRoot = scanRoot;
148
- runtime.projectRoot = scanRoot;
184
+ const useScanRootForSignals =
185
+ startupScan.canWarmCaches || startupScan.reason === "too-many-source-files";
186
+ const analysisRoot = useScanRootForSignals ? scanRoot : cwd;
187
+ runtime.projectRoot = cwd;
188
+ const languageProfile = detectProjectLanguageProfile(analysisRoot);
149
189
  dbg(`session_start cwd: ${cwd}`);
150
190
  dbg(
151
191
  `session_start scan root: ${scanRoot} (warmCaches=${startupScan.canWarmCaches}${startupScan.reason ? `, reason=${startupScan.reason}` : ""})`,
152
192
  );
153
- if (analysisRoot !== cwd) {
193
+ dbg(`session_start analysis root: ${analysisRoot}`);
194
+ dbg(`session_start workspace root: ${runtime.projectRoot}`);
195
+ dbg(
196
+ `session_start language profile: ${languageProfile.detectedKinds.join(", ") || "none"}`,
197
+ );
198
+ dbg(
199
+ `session_start language counts: ${JSON.stringify(languageProfile.counts)} configured=${JSON.stringify(languageProfile.configured)}`,
200
+ );
201
+ dbg(`session_start workspace cwd available: ${hasWorkspaceCwd}`);
202
+ if (useScanRootForSignals && analysisRoot !== cwd) {
154
203
  dbg(`session_start: monorepo analysis root override -> ${analysisRoot}`);
155
204
  }
156
205
 
206
+ const lensLspEnabled = !!getFlag("lens-lsp") && !getFlag("no-lsp");
207
+ const startupDefaults = getDefaultStartupTools(languageProfile).filter((tool) => {
208
+ if (
209
+ (tool === "typescript-language-server" || tool === "pyright") &&
210
+ !lensLspEnabled
211
+ ) {
212
+ return false;
213
+ }
214
+ if (tool === "ruff" && getFlag("no-autofix-ruff")) {
215
+ return false;
216
+ }
217
+ return true;
218
+ });
219
+
220
+ if (startupDefaults.length > 0) {
221
+ dbg(`session_start: pre-install defaults -> ${startupDefaults.join(", ")}`);
222
+ for (const tool of startupDefaults) {
223
+ const startedAt = Date.now();
224
+ dbg(`session_start preinstall ${tool}: start`);
225
+ ensureTool(tool)
226
+ .then((toolPath) => {
227
+ if (toolPath) {
228
+ dbg(`session_start: ${tool} ready at ${toolPath}`);
229
+ dbg(
230
+ `session_start preinstall ${tool}: success (${Date.now() - startedAt}ms)`,
231
+ );
232
+ } else {
233
+ dbg(`session_start: ${tool} installation unavailable`);
234
+ dbg(
235
+ `session_start preinstall ${tool}: unavailable (${Date.now() - startedAt}ms)`,
236
+ );
237
+ }
238
+ })
239
+ .catch((err) => {
240
+ dbg(`session_start: ${tool} pre-install error: ${err}`);
241
+ dbg(
242
+ `session_start preinstall ${tool}: error (${Date.now() - startedAt}ms)`,
243
+ );
244
+ });
245
+ }
246
+ } else {
247
+ dbg("session_start: no language defaults selected for pre-install");
248
+ }
249
+
157
250
  {
158
251
  const pkgPath = path.join(analysisRoot, "package.json");
159
252
  try {
@@ -174,9 +267,7 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
174
267
  if (p) dbg(`session_start: prettier ready at ${p}`);
175
268
  else dbg("session_start: prettier install failed silently");
176
269
  })
177
- .catch((err) =>
178
- dbg(`session_start: prettier install error: ${err}`),
179
- );
270
+ .catch((err) => dbg(`session_start: prettier install error: ${err}`));
180
271
  }
181
272
  } catch {
182
273
  // no package.json at cwd root
@@ -196,14 +287,17 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
196
287
  dbg(`session_start tools: ${tools.join(", ")}`);
197
288
 
198
289
  const startupNotes: string[] = [];
199
- startupNotes.push(
200
- "📌 pi-lens active — fix any errors you find (including pre-existing). Prefer: lsp_navigation for definitions/references, ast_grep_search for code patterns, grep for text/TODO search.",
290
+ const agentStartupGuidance: string[] = [];
291
+ agentStartupGuidance.push(
292
+ "📌 pi-lens active — as you work on this project, fix any errors you encounter (including pre-existing). Prefer: lsp_navigation for definitions/references, ast_grep_search for code patterns, grep for text/TODO search.",
201
293
  );
202
294
 
203
295
  runtime.projectRulesScan = scanProjectRules(analysisRoot);
204
296
  if (runtime.projectRulesScan.hasCustomRules) {
205
297
  const ruleCount = runtime.projectRulesScan.rules.length;
206
- const sources = [...new Set(runtime.projectRulesScan.rules.map((r) => r.source))];
298
+ const sources = [
299
+ ...new Set(runtime.projectRulesScan.rules.map((r) => r.source)),
300
+ ];
207
301
  dbg(
208
302
  `session_start: found ${ruleCount} project rule(s) from ${sources.join(", ")}`,
209
303
  );
@@ -214,102 +308,196 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
214
308
  dbg("session_start: no project rules found");
215
309
  }
216
310
 
217
- const todoResult = todoScanner.scanDirectory(analysisRoot);
218
- dbg(
219
- `session_start TODO scan: ${todoResult.items.length} items (baseline stored)`,
220
- );
221
- cacheManager.writeCache("todo-baseline", { items: todoResult.items }, analysisRoot);
311
+ if (hasWorkspaceCwd) {
312
+ const installHints = getLanguageInstallHints(languageProfile);
313
+ dbg(`session_start tooling hints count: ${installHints.length}`);
314
+ if (installHints.length > 0) {
315
+ startupNotes.push(`🧰 Tooling hints: ${installHints.join(" ")}`);
316
+ }
317
+ } else {
318
+ dbg("session_start: skipping tooling hints (workspace cwd unavailable)");
319
+ }
320
+
321
+ if (agentStartupGuidance.length > 0) {
322
+ cacheManager.writeCache(
323
+ "session-start-guidance",
324
+ { content: agentStartupGuidance.join("\n") },
325
+ analysisRoot,
326
+ );
327
+ }
328
+
329
+ const sessionGeneration = runtime.sessionGeneration;
330
+ const runStartupTask = (name: string, task: () => Promise<void>): void => {
331
+ const startedAt = Date.now();
332
+ dbg(`session_start task ${name}: start`);
333
+ runtime.markStartupScanInFlight(name, sessionGeneration);
334
+ void task()
335
+ .then(() => {
336
+ dbg(`session_start task ${name}: success (${Date.now() - startedAt}ms)`);
337
+ })
338
+ .catch((err) => {
339
+ dbg(`session_start: ${name} background scan failed: ${err}`);
340
+ dbg(`session_start task ${name}: failed (${Date.now() - startedAt}ms)`);
341
+ })
342
+ .finally(() => {
343
+ runtime.clearStartupScanInFlight(name, sessionGeneration);
344
+ dbg(`session_start task ${name}: end`);
345
+ });
346
+ };
347
+
348
+ // Fire off heavy scans as background tasks — don't block session start.
349
+ // Each consumer already handles the "not ready yet" case gracefully
350
+ // (cachedExports.size > 0, cachedProjectIndex != null, cache miss paths).
222
351
 
223
352
  if (!startupScan.canWarmCaches) {
224
- dbg(`session_start: skipping heavy scans (${startupScan.reason ?? "unknown"})`);
353
+ dbg(
354
+ `session_start: skipping heavy scans (${startupScan.reason ?? "unknown"})`,
355
+ );
356
+ dbg(`session_start: skipping TODO scan (${startupScan.reason ?? "unknown"})`);
225
357
  } else {
226
- if (await knipClient.ensureAvailable()) {
227
- const cached = cacheManager.readCache<ReturnType<KnipClient["analyze"]>>(
228
- "knip",
229
- analysisRoot,
230
- );
231
- if (cached) {
232
- dbg(
233
- `session_start Knip: cache hit (${Math.round((Date.now() - new Date(cached.meta.timestamp).getTime()) / 1000)}s ago)`,
234
- );
235
- } else {
236
- const startMs = Date.now();
237
- const knipResult = knipClient.analyze(
238
- analysisRoot,
239
- getKnipIgnorePatterns(),
240
- );
241
- cacheManager.writeCache("knip", knipResult, analysisRoot, {
242
- scanDurationMs: Date.now() - startMs,
243
- });
244
- dbg("session_start Knip scan done");
245
- }
246
- } else {
247
- dbg("session_start Knip: not available");
358
+ const canRunJsTsHeavyScans = canRunStartupHeavyScans(
359
+ languageProfile,
360
+ "jsts",
361
+ );
362
+ const scanNames = ["todo"];
363
+ if (canRunJsTsHeavyScans) {
364
+ scanNames.push("knip", "jscpd", "ast-grep exports", "project index");
248
365
  }
366
+ dbg(
367
+ `session_start: launching background scans (${scanNames.join(", ")})`,
368
+ );
249
369
 
250
- if (await jscpdClient.ensureAvailable()) {
251
- const cached = cacheManager.readCache<ReturnType<JscpdClient["scan"]>>(
252
- "jscpd",
370
+ runStartupTask("todo", async () => {
371
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
372
+ const todoResult = todoScanner.scanDirectory(analysisRoot);
373
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
374
+ dbg(
375
+ `session_start TODO scan: ${todoResult.items.length} items (baseline stored)`,
376
+ );
377
+ cacheManager.writeCache(
378
+ "todo-baseline",
379
+ { items: todoResult.items },
253
380
  analysisRoot,
254
381
  );
255
- if (cached) {
256
- dbg("session_start jscpd: cache hit");
257
- } else {
258
- const startMs = Date.now();
259
- const jscpdResult = jscpdClient.scan(analysisRoot);
260
- cacheManager.writeCache("jscpd", jscpdResult, analysisRoot, {
261
- scanDurationMs: Date.now() - startMs,
262
- });
263
- dbg("session_start jscpd scan done");
264
- }
382
+ });
383
+
384
+ if (!canRunJsTsHeavyScans) {
385
+ dbg(
386
+ "session_start: skipping JS/TS startup scans (requires JS/TS language + project config)",
387
+ );
265
388
  } else {
266
- dbg("session_start jscpd: not available");
267
- }
389
+ // Knip dead code / unused exports
390
+ runStartupTask("knip", async () => {
391
+ if (await knipClient.ensureAvailable()) {
392
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
393
+ const cached = cacheManager.readCache<
394
+ ReturnType<KnipClient["analyze"]>
395
+ >("knip", analysisRoot);
396
+ if (cached) {
397
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
398
+ dbg(
399
+ `session_start Knip: cache hit (${Math.round((Date.now() - new Date(cached.meta.timestamp).getTime()) / 1000)}s ago)`,
400
+ );
401
+ } else {
402
+ const startMs = Date.now();
403
+ const knipResult = knipClient.analyze(
404
+ analysisRoot,
405
+ getKnipIgnorePatterns(),
406
+ );
407
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
408
+ cacheManager.writeCache("knip", knipResult, analysisRoot, {
409
+ scanDurationMs: Date.now() - startMs,
410
+ });
411
+ dbg(`session_start Knip scan done (${Date.now() - startMs}ms)`);
412
+ }
413
+ } else {
414
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
415
+ dbg("session_start Knip: not available");
416
+ }
417
+ });
268
418
 
269
- if (await astGrepClient.ensureAvailable()) {
270
- const exports = await astGrepClient.scanExports(analysisRoot, "typescript");
271
- dbg(`session_start exports scan: ${exports.size} functions found`);
272
- for (const [name, file] of exports) {
273
- runtime.cachedExports.set(name, file);
274
- }
275
- }
419
+ // jscpd duplicate code detection
420
+ runStartupTask("jscpd", async () => {
421
+ if (await jscpdClient.ensureAvailable()) {
422
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
423
+ const cached = cacheManager.readCache<ReturnType<JscpdClient["scan"]>>(
424
+ "jscpd",
425
+ analysisRoot,
426
+ );
427
+ if (cached) {
428
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
429
+ dbg("session_start jscpd: cache hit");
430
+ } else {
431
+ const startMs = Date.now();
432
+ const jscpdResult = jscpdClient.scan(analysisRoot);
433
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
434
+ cacheManager.writeCache("jscpd", jscpdResult, analysisRoot, {
435
+ scanDurationMs: Date.now() - startMs,
436
+ });
437
+ dbg(`session_start jscpd scan done (${Date.now() - startMs}ms)`);
438
+ }
439
+ } else {
440
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
441
+ dbg("session_start jscpd: not available");
442
+ }
443
+ });
276
444
 
277
- try {
278
- const existing = await loadIndex(analysisRoot);
279
- if (
280
- existing &&
281
- existing.entries.size > 0 &&
282
- (await isIndexFresh(analysisRoot))
283
- ) {
284
- runtime.cachedProjectIndex = existing;
285
- dbg(
286
- `session_start: loaded fresh project index (${existing.entries.size} entries)`,
287
- );
288
- } else {
289
- const sourceFiles = getSourceFiles(analysisRoot, true);
290
- const tsFiles = sourceFiles.filter(
291
- (f) => f.endsWith(".ts") || f.endsWith(".tsx"),
292
- );
293
- if (tsFiles.length > 0 && tsFiles.length <= 500) {
294
- runtime.cachedProjectIndex = await buildProjectIndex(
445
+ // ast-grep — export scan for duplicate detection
446
+ runStartupTask("ast-grep-exports", async () => {
447
+ if (await astGrepClient.ensureAvailable()) {
448
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
449
+ const exports = await astGrepClient.scanExports(
295
450
  analysisRoot,
296
- tsFiles,
451
+ "typescript",
297
452
  );
298
- await saveIndex(runtime.cachedProjectIndex, analysisRoot);
453
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
454
+ dbg(`session_start exports scan: ${exports.size} functions found`);
455
+ for (const [name, file] of exports) {
456
+ runtime.cachedExports.set(name, file);
457
+ }
458
+ }
459
+ });
460
+
461
+ // Project index — structural similarity detection
462
+ runStartupTask("project-index", async () => {
463
+ const existing = await loadIndex(analysisRoot);
464
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
465
+ if (
466
+ existing &&
467
+ existing.entries.size > 0 &&
468
+ (await isIndexFresh(analysisRoot))
469
+ ) {
470
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
471
+ runtime.cachedProjectIndex = existing;
299
472
  dbg(
300
- `session_start: built project index (${runtime.cachedProjectIndex.entries.size} entries from ${tsFiles.length} files)`,
473
+ `session_start: loaded fresh project index (${existing.entries.size} entries)`,
301
474
  );
302
475
  } else {
303
- dbg(`session_start: skipped project index (${tsFiles.length} files)`);
476
+ const sourceFiles = getSourceFiles(analysisRoot, true);
477
+ const tsFiles = sourceFiles.filter(
478
+ (f) => f.endsWith(".ts") || f.endsWith(".tsx"),
479
+ );
480
+ if (tsFiles.length > 0 && tsFiles.length <= 500) {
481
+ runtime.cachedProjectIndex = await buildProjectIndex(
482
+ analysisRoot,
483
+ tsFiles,
484
+ );
485
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
486
+ await saveIndex(runtime.cachedProjectIndex, analysisRoot);
487
+ dbg(
488
+ `session_start: built project index (${runtime.cachedProjectIndex.entries.size} entries from ${tsFiles.length} files)`,
489
+ );
490
+ } else {
491
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
492
+ dbg(`session_start: skipped project index (${tsFiles.length} files)`);
493
+ }
304
494
  }
305
- }
306
- } catch (err) {
307
- dbg(`session_start: project index build failed: ${err}`);
495
+ });
308
496
  }
309
497
  }
310
498
 
311
499
  dbg(
312
- `session_start: scans complete (${startupNotes.length} startup note(s)), cached for commands`,
500
+ `session_start: background scans launched (${startupNotes.length} startup note(s))`,
313
501
  );
314
502
 
315
503
  const errorDebtEnabled = getFlag("error-debt");
@@ -368,4 +556,6 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
368
556
  if (startupNotes.length > 0) {
369
557
  notify(startupNotes.join("\n"), "info");
370
558
  }
559
+
560
+ dbg(`session_start total: ${Date.now() - sessionStartMs}ms`);
371
561
  }
@@ -1,6 +1,7 @@
1
1
  import * as nodeFs from "node:fs";
2
- import { FileTimeError, createFileTime } from "./file-time.js";
2
+ import { createFileTime } from "./file-time.js";
3
3
  import { getFormatService } from "./format-service.js";
4
+ import { resolveLanguageRootForFile } from "./language-profile.js";
4
5
  import { logLatency } from "./latency-logger.js";
5
6
  import { runPipeline } from "./pipeline.js";
6
7
  import type { BiomeClient } from "./biome-client.js";
@@ -101,19 +102,16 @@ export async function handleToolResult(
101
102
  }
102
103
 
103
104
  const sessionFileTime = createFileTime("default");
104
- try {
105
- sessionFileTime.assert(filePath);
106
- } catch (err: unknown) {
107
- if (err instanceof FileTimeError) {
108
- dbg(`⚠️ FileTime warning: ${err.message}`);
109
- }
110
- }
105
+ // tool_result is emitted after write/edit has already been applied.
106
+ // Asserting pre-write stamps here produces false positives on rapid edits.
111
107
  sessionFileTime.read(filePath);
112
108
 
113
109
  const toolResultStart = Date.now();
114
110
  dbg(`tool_result: tracking turn state for ${event.toolName} on ${filePath}`);
115
111
 
116
- const cwd = runtime.projectRoot;
112
+ const workspaceRoot = runtime.projectRoot;
113
+ const cwd = resolveLanguageRootForFile(filePath, workspaceRoot);
114
+ dbg(`tool_result: resolved dispatch cwd ${cwd} for ${filePath}`);
117
115
  if (event.model || event.provider || event.sessionId || event.session?.id) {
118
116
  runtime.setTelemetryIdentity({
119
117
  model: event.model,
@@ -183,7 +181,7 @@ export async function handleToolResult(
183
181
  result = await runPipeline(
184
182
  {
185
183
  filePath,
186
- cwd: runtime.projectRoot,
184
+ cwd,
187
185
  toolName: event.toolName,
188
186
  modifiedRanges,
189
187
  telemetry: {