diff --git a/packages/pi-fff/README.md b/packages/pi-fff/README.md index 233b1b98a..bd0846797 100644 --- a/packages/pi-fff/README.md +++ b/packages/pi-fff/README.md @@ -134,7 +134,8 @@ Mode precedence: - `--fff-mode ` — set mode (see above) - `--fff-frecency-db ` — path to frecency database (also: `FFF_FRECENCY_DB` env) - `--fff-history-db ` — path to query history database (also: `FFF_HISTORY_DB` env) -- `--fff-enable-root-scan` — allow indexing when launched from `/` (also: `FFF_ENABLE_ROOT_SCAN=1` env). FFF refuses to init at the filesystem root by default. Home directory scanning is always enabled for pi. +- `--fff-enable-root-scan` — allow indexing when launched from `/` (also: `FFF_ENABLE_ROOT_SCAN=1` env). FFF refuses to init at the filesystem root by default. +- `--fff-enable-home-scan` — index the home directory when launched from `$HOME` (also: `FFF_ENABLE_HOME_SCAN` env). Enabled by default. Disable with `--fff-enable-home-scan=false` or `FFF_ENABLE_HOME_SCAN=0` if your `$HOME` contains huge trees (toolchains, kernel sources, build outputs) that make the background index run for a long time. When launched from `$HOME` with this enabled, pi shows a warning that the whole home tree is being indexed. ## Data diff --git a/packages/pi-fff/src/aux-finders.ts b/packages/pi-fff/src/aux-finders.ts index dd637f002..48a8b20c6 100644 --- a/packages/pi-fff/src/aux-finders.ts +++ b/packages/pi-fff/src/aux-finders.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import type { FileFinderApi } from "@ff-labs/fff-node"; +import { HOME_DIR } from "./paths"; import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk"; export const MAX_AUX = 3; @@ -15,6 +15,9 @@ interface AuxPicker { export interface AuxOpts { enableFsRootScanning: boolean; + enableHomeDirScanning?: boolean; + // Called before a newly spawned aux picker starts a scan that covers $HOME. + onHomeDirScan?: (root: string) => void; } export class AuxFinderPool { @@ -89,6 +92,13 @@ export class AuxFinderPool { this.entries = this.entries.filter((e) => e !== oldest); } + const enableHomeDirScanning = this.opts.enableHomeDirScanning ?? true; + // A fresh picker rooted at (or above) $HOME walks the whole home tree, so + // the user gets told every time the agent spawns one — see issue #743. + if (enableHomeDirScanning && rootCovers(root, HOME_DIR)) { + this.opts.onHomeDirScan?.(root); + } + const { FileFinder } = await loadSdk(); // LMDB env can only be opened once per process; the main finder already // owns the frecency/history DBs. Aux finders are transient and run without @@ -96,7 +106,7 @@ export class AuxFinderPool { const result = FileFinder.create({ basePath: root, aiMode: true, - enableHomeDirScanning: true, + enableHomeDirScanning, enableFsRootScanning: this.opts.enableFsRootScanning, }); if (!result.ok) @@ -171,7 +181,7 @@ export function routePathConstraint( let candidate = pathConstraint.trim(); if (!candidate) return null; if (candidate === "~" || candidate.startsWith("~/")) - candidate = path.join(os.homedir(), candidate.slice(1)); + candidate = path.join(HOME_DIR, candidate.slice(1)); if (!path.isAbsolute(candidate)) { // Plain workspace-relative constraints stay on the workspace finder. if (candidate !== ".." && !candidate.startsWith("../")) return null; diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index f17cd4ed5..608d09fda 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -23,6 +23,7 @@ import type { import { Type } from "@sinclair/typebox"; import { AuxFinderPool, routePathConstraint } from "./aux-finders"; import { buildQuery } from "./query"; +import { isHomeDir } from "./paths"; import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk"; export { SCAN_TIMEOUT_MS } from "./sdk"; @@ -39,6 +40,11 @@ const MENTION_MAX_RESULTS = 20; // If we exceed 10 seconds for indexed grep - something is definitely off const GREP_TIME_BUDGET_MS = 10_000; +const HOME_SCAN_STATUS_KEY = "fff"; +const HOME_SCAN_POLL_MS = 1_000; +const HOME_SCAN_DISABLE_HINT = + "You can prevent home dir indexing with --fff-enable-home-scan=false (or FFF_ENABLE_HOME_SCAN=0)."; + type FffMode = "tools-and-ui" | "tools-only" | "override"; const VALID_MODES: FffMode[] = ["tools-and-ui", "tools-only", "override"]; @@ -313,20 +319,32 @@ export default function fffExtension(pi: ExtensionAPI) { process.env.FFF_HISTORY_DB ?? undefined; - // Root scanning opt-in: flag (boolean) > env ("1"/"true") > false. - // FFF refuses to init at / unless this is set. Home dir scanning is on by - // default for pi — launching pi from $HOME is a normal flow. - function resolveBoolOpt(flagName: string, envName: string): boolean { + // flag (boolean) > env ("1"/"true", or "0"/"false") > default. + function resolveBoolOpt( + flagName: string, + envName: string, + fallback = false, + ): boolean { const flag = pi.getFlag(flagName); if (typeof flag === "boolean") return flag; if (typeof flag === "string") return flag === "true" || flag === "1"; const env = process.env[envName]; - return env === "1" || env === "true"; + if (env === "1" || env === "true") return true; + if (env === "0" || env === "false") return false; + return fallback; } + // Root scanning opt-in: FFF refuses to init at / unless this is set. const enableFsRootScanning = resolveBoolOpt( "fff-enable-root-scan", "FFF_ENABLE_ROOT_SCAN", ); + // Home dir scanning is on by default (launching pi from $HOME is a normal + // flow), but configurable so users with huge $HOME trees can opt out. + const enableHomeDirScanning = resolveBoolOpt( + "fff-enable-home-scan", + "FFF_ENABLE_HOME_SCAN", + true, + ); function getMode(): FffMode { return currentMode; @@ -340,8 +358,27 @@ export default function fffExtension(pi: ExtensionAPI) { return currentMode !== "tools-only"; } + // Set on session_start; the only handle to the UI outside an event handler. + // setStatus is TUI/RPC-only, hence optional. + let uiCtx: { + ui: { + notify: (message: string, type?: "info" | "warning" | "error") => void; + setStatus?: (key: string, text: string | undefined) => void; + }; + } | null = null; + let homeScanTimer: ReturnType | null = null; + + function warnHomeDirScan(root: string): void { + uiCtx?.ui.notify( + `(fff): Your cwd (${root}) is too large. Indexing will take additional time and resources.\n${HOME_SCAN_DISABLE_HINT}`, + "warning", + ); + } + let auxPool = new AuxFinderPool({ enableFsRootScanning, + enableHomeDirScanning, + onHomeDirScan: warnHomeDirScan, }); // in case cwd changes we need to figure this out @@ -364,7 +401,7 @@ export default function fffExtension(pi: ExtensionAPI) { frecencyDbPath, historyDbPath, aiMode: true, - enableHomeDirScanning: true, + enableHomeDirScanning, enableFsRootScanning, }); @@ -382,7 +419,40 @@ export default function fffExtension(pi: ExtensionAPI) { return finderPromise; } + function stopHomeScanStatus(): void { + if (homeScanTimer) { + clearInterval(homeScanTimer); + homeScanTimer = null; + } + uiCtx?.ui.setStatus?.(HOME_SCAN_STATUS_KEY, undefined); + } + + // waitForScan() resolves on timeout too, so the scan can still be running. + // Poll the live progress until it settles, then clear the footer. + function trackHomeScanStatus(): void { + stopHomeScanStatus(); + if (!uiCtx?.ui.setStatus) return; + + const tick = () => { + const progress = mainFinder?.getScanProgress?.(); + if (!progress?.ok || !progress.value.isScanning) { + stopHomeScanStatus(); + return; + } + uiCtx?.ui.setStatus?.( + HOME_SCAN_STATUS_KEY, + `Agent is indexing $HOME (${progress.value.scannedFilesCount} files), this can lead to high CPU`, + ); + }; + + homeScanTimer = setInterval(tick, HOME_SCAN_POLL_MS); + // Must not hold the process open once pi is done. + (homeScanTimer as { unref?: () => void }).unref?.(); + tick(); + } + function destroyFinder() { + stopHomeScanStatus(); if (mainFinder && !mainFinder.isDestroyed) { mainFinder.destroy(); mainFinder = null; @@ -504,9 +574,16 @@ export default function fffExtension(pi: ExtensionAPI) { type: "boolean", }); + pi.registerFlag("fff-enable-home-scan", { + description: + "Index the home dir when launched from $HOME (default true; disable with --fff-enable-home-scan=false or FFF_ENABLE_HOME_SCAN=0)", + type: "boolean", + }); + pi.on("session_start", async (_event, ctx) => { try { activeCwd = ctx.cwd; + uiCtx = ctx as unknown as typeof uiCtx; // Restore persisted mode from session entries. This handles session // resume after process restart where env vars are lost, and ensures @@ -533,6 +610,21 @@ export default function fffExtension(pi: ExtensionAPI) { registerAutocompleteProvider(ctx); await ensureFinder(activeCwd); + + // Warn when launched from $HOME with home scanning on: indexing a large + // home tree can run for a long time in the background (issue #743). + const atHome = enableHomeDirScanning && isHomeDir(activeCwd); + if (atHome) { + warnHomeDirScan(activeCwd); + ctx.ui.setStatus?.( + HOME_SCAN_STATUS_KEY, + "Agent is indexing $HOME, this can lead to high CPU", + ); + } + + // waitForScan() also resolves on timeout, so poll until the scan really + // settles before clearing the footer. + if (atHome) trackHomeScanStatus(); } catch (e: unknown) { ctx.ui.notify( `FFF init failed: ${e instanceof Error ? e.message : String(e)}`, diff --git a/packages/pi-fff/src/paths.ts b/packages/pi-fff/src/paths.ts new file mode 100644 index 000000000..2dce44aee --- /dev/null +++ b/packages/pi-fff/src/paths.ts @@ -0,0 +1,9 @@ +import os from "node:os"; +import path from "node:path"; + +// Resolved once per process: os.homedir() hits the env/passwd on every call. +export const HOME_DIR = path.resolve(os.homedir()); + +export function isHomeDir(dir: string): boolean { + return path.resolve(dir) === HOME_DIR; +} diff --git a/packages/pi-fff/test/aux-pool.test.ts b/packages/pi-fff/test/aux-pool.test.ts index 13110f599..6834356a6 100644 --- a/packages/pi-fff/test/aux-pool.test.ts +++ b/packages/pi-fff/test/aux-pool.test.ts @@ -1,4 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; +import os from "node:os"; +import path from "node:path"; interface MockFinder { isDestroyed: boolean; @@ -40,10 +42,10 @@ mock.module("@ff-labs/fff-bun", () => finderModule); const { AuxFinderPool } = await import("../src/aux-finders"); -function makePool() { +function makePool(opts: Record = {}) { created.length = 0; createOptions.length = 0; - return new AuxFinderPool({ enableFsRootScanning: false }); + return new AuxFinderPool({ enableFsRootScanning: false, ...opts }); } describe("AuxFinderPool covering reuse", () => { @@ -92,6 +94,31 @@ describe("AuxFinderPool covering reuse", () => { expect(created.length).toBe(2); }); + // Regression for #743: the agent spawning an aux picker over $HOME must warn + // the user every time, not silently walk the home tree. + test("notifies on every aux picker that covers $HOME", async () => { + const onHomeDirScan = mock(() => undefined); + const pool = makePool({ onHomeDirScan }); + const home = os.homedir(); + + await pool.acquire(home); + await pool.acquire(path.dirname(home)); + expect(onHomeDirScan.mock.calls).toEqual([[home], [path.dirname(home)]]); + + // Project-scoped roots below $HOME do not walk the whole home tree. + await pool.acquire(path.join(home, "dev", "some-project")); + expect(onHomeDirScan).toHaveBeenCalledTimes(2); + }); + + test("no aux notification when home scanning is disabled", async () => { + const onHomeDirScan = mock(() => undefined); + const pool = makePool({ enableHomeDirScanning: false, onHomeDirScan }); + + await pool.acquire(os.homedir()); + expect(onHomeDirScan).not.toHaveBeenCalled(); + expect(createOptions[0].enableHomeDirScanning).toBe(false); + }); + // Regression for #700: aux finders must not reopen the main frecency/history // LMDB envs, or heed fails with "environment already open in this program". test("aux finders are created without frecency/history db paths", async () => { diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts index c0d351c6e..eb977b9f9 100644 --- a/packages/pi-fff/test/extension.test.ts +++ b/packages/pi-fff/test/extension.test.ts @@ -1,20 +1,35 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; +import os from "node:os"; type MockFinder = { isDestroyed: boolean; waitForScan: ReturnType; mixedSearch: ReturnType; + getScanProgress: ReturnType; destroy: ReturnType; }; const createCalls: unknown[] = []; let finders: MockFinder[] = []; let mixedSearchImpl: ((query: string, options: unknown) => unknown) | undefined; +let scanProgressImpl: (() => unknown) | undefined; function createMockFinder(): MockFinder { return { isDestroyed: false, waitForScan: mock(async () => undefined), + getScanProgress: mock(() => { + if (scanProgressImpl) return scanProgressImpl(); + return { + ok: true, + value: { + scannedFilesCount: 0, + isScanning: false, + isWatcherReady: true, + isWarmupComplete: true, + }, + }; + }), mixedSearch: mock((query: string, options: unknown) => { if (mixedSearchImpl) return mixedSearchImpl(query, options); return { @@ -102,20 +117,21 @@ function createPi(mode?: string) { return { pi, events, commands }; } -function createContext() { +function createContext(cwd = "/tmp/workspace") { return { - cwd: "/tmp/workspace", + cwd, ui: { addAutocompleteProvider: mock(() => undefined), notify: mock(() => undefined), setEditorComponent: mock(() => undefined), + setStatus: mock(() => undefined), }, }; } -async function start(mode?: string) { +async function start(mode?: string, cwd?: string) { const setup = createPi(mode); - const ctx = createContext(); + const ctx = createContext(cwd); fffExtension(setup.pi as any); const sessionStart = setup.events.get("session_start"); @@ -125,6 +141,10 @@ async function start(mode?: string) { return { ...setup, ctx }; } +async function shutdown(setup: { events: Map }) { + await setup.events.get("session_shutdown")?.({}, undefined); +} + function currentProvider( result = { items: [{ value: "base", label: "base" }], prefix: "ba" }, ) { @@ -143,7 +163,71 @@ beforeEach(() => { createCalls.length = 0; finders = []; mixedSearchImpl = undefined; + scanProgressImpl = undefined; delete process.env.PI_FFF_MODE; + delete process.env.FFF_ENABLE_HOME_SCAN; +}); + +// Regression for #743: launching from $HOME must be visible and interruptible. +describe("pi-fff $HOME scan warning", () => { + test("warns and pins a status when cwd is $HOME", async () => { + const setup = await start(undefined, os.homedir()); + + expect(setup.ctx.ui.notify).toHaveBeenCalledTimes(1); + const [message, level] = setup.ctx.ui.notify.mock.calls[0]; + expect(message).toContain(os.homedir()); + expect(level).toBe("warning"); + expect(setup.ctx.ui.setStatus).toHaveBeenCalledWith( + "fff", + "Agent is indexing $HOME, this can lead to high CPU", + ); + await shutdown(setup); + }); + + test("stays silent outside $HOME", async () => { + const { ctx } = await start(); + + expect(ctx.ui.notify).not.toHaveBeenCalled(); + expect(ctx.ui.setStatus).not.toHaveBeenCalled(); + }); + + test("clears the status once the scan settles", async () => { + const setup = await start(undefined, os.homedir()); + + expect(setup.ctx.ui.setStatus).toHaveBeenLastCalledWith("fff", undefined); + await shutdown(setup); + }); + + // waitForScan resolves on timeout, so a slow $HOME walk keeps the footer up. + test("keeps reporting live progress while the scan is still running", async () => { + scanProgressImpl = () => ({ + ok: true, + value: { + scannedFilesCount: 12345, + isScanning: true, + isWatcherReady: false, + isWarmupComplete: false, + }, + }); + const setup = await start(undefined, os.homedir()); + + const [key, text] = setup.ctx.ui.setStatus.mock.calls.at(-1) as [string, string]; + expect(key).toBe("fff"); + expect(text).toContain("12345 files"); + + // session_shutdown must stop the poller and clear the footer. + await shutdown(setup); + expect(setup.ctx.ui.setStatus).toHaveBeenLastCalledWith("fff", undefined); + }); + + test("no warning when home scanning is disabled", async () => { + process.env.FFF_ENABLE_HOME_SCAN = "0"; + const setup = await start(undefined, os.homedir()); + + expect(setup.ctx.ui.notify).not.toHaveBeenCalled(); + expect(setup.ctx.ui.setStatus).not.toHaveBeenCalled(); + await shutdown(setup); + }); }); describe("pi-fff autocomplete registration", () => { @@ -164,6 +248,14 @@ describe("pi-fff autocomplete registration", () => { ]); }); + test("FFF_ENABLE_HOME_SCAN=0 disables home dir scanning", async () => { + process.env.FFF_ENABLE_HOME_SCAN = "0"; + await start(); + + const opts = createCalls[0] as { enableHomeDirScanning: boolean }; + expect(opts.enableHomeDirScanning).toBe(false); + }); + test("session_start survives hosts without addAutocompleteProvider", async () => { const setup = createPi(); const ctx = {