Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/pi-fff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ Mode precedence:
- `--fff-mode <mode>` — set mode (see above)
- `--fff-frecency-db <path>` — path to frecency database (also: `FFF_FRECENCY_DB` env)
- `--fff-history-db <path>` — 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

Expand Down
16 changes: 13 additions & 3 deletions packages/pi-fff/src/aux-finders.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,6 +15,9 @@ interface AuxPicker {

export interface AuxOpts {
enableFsRootScanning: boolean;
enableHomeDirScanning?: boolean;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every time the llm is trying to spawn the aux picker in home dir we should send the notification to the user

// Called before a newly spawned aux picker starts a scan that covers $HOME.
onHomeDirScan?: (root: string) => void;
}

export class AuxFinderPool {
Expand Down Expand Up @@ -89,14 +92,21 @@ 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
// persistent scoring — see issue #700.
const result = FileFinder.create({
basePath: root,
aiMode: true,
enableHomeDirScanning: true,
enableHomeDirScanning,
enableFsRootScanning: this.opts.enableFsRootScanning,
});
if (!result.ok)
Expand Down Expand Up @@ -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;
Expand Down
104 changes: 98 additions & 6 deletions packages/pi-fff/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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"];
Expand Down Expand Up @@ -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;
Expand All @@ -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<typeof setInterval> | 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
Expand All @@ -364,7 +401,7 @@ export default function fffExtension(pi: ExtensionAPI) {
frecencyDbPath,
historyDbPath,
aiMode: true,
enableHomeDirScanning: true,
enableHomeDirScanning,
enableFsRootScanning,
});

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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)}`,
Expand Down
9 changes: 9 additions & 0 deletions packages/pi-fff/src/paths.ts
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 29 additions & 2 deletions packages/pi-fff/test/aux-pool.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -40,10 +42,10 @@ mock.module("@ff-labs/fff-bun", () => finderModule);

const { AuxFinderPool } = await import("../src/aux-finders");

function makePool() {
function makePool(opts: Record<string, unknown> = {}) {
created.length = 0;
createOptions.length = 0;
return new AuxFinderPool({ enableFsRootScanning: false });
return new AuxFinderPool({ enableFsRootScanning: false, ...opts });
}

describe("AuxFinderPool covering reuse", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading