pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
1,145 lines • 58.4 kB
JavaScript
import * as nodeFs from "node:fs";
import * as path from "node:path";
import { deadCodeIssueCount } from "./dead-code-client.js";
import { logDeadCodeScan } from "./dead-code-logger.js";
import { getDiagnosticTracker } from "./diagnostic-tracker.js";
import { clearAllSessions as clearFileTimeSessions } from "./file-time.js";
import { getKnipIgnorePatterns } from "./file-utils.js";
import { GitleaksClient } from "./gitleaks-client.js";
import { TrivyClient } from "./trivy-client.js";
import { GovulncheckClient, } from "./govulncheck-client.js";
import { canRunStartupHeavyScans } from "./language-policy.js";
import { detectProjectLanguageProfile, getDefaultStartupTools, } from "./language-profile.js";
import { logLatency } from "./latency-logger.js";
import { runLogCleanup } from "./log-cleanup.js";
import { setSessionLanguages } from "./widget-state.js";
import { initLSPConfig, loadLSPConfig } from "./lsp/config.js";
import { getLSPService } from "./lsp/index.js";
import { readLatestProjectSequence } from "./project-changes.js";
import { getProjectSnapshotPath, hydrateRuntimeFromProjectSnapshot, isProjectSnapshotFresh, loadProjectSnapshot, saveRuntimeProjectSnapshot, } from "./project-snapshot.js";
import { scanProjectRules } from "./rules-scanner.js";
import { isAtOrAboveHomeDir } from "./path-utils.js";
import { getSlowFsVerdict, isSlowFs, slowFsDegradationNotice, } from "./slow-fs.js";
import { getSubagentIdentity, isSubagentSession, subagentLightModeNotice, } from "./subagent-mode.js";
import { findNearestProjectRoot, resolveStartupScanContext, } from "./startup-scan.js";
function resolveSnapshotRoot(cwd) {
const resolvedCwd = path.resolve(cwd);
const nearest = findNearestProjectRoot(resolvedCwd);
// Reject a root at — or above — $HOME (the #250/#253 escape); fall back to
// the cwd so the snapshot stays scoped to the actual workspace.
if (!nearest || isAtOrAboveHomeDir(nearest)) {
return resolvedCwd;
}
return nearest;
}
function describeSnapshotMiss(snapshot, currentProjectSeq) {
if (!snapshot)
return "missing";
if (snapshot.seq !== currentProjectSeq) {
return `stale(seq=${snapshot.seq}, current=${currentProjectSeq})`;
}
return "incompatible";
}
function logProjectSnapshotProbe(args) {
args.dbg(`project_snapshot: probe root=${args.root} path=${getProjectSnapshotPath(args.root)} currentSeq=${args.currentProjectSeq}`);
if (isProjectSnapshotFresh(args.snapshot, args.currentProjectSeq)) {
args.dbg(`project_snapshot: loaded seq=${args.snapshot.seq} exports=${args.snapshot.cachedExports.length} files=${Object.keys(args.snapshot.files ?? {}).length} reverseDeps=${Object.keys(args.snapshot.reverseDeps ?? {}).length} startupScan=${Boolean(args.snapshot.startupScan)} languageProfile=${Boolean(args.snapshot.languageProfile)}`);
}
else {
args.dbg(`project_snapshot: miss reason=${describeSnapshotMiss(args.snapshot, args.currentProjectSeq)}`);
}
}
function resolveStartupMode() {
const envMode = (process.env.PI_LENS_STARTUP_MODE ?? "").trim().toLowerCase();
if (envMode === "full" || envMode === "minimal" || envMode === "quick") {
return envMode;
}
const argv = process.argv;
if (argv.includes("--print") || argv.includes("-p")) {
return "quick";
}
return "full";
}
// --- Session-start helpers ---
async function igniteWarmFiles(cwd, warmFiles, runtime, sessionGeneration, dbg) {
try {
dbg(`session_start lsp-warm: ${warmFiles.length} warm file(s) configured`);
await initLSPConfig(cwd);
if (!runtime.isCurrentSession(sessionGeneration))
return;
const lspService = getLSPService();
const total = warmFiles.length;
let loaded = 0;
let errors = 0;
for (const relPath of warmFiles) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
const filePath = path.isAbsolute(relPath)
? relPath
: path.resolve(cwd, relPath);
if (!nodeFs.existsSync(filePath)) {
dbg(`session_start lsp-warm: not found: ${relPath}`);
errors++;
continue;
}
try {
const content = nodeFs.readFileSync(filePath, "utf-8");
await lspService.touchFile(filePath, content, {
diagnostics: "none",
source: "startup-warm",
clientScope: "primary",
maxClientWaitMs: 2000,
});
loaded++;
}
catch (err) {
dbg(`session_start lsp-warm: error ${relPath}: ${err}`);
errors++;
}
}
dbg(`session_start lsp-warm: ${loaded}/${total} opened (${errors} err)`);
}
catch (err) {
dbg(`session_start lsp-warm: config/init error: ${err}`);
}
}
/**
* Fallback warm when a project has no explicit `warmFiles`: pre-spawn the LSP
* for the project's *dominant* language (highest source-file count) by opening
* one representative file. This eliminates the cold-spawn stall the first edit
* would otherwise pay (`lsp_client_wait_timeout`, observed up to 5s on
* TypeScript/Deno). Only a single server is warmed — launching every detected
* language's server at once (rust-analyzer + gopls + tsserver …) would spike CPU
* and the event loop at startup, working against the very latency we protect.
* Projects that want more pre-warming can list explicit `warmFiles` (#203).
*/
async function igniteDominantLanguageWarm(cwd, runtime, sessionGeneration, dbg) {
try {
await initLSPConfig(cwd);
if (!runtime.isCurrentSession(sessionGeneration))
return;
const lspService = getLSPService();
const { collectSourceFilesAsync } = await import("./source-filter.js");
const { detectFileKind } = await import("./file-kinds.js");
// Async, event-loop-yielding walk (deferred off the interactive path).
// inspectGeneratedHeaders:false keeps the walk to directory reads only — no
// per-file content opens — so we never hold a file handle (cheaper, and it
// can't collide with concurrent fs teardown). Picking a representative file
// doesn't need generated-banner filtering.
const files = await collectSourceFilesAsync(cwd, {
inspectGeneratedHeaders: false,
});
if (!runtime.isCurrentSession(sessionGeneration))
return;
// Rank languages by source-file count. Computed here from the scan rather
// than reused from languageProfile.counts, which is left empty on the
// no-warm-caches startup path (detectProjectLanguageProfile is called with
// an empty file list there).
const counts = new Map();
for (const f of files) {
const kind = detectFileKind(f);
if (kind)
counts.set(kind, (counts.get(kind) ?? 0) + 1);
}
if (counts.size === 0) {
dbg("session_start lsp-warm: no detected languages to auto-warm");
return;
}
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
// Walk languages by descending count; warm the first that has both an LSP
// server and a representative on-disk file.
for (const [kind] of ranked) {
const sample = files.find((f) => detectFileKind(f) === kind && lspService.supportsLSP(f));
if (!sample)
continue;
const content = await nodeFs.promises.readFile(sample, "utf-8");
if (!runtime.isCurrentSession(sessionGeneration))
return;
await lspService.touchFile(sample, content, {
diagnostics: "none",
source: "startup-warm-dominant",
clientScope: "primary",
maxClientWaitMs: 2000,
});
dbg(`session_start lsp-warm: dominant=${kind} via ${path.basename(sample)}`);
return;
}
dbg("session_start lsp-warm: no dominant-language LSP file found to warm");
}
catch (err) {
dbg(`session_start lsp-warm: dominant warm error: ${err}`);
}
}
function firePreinstallDefaults(ensureTool, dbg, startupDefaults) {
for (const tool of startupDefaults) {
const startedAt = Date.now();
dbg(`session_start preinstall ${tool}: start`);
ensureTool(tool)
.then((toolPath) => {
if (toolPath) {
dbg(`session_start: ${tool} ready at ${toolPath}`);
dbg(`session_start preinstall ${tool}: success (${Date.now() - startedAt}ms)`);
}
else {
dbg(`session_start: ${tool} installation unavailable`);
dbg(`session_start preinstall ${tool}: unavailable (${Date.now() - startedAt}ms)`);
}
})
.catch((err) => {
dbg(`session_start: ${tool} pre-install error: ${err}`);
dbg(`session_start preinstall ${tool}: error (${Date.now() - startedAt}ms)`);
});
}
}
async function probePrettierInstall(ensureTool, dbg, analysisRoot) {
const pkgPath = path.join(analysisRoot, "package.json");
try {
const raw = await nodeFs.promises.readFile(pkgPath, "utf-8");
const pkg = JSON.parse(raw);
const usesPrettier = !!pkg.devDependencies?.prettier ||
!!pkg.dependencies?.prettier ||
pkg.prettier !== undefined;
if (usesPrettier) {
dbg("session_start: project uses prettier, ensuring install...");
ensureTool("prettier")
.then((p) => {
if (p)
dbg(`session_start: prettier ready at ${p}`);
else
dbg("session_start: prettier install failed silently");
})
.catch((err) => dbg(`session_start: prettier install error: ${err}`));
}
}
catch {
// no package.json at cwd root
}
}
/** Scan one file via the per-file API, pushing any items. Tolerates an
* unreadable file and a scanner without `scanFile` (no-op). */
function scanOneTodoFile(scanner, filePath, items) {
if (typeof scanner.scanFile !== "function")
return;
try {
// scanFile returns TodoItem[] directly (not a { items } result).
const result = scanner.scanFile(filePath);
if (Array.isArray(result))
items.push(...result);
}
catch {
// Per-file error: skip and continue (matches scanDirectory's tolerance).
}
}
/** Collect the TODO baseline without blocking: enumerate source files and scan
* them per-file, yielding to the event loop every 30 files. Falls back to the
* blocking `scanDirectory` if the chunked path can't run (import error or a
* scanner without `scanFile`). */
async function collectTodoBaselineItems(scanner, analysisRoot, stillCurrent) {
const items = [];
try {
const { getSourceFilesAsync } = await import("./scan-utils.js");
// Enumerate with the chunked-yield walker so the file collection itself
// (the previously-synchronous ~1.5s burst on a 2k-file tree) no longer
// blocks the event loop before the per-file scan loop below even starts.
const files = await getSourceFilesAsync(analysisRoot, true);
if (!stillCurrent())
return items;
let processedSinceYield = 0;
for (const file of files) {
if (!stillCurrent())
return items;
scanOneTodoFile(scanner, file, items);
if (++processedSinceYield % 30 === 0) {
await new Promise((resolve) => setImmediate(resolve));
}
}
}
catch {
const todoResult = scanner.scanDirectory(analysisRoot);
items.push(...todoResult.items);
}
return items;
}
// word-index — identifier inverted index + BM25 for ranked symbol search
// (#162). Shared load -> rebuild-if-stale -> persist lifecycle (#348), the same
// shape the call-graph task uses: reuse a fresh persisted index when the
// project `seq` hasn't moved since it was built, otherwise do a bounded
// rebuild and persist. Called from the full-mode background task AND the
// quick-mode cold-start warmup pass (below) so every startup mode ends up
// with a queryable index once per session, off the hot path.
async function buildOrRefreshWordIndex(args) {
const { runtime, sessionGeneration, analysisRoot, snapshotRoot, dbg } = args;
if (!runtime.isCurrentSession(sessionGeneration))
return;
const startMs = Date.now();
const latestSeq = readLatestProjectSequence(snapshotRoot);
const effectiveSeq = runtime.projectSeq ?? latestSeq.projectSeq;
const snapshot = loadProjectSnapshot(snapshotRoot);
if (isProjectSnapshotFresh(snapshot, effectiveSeq) && snapshot.wordIndex) {
const { deserializeWordIndex } = await import("./word-index.js");
const index = deserializeWordIndex(snapshot.wordIndex);
if (index) {
runtime.wordIndex = index;
dbg(`session_start word-index: reused fresh snapshot (seq=${effectiveSeq}, ${index.docCount} files, ${Date.now() - startMs}ms)`);
return;
}
}
// Shared file-walk-and-read helper (#348): the ONE collectWordIndexDocs
// implementation backs this task, the quick-mode warmup call below, AND the
// stateless cold-query background trigger in word-index.ts — a bound/skip
// -rule change lands once, not in three copies.
const { buildWordIndex, collectWordIndexDocs } = await import("./word-index.js");
const docs = await collectWordIndexDocs(analysisRoot, () => runtime.isCurrentSession(sessionGeneration));
if (!runtime.isCurrentSession(sessionGeneration))
return;
runtime.wordIndex = buildWordIndex(docs);
saveRuntimeProjectSnapshot({ cwd: snapshotRoot, runtime, dbg });
dbg(`session_start word-index: rebuilt (absent/stale, seq=${effectiveSeq}) ` +
`${runtime.wordIndex.docCount} files, ${runtime.wordIndex.postings.size} tokens (${Date.now() - startMs}ms)`);
}
// Fire off heavy scans as background tasks — don't block session start.
// Each consumer already handles the "not ready yet" case gracefully
// (cachedExports.size > 0, cache miss paths).
function scheduleStartupScans(deps, runtime, sessionGeneration, analysisRoot, snapshotRoot, languageProfile, dbg) {
const { todoScanner, cacheManager, knipClient, jscpdClient, deadCodeClients, govulncheckClient, gitleaksClient, trivyClient, opengrepClient, astGrepClient, depChecker, } = deps;
// Some background scans are CPU-heavy and arrive on the event loop
// just as the user is most likely typing (right after /new). Defer
// those by a few seconds so the perceptible 50-100ms sync bursts they
// contain land during LLM streaming idle time instead. All other
// tasks (those not listed here) run on the next `setImmediate` tick
// as before. The delays are deliberately staggered (200ms apart) so
// two heavy tasks don't both run on the same macrotask.
const taskDeferMsByName = {
"call-graph": 5000,
"codebase-model": 5200,
"ast-grep exports": 5400,
"project index": 5400,
};
const runTask = (name, task) => {
const queuedAt = Date.now();
dbg(`session_start task ${name}: scheduled`);
runtime.markStartupScanInFlight(name, sessionGeneration);
const fire = () => {
const startedAt = Date.now();
dbg(`session_start task ${name}: start queuedMs=${startedAt - queuedAt}`);
void task()
.then(() => {
dbg(`session_start task ${name}: success runMs=${Date.now() - startedAt} queuedMs=${startedAt - queuedAt}`);
})
.catch((err) => {
dbg(`session_start: ${name} background scan failed: ${err}`);
dbg(`session_start task ${name}: failed runMs=${Date.now() - startedAt} queuedMs=${startedAt - queuedAt}`);
})
.finally(() => {
runtime.clearStartupScanInFlight(name, sessionGeneration);
dbg(`session_start task ${name}: end`);
});
};
const delay = taskDeferMsByName[name] ?? 0;
if (delay > 0) {
setTimeout(fire, delay);
}
else {
setImmediate(fire);
}
};
const canRunJsTsHeavyScans = canRunStartupHeavyScans(languageProfile, "jsts");
const scanNames = ["todo", "dead-code"];
if (canRunJsTsHeavyScans) {
scanNames.push("knip", "jscpd", "ast-grep exports", "project index");
}
dbg(`session_start: launching background scans (${scanNames.join(", ")})`);
runTask("todo", async () => {
if (!runtime.isCurrentSession(sessionGeneration))
return;
// The original implementation called todoScanner.scanDirectory(), which
// walks the project synchronously and freezes the TUI for ~3s on a 2k-file
// project. collectTodoBaselineItems re-implements the walk in async chunks
// (per-file scan, yielding every 30 files), falling back to scanDirectory.
const items = await collectTodoBaselineItems(todoScanner, analysisRoot, () => runtime.isCurrentSession(sessionGeneration));
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg(`session_start TODO scan: ${items.length} items (baseline stored)`);
cacheManager.writeCache("todo-baseline", { items }, analysisRoot);
});
if (!canRunJsTsHeavyScans) {
dbg("session_start: skipping JS/TS startup scans (requires JS/TS language + project config)");
return;
}
// #462: knip/jscpd/madge/dead-code/govulncheck/gitleaks/trivy/opengrep each spawn an
// external CLI that walks the whole project tree on its own — a walk we
// don't control or get to route to an async collector. On a measured-slow
// filesystem that walk reproduces the exact multi-second freeze this
// feature exists to prevent, so skip exactly those seven with a visible
// reason instead of leaving the agent to read an empty/stale cache as
// "clean". The other scans in this function (todo above; call-graph/
// codebase-model/ast-grep-exports/word-index below) walk via
// `collectSourceFilesAsync` or build from cached review-graph data, so
// they stay on.
//
// #449 slice 0: the same gate also fires inside a nicobailon/pi-subagents
// child `pi` process (`PI_SUBAGENT_CHILD=1`) — a fan-out of N subagents in
// the same cwd otherwise pays N full heavyweight-scan fleets for
// short-lived task agents that rarely consult them. In-process scans stay
// on for the same reason as slow-FS: the subagent may still use symbol
// search / word-index.
const isSubagent = isSubagentSession();
const skipHeavyweightScans = isSlowFs(analysisRoot) || isSubagent;
const runHeavyweightTask = (name, task) => {
if (skipHeavyweightScans)
return;
runTask(name, task);
};
if (skipHeavyweightScans) {
dbg(`session_start: skipping knip/jscpd/madge/dead-code/govulncheck/gitleaks/trivy/opengrep (${isSubagent ? "subagent" : "slow-fs"})`);
deps.notify(`⏭️ Skipped background code-quality scans (knip/jscpd/madge/dead-code/govulncheck/gitleaks/trivy/opengrep): ${isSubagent ? subagentLightModeNotice() : slowFsDegradationNotice()}`, "info");
}
// Knip — dead code / unused exports
runHeavyweightTask("knip", async () => {
if (!runtime.isCurrentSession(sessionGeneration))
return;
const cached = cacheManager.readCache("knip", analysisRoot);
if (cached) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg(`session_start Knip: cache hit (${Math.round((Date.now() - new Date(cached.meta.timestamp).getTime()) / 1000)}s ago)`);
}
else {
// KnipClient skips before probing/installing when analysisRoot is not a real
// JS/Knip project. This avoids running knip from Unity/C#/generic repos.
const startMs = Date.now();
const knipResult = await knipClient.analyze(analysisRoot, getKnipIgnorePatterns());
if (!runtime.isCurrentSession(sessionGeneration))
return;
cacheManager.writeCache("knip", knipResult, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
dbg(`session_start Knip scan done (${Date.now() - startMs}ms)`);
}
});
// jscpd — duplicate code detection
runHeavyweightTask("jscpd", async () => {
if (await jscpdClient.ensureAvailable()) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
// Detect TS projects by tsconfig.json at the analysis root. When
// set, JscpdClient.scan adds **/*.js and **/*.jsx to its ignore
// pattern so compiled artifacts under dist/ aren't flagged as
// duplicates of their TypeScript sources (closes #126's latent
// dist/-as-duplicate bug). Cache scanner key varies by this flag
// so a stale cache built with the wrong setting invalidates on
// first read.
const isTsProject = nodeFs.existsSync(path.join(analysisRoot, "tsconfig.json"));
const scannerKey = isTsProject ? "jscpd-ts" : "jscpd";
const cached = cacheManager.readCache(scannerKey, analysisRoot);
if (cached) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg(`session_start jscpd: cache hit (${scannerKey})`);
}
else {
const startMs = Date.now();
const jscpdResult = await jscpdClient.scan(analysisRoot, undefined, undefined, isTsProject);
if (!runtime.isCurrentSession(sessionGeneration))
return;
cacheManager.writeCache(scannerKey, jscpdResult, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
dbg(`session_start jscpd scan done (${Date.now() - startMs}ms, isTsProject=${isTsProject})`);
}
}
else {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg("session_start jscpd: not available");
}
});
// dead-code — cross-file dead-code for non-JS/TS languages (#127). Each
// client self-gates via detect() (a cheap fs marker probe), so only a
// matching-language project incurs the whole-tree scan cost. Knip remains
// the JS/TS path (above); these run alongside it for polyglot repos.
runHeavyweightTask("dead-code", async () => {
const applicable = deadCodeClients.filter((c) => c.detect(analysisRoot));
if (applicable.length === 0)
return;
await Promise.all(applicable.map(async (client) => {
if (!runtime.isCurrentSession(sessionGeneration))
return undefined;
const cacheKey = `dead-code-${client.id}`;
const cached = cacheManager.readCache(cacheKey, analysisRoot);
if (cached) {
dbg(`session_start dead-code(${client.id}): cache hit`);
return undefined;
}
const startMs = Date.now();
const result = await client.analyze(analysisRoot);
if (!runtime.isCurrentSession(sessionGeneration))
return undefined;
cacheManager.writeCache(cacheKey, result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
logDeadCodeScan({
language: client.language,
success: result.success,
cached: false,
unusedExports: result.unusedExports.length,
unusedFiles: result.unusedFiles.length,
unusedDeps: result.unusedDeps.length,
unlistedDeps: result.unlistedDeps.length,
durationMs: result.durationMs ?? Date.now() - startMs,
...(!result.success && { reason: result.summary }),
});
dbg(`session_start dead-code(${client.id}) done (${Date.now() - startMs}ms, ${deadCodeIssueCount(result)} issues)`);
}));
});
// govulncheck — Go module CVE detection (#132)
// Skipped silently when the project isn't a Go module or when
// `govulncheck` isn't installed (no auto-install in this slice).
runHeavyweightTask("govulncheck", async () => {
if (!GovulncheckClient.hasGoModule(analysisRoot)) {
dbg("session_start govulncheck: no go.mod — skipped");
return;
}
if (!(await govulncheckClient.ensureAvailable())) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg("session_start govulncheck: not installed (go install golang.org/x/vuln/cmd/govulncheck@latest)");
return;
}
if (!runtime.isCurrentSession(sessionGeneration))
return;
const cached = cacheManager.readCache("govulncheck", analysisRoot);
if (cached) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg(`session_start govulncheck: cache hit (${cached.data.findings.length} findings)`);
return;
}
const startMs = Date.now();
const result = await govulncheckClient.analyze(analysisRoot);
if (!runtime.isCurrentSession(sessionGeneration))
return;
cacheManager.writeCache("govulncheck", result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
dbg(`session_start govulncheck: ${result.findings.length} reachable findings (${Date.now() - startMs}ms)`);
});
// gitleaks — committed-secrets detection (#130)
// Config-gated: opts in via .gitleaks.toml / .gitleaksignore / git
// hook / gitleaks dep. Cross-language by design.
runHeavyweightTask("gitleaks", async () => {
if (!GitleaksClient.hasGitleaksSignal(analysisRoot)) {
dbg("session_start gitleaks: no opt-in signal — skipped");
return;
}
if (!(await gitleaksClient.ensureAvailable())) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg("session_start gitleaks: not available (install failed?)");
return;
}
if (!runtime.isCurrentSession(sessionGeneration))
return;
const cached = cacheManager.readCache("gitleaks", analysisRoot);
if (cached) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg(`session_start gitleaks: cache hit (${cached.data.findings.length} findings)`);
return;
}
const startMs = Date.now();
const result = await gitleaksClient.scan(analysisRoot);
if (!runtime.isCurrentSession(sessionGeneration))
return;
cacheManager.writeCache("gitleaks", result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
dbg(`session_start gitleaks: ${result.findings.length} findings (${Date.now() - startMs}ms)`);
});
// opengrep — full-workspace security/quality findings via a single CLI scan
// (#584). Structurally always-on (mirrors the LSP auxiliary's own
// enablement — see `OpengrepClient.resolveConfig`): the local rule file or
// `auto` registry ruleset always runs, `resolveOpengrepConfig` only picks
// which. Replaces the per-file LSP sweep as the source of opengrep
// findings for `lens_diagnostics mode=full` (`runWorkspaceDiagnostics` now
// excludes the opengrep server from its per-file "all"-scope touch —
// clients/lsp/index.ts `WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`); the per-edit
// real-time LSP path is untouched.
runHeavyweightTask("opengrep", async () => {
if (!(await opengrepClient.ensureAvailable())) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg("session_start opengrep: not available (install failed?)");
return;
}
if (!runtime.isCurrentSession(sessionGeneration))
return;
const cached = cacheManager.readCache("opengrep", analysisRoot);
if (cached) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg(`session_start opengrep: cache hit (${cached.data.findings.length} findings)`);
return;
}
const startMs = Date.now();
const result = await opengrepClient.scan(analysisRoot);
if (!runtime.isCurrentSession(sessionGeneration))
return;
cacheManager.writeCache("opengrep", result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
dbg(`session_start opengrep: ${result.findings.length} findings (${Date.now() - startMs}ms)`);
});
// madge — whole-project circular-dependency detection. Session-start + cached
// (uniform with knip/jscpd/gitleaks) so lens_diagnostics mode=full reads it
// from the `madge` cache via the extractor registry — never a fresh scan.
runHeavyweightTask("madge", async () => {
if (!(await depChecker.ensureAvailable())) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg("session_start madge: not available");
return;
}
if (!runtime.isCurrentSession(sessionGeneration))
return;
const cached = cacheManager.readCache("madge", analysisRoot);
if (cached) {
dbg(`session_start madge: cache hit (${cached.data.circular.length} cycles)`);
return;
}
const startMs = Date.now();
const result = await depChecker.scanProject(analysisRoot);
if (!runtime.isCurrentSession(sessionGeneration))
return;
cacheManager.writeCache("madge", result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
dbg(`session_start madge: ${result.circular.length} circular dependency chain(s) (${Date.now() - startMs}ms)`);
});
// trivy — dependency CVE detection (#131, Phase 1)
// Explicit opt-in: `trivy.enabled: true` in .pi-lens.json AND a dependency
// manifest present. The first run downloads Trivy's vuln DB (~30-200 MB);
// harmless here since this whole task runs in the background session_start
// wrapper.
runHeavyweightTask("trivy", async () => {
if (!TrivyClient.shouldScan(analysisRoot)) {
dbg("session_start trivy: not enabled / no dependency manifest — skipped");
return;
}
if (!(await trivyClient.ensureAvailable())) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg("session_start trivy: not available (install failed?)");
return;
}
if (!runtime.isCurrentSession(sessionGeneration))
return;
const cached = cacheManager.readCache("trivy", analysisRoot);
if (cached) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg(`session_start trivy: cache hit (${cached.data.findings.length} findings)`);
return;
}
const startMs = Date.now();
const result = await trivyClient.scan(analysisRoot);
if (!runtime.isCurrentSession(sessionGeneration))
return;
cacheManager.writeCache("trivy", result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
dbg(`session_start trivy: ${result.findings.length} CVE findings (${Date.now() - startMs}ms)`);
});
// call-graph — build function-level call graph from review graph data
runTask("call-graph", async () => {
const { FactStore } = await import("./dispatch/fact-store.js");
const { buildOrUpdateGraph, extractSymbolsAndRefsFromGraph, isReviewGraphMigrationNeeded, } = await import("./review-graph/builder.js");
const { buildCallGraph, saveCallGraph, loadCallGraph, staleFiles, readMtimes, } = await import("./call-graph.js");
if (!runtime.isCurrentSession(sessionGeneration))
return;
const startMs = Date.now();
// Try loading from cache first
const cached = loadCallGraph(snapshotRoot);
if (cached) {
const cachedFiles = [...cached.fileMtimes.keys()];
const stale = staleFiles(cached.fileMtimes, cachedFiles);
// #260: a stale REVIEW-graph version must force a rebuild even when the
// (separate) call-graph cache is fresh — otherwise an upgrade that
// invalidated the persisted graph (e.g. v2→v3 test exclusion) leaves
// reads cold until the next edit. The version check is cheap (file head).
if (stale.length === 0 &&
cachedFiles.length > 0 &&
!isReviewGraphMigrationNeeded(analysisRoot)) {
runtime.callGraph = cached.graph;
dbg(`session_start call-graph: loaded from cache (${cached.graph.edges.length} edges, ${Date.now() - startMs}ms)`);
return;
}
}
// Build from the review graph (reuses already-parsed data, no re-parse)
const sessionFacts = new FactStore();
const graph = await buildOrUpdateGraph(analysisRoot, [], sessionFacts);
if (!runtime.isCurrentSession(sessionGeneration))
return;
const { allSymbols, allRefs } = extractSymbolsAndRefsFromGraph(graph);
const callGraph = buildCallGraph(allSymbols, allRefs);
runtime.callGraph = callGraph;
const mtimes = readMtimes([...allSymbols.keys()]);
saveCallGraph(snapshotRoot, callGraph, mtimes);
dbg(`session_start call-graph: built ${callGraph.edges.length} edges, ${callGraph.callers.size} callee entries (${Date.now() - startMs}ms)`);
});
// codebase-model — build mental model from call graph (internal-only until validated)
runTask("codebase-model", async () => {
if (!runtime.isCurrentSession(sessionGeneration))
return;
if (!runtime.callGraph)
return; // call-graph task may not have completed yet
const { buildCodebaseModel, saveCodebaseModel } = await import("./codebase-model.js");
const model = buildCodebaseModel(runtime.callGraph, analysisRoot);
saveCodebaseModel(snapshotRoot, model);
const top3 = model.entries
.slice(0, 3)
.map((e) => e.name)
.join(", ");
dbg(`session_start codebase-model: ${model.entries.length} entries, ` +
`${model.totalTokens} tokens, top symbols: ${top3 || "(none)"}`);
});
// ast-grep — export scan for duplicate detection
runTask("ast-grep-exports", async () => {
if (await astGrepClient.ensureAvailable()) {
if (!runtime.isCurrentSession(sessionGeneration))
return;
const exports = await astGrepClient.scanExports(analysisRoot, "typescript");
if (!runtime.isCurrentSession(sessionGeneration))
return;
dbg(`session_start exports scan: ${exports.size} functions found`);
for (const [name, file] of exports) {
runtime.cachedExports.set(name, file);
}
saveRuntimeProjectSnapshot({ cwd: snapshotRoot, runtime, dbg });
}
});
// word-index — identifier inverted index + BM25 for ranked symbol search
// (#162). Load -> rebuild-if-stale -> persist lifecycle (#348), shared with
// the quick-mode cold-start warmup pass below.
runTask("word-index", async () => {
await buildOrRefreshWordIndex({
runtime,
sessionGeneration,
analysisRoot,
snapshotRoot,
dbg,
});
});
}
function scheduleDeferredToolProbes(deps, languageProfile, startupDefaults, startupScansWillRun, dbg) {
const { biomeClient, ruffClient, depChecker } = deps;
const defaultTools = new Set(startupDefaults);
const probes = [];
// Do not probe tools already covered by startup preinstall or startup scans.
// This keeps session_start logs from showing duplicate "ensure X: start" lines
// while preserving lazy checks for tools that are actually relevant.
if (languageProfile.present.jsts && !defaultTools.has("biome")) {
probes.push(["biome", () => biomeClient.ensureAvailable()]);
}
if (languageProfile.present.python && !defaultTools.has("ruff")) {
probes.push(["ruff", () => ruffClient.ensureAvailable()]);
}
if (startupScansWillRun) {
probes.push(["madge", () => depChecker.ensureAvailable()]);
}
if (probes.length === 0) {
dbg("session_start tools: no deferred availability probes needed");
return;
}
void (async () => {
const warmStart = Date.now();
const results = await Promise.all(probes.map(async ([name, run]) => {
try {
return [name, await run()];
}
catch (err) {
dbg(`session_start: ${name} availability check failed: ${err}`);
return [name, false];
}
}));
const summary = results
.map(([name, ready]) => `${name}=${ready}`)
.join(" ");
dbg(`session_start tools (deferred probes complete, ${Date.now() - warmStart}ms): ${summary}`);
})();
}
/**
* Session-start orientation prepended as a context message (gated by the
* context-injection toggle). Deliberately lean: it names the high-value tools
* and the one non-obvious behaviour (mode=all resurfaces stale blocking errors)
* — per-tool argument detail lives in each tool's own registered description, so
* re-documenting it here would just pay the tokens twice every session.
*/
export const SESSION_START_GUIDANCE = [
"📌 pi-lens active — automated checks run on every edit/write; blocking errors (including pre-existing) show inline and must be fixed.\n" +
"Key tools (see each tool's own description for args):\n" +
"• lens_diagnostics — session-wide diagnostic state; mode=all resurfaces stale blocking errors that dropped from turn context.\n" +
"• symbol_search → module_report → read_symbol/read_enclosing — ranked identifier search, then navigable outline/callback handles + exact body reads; cheaper than reading a whole file before editing.\n" +
"• lsp_diagnostics — probe LSP for errors in a file/folder/workspace.\n" +
"• Situational (activate via pi_lens_activate_tools): lsp_navigation, ast_grep_search, ast_grep_replace, ast_grep_dump.",
];
export async function handleSessionStart(deps) {
const sessionStartMs = Date.now();
// Cold-start input-latency mitigation. The first `session_start` of
// the process — i.e. the one that fires immediately after the user
// launches `pi` — must return as fast as possible so the TUI input
// box becomes responsive. The full startup mode runs several
// expensive synchronous walks (resolveStartupScanContext,
// detectProjectLanguageProfile, scanProjectRules, scheduleStartupScans)
// that together can block the event loop for 3-6s on a 2k-file
// project, during which keystrokes are dropped or batched.
//
// Strategy:
// - Force the very first invocation to "quick" mode, which exits
// after a minimal runtime reset and snapshot hydration.
// - 2 seconds later, schedule a background warmup that walks the
// project asynchronously (yielding every 100 entries) and
// populates the in-process memo caches
// (startupScanContextCache + languageProfileCache).
// - The user's first /new (or any subsequent session_start) sees
// a cache hit on both walks and finishes the full path in <50ms.
//
// Opt-out: PI_LENS_COLD_START_QUICK=0 disables this behaviour.
// Override: PI_LENS_STARTUP_MODE explicitly set wins (we honour it).
// Tunable: PI_LENS_WARMUP_DELAY_MS adjusts the warmup delay.
let startupMode = resolveStartupMode();
const processGlobals = globalThis;
const isFirstSessionOfProcess = !processGlobals.__piLensFirstSessionDone;
if (isFirstSessionOfProcess &&
process.env.PI_LENS_COLD_START_QUICK !== "0" &&
!process.env.PI_LENS_STARTUP_MODE) {
startupMode = "quick";
}
processGlobals.__piLensFirstSessionDone = true;
if (startupMode === "quick" &&
process.env.PI_LENS_COLD_START_QUICK !== "0" &&
!processGlobals.__piLensWarmupScheduled) {
processGlobals.__piLensWarmupScheduled = true;
const warmupDelayMs = Number(process.env.PI_LENS_WARMUP_DELAY_MS ?? 2000);
const warmupCwd = deps.ctxCwd ?? process.cwd();
const warmupDbg = deps.dbg;
setTimeout(() => {
const warmupStartedAt = Date.now();
void (async () => {
try {
warmupDbg("warmup: starting background warmup");
// Dynamic imports keep the warmup pipeline off the hot
// startup path — these modules don't load until the timer
// fires, well after the TUI is interactive.
const startupScanModule = await import("./startup-scan.js");
const languageProfileModule = await import("./language-profile.js");
const scan = await startupScanModule.resolveStartupScanContextAsync(warmupCwd);
warmupDbg(`warmup: scan-context done in ${Date.now() - warmupStartedAt}ms (canWarm=${scan.canWarmCaches})`);
// Respect the startup-scan guard (#250): canWarmCaches is false for
// home-dir / no-project-root / too-many-source-files. Proceeding into
// the language-profile source walk in those cases lets it root at an
// ancestor (e.g. a marker in $HOME when pi runs in ~/tmp) and traverse
// the entire home tree — multi-hour scans. Nothing to warm anyway when
// the guard says caches can't be warmed.
if (!scan.canWarmCaches) {
warmupDbg(`warmup: skipping language-profile (canWarm=false, reason=${scan.reason ?? "unknown"})`);
return;
}
const languageRoot = scan.projectRoot ?? warmupCwd;
const languageProfileStartedAt = Date.now();
await languageProfileModule.detectProjectLanguageProfileAsync(languageRoot);
warmupDbg(`warmup: language-profile done in ${Date.now() - languageProfileStartedAt}ms`);
// #348: fold the word-index build/refresh into this existing
// cold-start warmup pass so quick-mode (and any session whose very
// first session_start is forced quick) still ends up with a
// queryable index — full-mode sessions get it via the "word-index"
// runTask above; this is the quick-mode equivalent, once per
// process, off the hot path.
const wordIndexStartedAt = Date.now();
const warmupSnapshotRoot = resolveSnapshotRoot(warmupCwd);
await buildOrRefreshWordIndex({
runtime: deps.runtime,
sessionGeneration: deps.runtime.sessionGeneration,
analysisRoot: languageRoot,
snapshotRoot: warmupSnapshotRoot,
dbg: warmupDbg,
});
warmupDbg(`warmup: word-index done in ${Date.now() - wordIndexStartedAt}ms`);
warmupDbg(`warmup: total ${Date.now() - warmupStartedAt}ms`);
}
catch (err) {
warmupDbg(`warmup: error ${err}`);
// Allow a future session to retry the warmup.
processGlobals.__piLensWarmupScheduled = false;
}
})();
}, warmupDelayMs);
}
const allowBootstrapTasks = startupMode === "full";
const quickMode = startupMode === "quick";
const { ctxCwd, getFlag, notify, dbg, log, runtime, metricsClient, cacheManager, testRunnerClient, goClient, rustClient, ensureTool, cleanStaleTsBuildInfo, resetDispatchBaselines, resetLSPService, } = deps;
// Lightweight phase timer — resets after each call so each log line shows
// the cost of that phase alone, not cumulative time from session start.
let _phaseT = Date.now();
const phase = (name) => {
dbg(`session_start phase ${name}: ${Date.now() - _phaseT}ms`);
_phaseT = Date.now();
};
metricsClient.reset();
getDiagnosticTracker().reset();
clearFileTimeSessions();
runtime.complexityBaselines.clear();
resetDispatchBaselines(ctxCwd);
runtime.resetForSession();
// Run log cleanup early in session start (non-blocking)
const logCleanup = runLogCleanup(dbg);
if (logCleanup.cleaned > 0 || logCleanup.rotated > 0) {
notify(`🧹 ${logCleanup.report}`, "info");
}
dbg(`session_start startup mode: ${startupMode}`);
if (!getFlag("no-lsp")) {
resetLSPService({ fast: true });
dbg("session_start: LSP service reset");
dbg("session_start: phase0 workspace diagnostics observation enabled (capability probe only)");
}
const hasWorkspaceCwd = typeof ctxCwd === "string" && ctxCwd.length > 0;
const cwd = ctxCwd ?? process.cwd();
if (quickMode) {
runtime.projectRoot = cwd;
const snapshotRoot = resolveSnapshotRoot(cwd);
const latestSeq = readLatestProjectSequence(snapshotRoot);
runtime.seedProjectSequence?.(latestSeq.projectSeq, latestSeq.fileSeqByPath);
const effectiveSeq = runtime.projectSeq ?? latestSeq.projectSeq;
dbg(`session_start sequence: projectSeq=${effectiveSeq} fileSeqEntries=${latestSeq.fileSeqByPath.size}`);
const snapshot = loadProjectSnapshot(snapshotRoot);
logProjectSnapshotProbe({
dbg,
root: snapshotRoot,
currentProjectSeq: effectiveSeq,
snapshot,
});
if (isProjectSnapshotFresh(snapshot, effectiveSeq)) {
hydrateRuntimeFromProjectSnapshot(runtime, snapshot);
}
const quickTools = [];
if (!getFlag("no-lsp")) {
quickTools.push("LSP Service");
}
log(`Active tools: ${quickTools.join(", ")}`);
dbg(`session_start tools: ${quickTools.join(", ") || "deferred (quick mode)"}`);
dbg("session_start: quick mode active - skipping slow tool probes, language profiling, preinstall, scans, and error debt baseline");
dbg(`session_start total: ${Date.now() - sessionStartMs}ms (interactive path)`);
return;
}
const tools = [];
if (!getFlag("no-lsp"))
tools.push("LSP Service");
if (allowBootstrapTasks && !getFlag("no-lsp")) {
const cleaned = cleanStaleTsBuildInfo(ctxCwd ?? process.cwd());
if (cleaned.length > 0) {
notify(`🧹 Deleted stale TypeScript build cache (${cleaned.map((f) => path.basename(f)).join(", ")}) — phantom errors suppressed.`, "warning");
dbg(`session_start: cleaned stale tsbuildinfo: ${cleaned.join(", ")}`);
}
}
const snapshotRoot = resolveSnapshotRoot(cwd);
const latestSeq = readLatestProjectSequence(snapshotRoot);
runtime.seedProjectSequence?.(latestSeq.projectSeq, latestSeq.fileSeqByPath);
const effectiveSeq = runtime.projectSeq ?? latestSeq.projectSeq;
dbg(`session_start sequence: projectSeq=${effectiveSeq} fileSeqEntries=${latestSeq.fileSeqByPath.size}`);
const snapshot = loadProjectSnapshot(snapshotRoot);
logProjectSnapshotProbe({
dbg,
root: snapshotRoot,
currentProjectSeq: effectiveSeq,
snapshot,
});
const freshSnapshot = isProjectSnapshotFresh(snapshot, effectiveSeq)
? snapshot
: null;
if (freshSnapshot) {
hydrateRuntimeFromProjectSnapshot(runtime, freshSnapshot);
}
const startupScanSource = freshSnapshot?.startupScan
? "snapshot"
: "computed";
const startupScan = freshSnapshot?.startupScan
? { ...freshSnapshot.startupScan, cwd: path.resolve(cwd) }
: resolveStartupScanContext(cwd);
phase("scan-context");
dbg(`session_start scan-context source=${startupScanSource}`);
const scanRoot = startupScan.projectRoot ?? cwd;
const useScanRootForSignals = startupScan.canWarmCaches || startupScan.reason === "too-many-source-files";
const analysisRoot = useScanRootForSignals ? scanRoot : cwd;
runtime.projectRoot = cwd;
const languageProfileSource = freshSnapshot?.languageProfile
? "snapshot"
: "computed";
const languageProfile = freshSnapshot?.languageProfile
? freshSnapshot.languageProfile
: detectProjectLanguageProfile(analysisRoot, startupScan.canWarmCaches ? undefined : []);
phase("language-profile");
dbg(`session_start language-profile source=${languageProfileSource}`);
dbg(`session_start cwd: ${cwd}`);
dbg(`session_start scan root: ${scanRoot} (warmCaches=${startupScan.canWarmCaches}${startupScan.reason ? `, reason=${startupScan.reason}` : ""})`);
dbg(`session_start analysis root: ${analysisRoot}`);
dbg(`session_start workspace root: ${runtime.projectRoot}`);
dbg(`session_start language profile: ${languageProfile.detectedKinds.join(", ") || "none"}`);
dbg(`session_start language counts: ${JSON.stringify(languageProfile.counts)} configured=${JSON.stringify(languageProfile.configured)}`);
dbg(`session_start workspace cwd available: ${hasWorkspaceCwd}`);
if (useScanRootForSignals && analysisRoot !== cwd) {
dbg(`session_start: monorepo analysis root override -> ${analysisRoot}`);
}
// Slow-FS probe (#462): classify the workspace filesystem by measurement
// (median fs.statSync cost) before any tree walk runs. WSL 9p mounts cost
// ~1.3ms/stat vs ~17µs native — a 75x slowdown that turns a 5,000-file sync
// walk into a multi-second TUI freeze. Logged to the latency log so
// dogfooding can see the verdict; a visible notice fires when engaged so a
// degraded scan is never mistaken for a silently-empty one.
const slowFsVerdict = getSlowFsVerdict(analysisRoot);
logLatency({
type: "phase",
phase: "slow_fs_probe",
filePath: analysisRoot,
durationMs: 0,
metadata: {
slow: slowFsVerdict.slow,
medianStatMicros: slowFsVerdict.medianStatMicros,
samples: slowFsVerdict.samples,
},
});
dbg(`session_start slow-fs probe: slow=${slowFsVerdict.slow} medianStatMicros=${slowFsVerdict.medianStatMicros.toFixed(1)} samples=${slowFsVerdict.samples}`);
if (slowFsVerdict.slow) {
notify(`🐢 Slow filesystem detected (median ${slowFsVerdict.medianStatMicros.toFixed(0)}µs/stat) — reduced-scan mode engaged (set PI_LENS_ALLOW_SLOW_FS_SCAN=1 to override).`, "warning");
}
// Subagent light mode (#449 slice 0): detected once per session, alongside
// the slow-FS probe above. Logged to the latency log so dogfooding can see
// how often subagent fan-outs engage it and what identity they carry.
const subagentSession = isSubagentSession();
if (subagentSession) {
const identity = getSubagentIdentity();
logLatency({
type: "phase",
phase: "subagent_light_mode",
filePath: analysisRoot,
durationMs: 0,
metadata: {
runId: identity?.runId,
agentName: identity?.agentName,
marker: identity?.marker,
},
});
dbg(`session_start subagent light mode: engaged (marker=${identity?.marker ?? "unknown"} runId=${identity?.runId ?? "unknown"} agentName=${identity?.agentName ?? "unknown"})`);
}
const lensLspEnabled = !getFlag("no-lsp");
const startupDefaults = getDefaultStartupTools(languageProfile).filter((tool) => {
if ((tool === "typescript-language-server" || tool === "pyright") &&
!lensLspEnabled) {
return false;
}
return true;
});
if (!allowBootstrapTasks) {
dbg("session_start: skipping tool preinstall (startup mode)");
}
else if (startupDefaults.length > 0) {
dbg(`session_start: pre-install defaults -> ${startupDefaults.join(", ")}`);
firePreinstallDefaults(ensureTool, dbg, startupDefaults);
}
else {
dbg("session_start: no language defaults selected for pre-install");
}
const startupScansWillRun = allowBootstrapTasks && startupScan.canWarmCaches;
const jstsHeavyScansWillRun = startupScansWillRun && canRunStartupHeavyScans(languageProfile, "jsts");
if (allowBootstrapTasks) {
scheduleDeferredToolProbes(deps, languageProfile, startupDefaults, jstsHeavyScansWillRun, dbg);
}
if (allowBootstrapTasks) {
// Fire-and-forget like other tool probes
void probePrettierInstall(ensureTool, dbg, analysisRoot);
}
else {
dbg("session_start: skipping prettier preinstall probe (startup mode)");
}
const detectedRunner = testRunnerClient.detectRunner(analysisRoot);
phase("test-runner-detect");
if (detectedRunner)
tools.push(`Test runner (${detectedRunner.runner})`);
if (await goClient.isGoAvailableAsync())
tools.push("Go (go vet)");
if (await rustClient.isAvailableAsync())
tools.push("Rust (cargo)");
log(`Active tools: ${tools.join(", ")}`);
dbg(`session_start tools: ${tools.join(", ")}`);
const agentStartupGuidance = SESSION_START_GUIDANCE;
runtime.projectRulesScan = scanProjectRules(analysisRoot);
saveRuntimeProjectSnapshot({
cwd: snapshotRoot,
runtime,
startupScan,
languageProfile,
dbg,
});
phase("project-rules");
if (runtime.projectRulesScan.hasCustomRules) {
const ruleCount = runtime.projectRulesScan.rules.length;
const sources = [
...new Set(runtime.projectRulesScan.rules.map((r) => r.source)),
];
dbg(`session_start: found ${ruleCount} project rule(s) from ${sources.join(", ")}`);
}
else {
dbg("session_start: no project rules found");
}
cacheManager.writeCache("session-start-guidance", { content: agentStartupGuidance.join("\n") }, analysisRoot);
const sessionGeneration = runtime.sessionGeneration;
if (!allowBootstrapTasks) {
dbg("session_start: skipping startup background scans (startup mode)");
}
else if (!startupScan.canWarmCaches) {
dbg(`session_start: skipping heavy scans (${startupScan.reason ?? "unknown"})`);
dbg(`session_start: skipping TODO scan (${startupScan.reason ?? "unknown"})`);
}
else {
scheduleStartupScans(deps, runtime, sessionGeneration, analysisRoot, snapshotRoot, languageProfile, dbg);
}
// LSP warm files — deferred to the next event-loop turn so the config walk
// (several ENOENT readFile calls up the directory tree) never runs on the
// interactive path. setImmediate guarantees handleSessionStart has already
// resolved before loadLSPConfig is even called.
//
// #449 slice 0: skip both the explicit warmFiles warm and the
// dominant-language auto-warm inside a subagent session — a fan-out of N
// subagents otherwise pays N full LSP pre-warms in the same cwd. Per-edit
// LSP dispatch is untouched (see `pipeline.ts`), so a subagent that
// actually edits code still gets diagnostics; it just spawns the server
// lazily on first edit instead of eagerly at session start.
if (subagentSession) {
dbg("session_start lsp-warm: skipping pre-warm (subagent session)");
}
else if (!getFlag("no-lsp") && allowBootstrapTasks) {
setImmediate(() => {
void loadLSPConfig(cwd).then((lspConfig) => {
const warmFiles = lspConfig.warmFiles ?? [];
dbg(`session_start lsp-config: loaded (${warmFiles.length} warm file(s) configured)`);
if (warmFiles.length > 0) {
igniteWarmFiles(cwd, warmFiles, runtime, sessionGeneration, dbg).catch((err) => dbg(`session_start lsp-warm: unhandled error: ${err}`));
}
else if (startupScan.canWarmCaches) {
// No explicit warmFiles — pre-spawn just the dominant language's
// LSP so the first edit doesn't pay the cold-spawn stall (#203).
// Only do the auto-discovery warm on guarded real project roots; on
// home/no-project/too-large roots this source walk can become the same
// delayed background tree scan that the startup-scan guard prevents.
igniteDominantLanguageWarm(analysisRoot, runtime, sessionGeneration, dbg).catch((err) => dbg(`session_start lsp-warm: unhandled dominant error: ${err}`));
}
else {
dbg(`session_start lsp-warm: skipping dominant-language auto-warm (${startupScan.reason ?? "unknown"})`);
}
});
});
phase("lsp-config");
}
setSessionLanguages(languageProfile.detectedKinds);
dbg(`session_start total: ${Date.now() - sessionStartMs}ms (interactive path; background tasks may continue)`);
}