From 07bcdf55b8da8f0b76ab6095d3dda9000dc34580 Mon Sep 17 00:00:00 2001 From: Ohlulu Date: Tue, 25 Aug 2026 09:33:12 +0800 Subject: [PATCH 1/2] feat(pi-fff): expose followSymlinks option FFF skips symlinks during the walk, so an indexed tree that reaches its real files through links - a git worktree whose docs/ points back at the main checkout, or a stowed dotfiles layout as in #627 - is missing those files from @-mentions and from the find/grep tools. #628 exposed follow_symlinks through fff-c, fff-mcp, fff-python and @ff-labs/fff-node, but pi-fff never passes it, so the extension has no way to reach the option. Wire it into the existing startup config so it resolves as flag > env > pi-fff.json > false, and pass it to both the cwd picker and the aux pickers, which would otherwise disagree for absolute-path find/grep. Default stays false, matching the Node SDK, the Rust core and fff.nvim. --- packages/pi-fff/README.md | 5 ++++- packages/pi-fff/pi-fff.schema.json | 5 +++++ packages/pi-fff/src/aux-finders.ts | 2 ++ packages/pi-fff/src/config.ts | 9 ++++++++- packages/pi-fff/src/file-picker.ts | 1 + packages/pi-fff/src/index.ts | 18 ++++++++++++++++++ packages/pi-fff/test/aux-pool.test.ts | 7 +++++++ packages/pi-fff/test/config.test.ts | 2 ++ packages/pi-fff/test/extension.test.ts | 18 ++++++++++++++++++ 9 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/pi-fff/README.md b/packages/pi-fff/README.md index 7951206db..91c65ced8 100644 --- a/packages/pi-fff/README.md +++ b/packages/pi-fff/README.md @@ -144,7 +144,8 @@ For persistent global configuration, create `pi-fff.json` in pi's agent director "historyDbPath": "/path/to/history", "enableFsRootScanning": false, "enableHomeDirScanning": true, - "warnOnHomeDirScan": true + "warnOnHomeDirScan": true, + "followSymlinks": false } ``` @@ -159,6 +160,7 @@ All fields are optional: | `enableFsRootScanning` | boolean | `false` | | `enableHomeDirScanning` | boolean | `true` | | `warnOnHomeDirScan` | boolean | `true` | +| `followSymlinks` | boolean | `false` | CLI flags take precedence over environment variables, which take precedence over this file. A missing file is ignored. Malformed JSON, unknown fields, and invalid values stop the extension from loading and report the file path and error. `/fff-mode` changes the current session; it does not edit this file. @@ -172,6 +174,7 @@ The file is global only. Project-level config cannot safely control tool names b - `--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. - `--fff-warn-home-scan` — show the warning notification when `$HOME` is indexed (also: `FFF_WARN_HOME_SCAN` env). Enabled by default. Disable with `--fff-warn-home-scan=false`, `FFF_WARN_HOME_SCAN=0`, or `"warnOnHomeDirScan": false` in `pi-fff.json`. Indexing and the footer status are unaffected. +- `--fff-follow-symlinks` — index through directory symlinks (also: `FFF_FOLLOW_SYMLINKS=1` env, or `"followSymlinks": true` in `pi-fff.json`). Disabled by default, matching the Node SDK and fff.nvim. Enable it when the indexed tree reaches its real files through symlinks — a git worktree whose `docs/` links back to the main checkout, or a stowed dotfiles layout — otherwise those files are missing from `@`-mentions and from find/grep. The indexed tree must not contain symlink cycles: the walker does not de-duplicate visited targets, and a cycle can wedge the file watcher. ## Data diff --git a/packages/pi-fff/pi-fff.schema.json b/packages/pi-fff/pi-fff.schema.json index 56f1d25bf..5aaaa623a 100644 --- a/packages/pi-fff/pi-fff.schema.json +++ b/packages/pi-fff/pi-fff.schema.json @@ -41,6 +41,11 @@ "type": "boolean", "default": true, "description": "Shows a warning notification when the home directory is indexed." + }, + "followSymlinks": { + "type": "boolean", + "default": false, + "description": "Indexes through directory symlinks, e.g. a git worktree or stow layout whose files live behind links. The tree must not contain symlink cycles." } } } diff --git a/packages/pi-fff/src/aux-finders.ts b/packages/pi-fff/src/aux-finders.ts index 45f4170b4..12e1ecbf0 100644 --- a/packages/pi-fff/src/aux-finders.ts +++ b/packages/pi-fff/src/aux-finders.ts @@ -16,6 +16,7 @@ interface AuxPicker { export interface AuxOpts { enableFsRootScanning: boolean; enableHomeDirScanning?: boolean; + followSymlinks?: boolean; pickers: FilePickerFactory; // Called before a newly spawned aux picker starts a scan that covers $HOME. onHomeDirScan?: (root: string) => void; @@ -104,6 +105,7 @@ export class AuxFinderPool { basePath: root, enableHomeDirScanning, enableFsRootScanning: this.opts.enableFsRootScanning, + followSymlinks: this.opts.followSymlinks, }); const entry: AuxPicker = { root, finder, lastUsed: Date.now() }; diff --git a/packages/pi-fff/src/config.ts b/packages/pi-fff/src/config.ts index f95d34f70..8e12f9d0f 100644 --- a/packages/pi-fff/src/config.ts +++ b/packages/pi-fff/src/config.ts @@ -15,6 +15,7 @@ export interface FffConfig { enableFsRootScanning?: boolean; enableHomeDirScanning?: boolean; warnOnHomeDirScan?: boolean; + followSymlinks?: boolean; } const CONFIG_KEYS = new Set([ @@ -25,6 +26,7 @@ const CONFIG_KEYS = new Set([ "enableFsRootScanning", "enableHomeDirScanning", "warnOnHomeDirScan", + "followSymlinks", ]); export function loadConfig(agentDir = piDataDir()): FffConfig { @@ -67,6 +69,7 @@ export function loadConfig(agentDir = piDataDir()): FffConfig { validateBoolean(configPath, parsed, "enableFsRootScanning"); validateBoolean(configPath, parsed, "enableHomeDirScanning"); validateBoolean(configPath, parsed, "warnOnHomeDirScan"); + validateBoolean(configPath, parsed, "followSymlinks"); return parsed as FffConfig; } @@ -97,7 +100,11 @@ function validateString( function validateBoolean( configPath: string, config: Record, - key: "enableFsRootScanning" | "enableHomeDirScanning" | "warnOnHomeDirScan", + key: + | "enableFsRootScanning" + | "enableHomeDirScanning" + | "warnOnHomeDirScan" + | "followSymlinks", ): void { const value = config[key]; if (value !== undefined && typeof value !== "boolean") { diff --git a/packages/pi-fff/src/file-picker.ts b/packages/pi-fff/src/file-picker.ts index 1be6681dd..d77ed027e 100644 --- a/packages/pi-fff/src/file-picker.ts +++ b/packages/pi-fff/src/file-picker.ts @@ -5,6 +5,7 @@ export interface PickerOptions { basePath: string; enableHomeDirScanning?: boolean; enableFsRootScanning?: boolean; + followSymlinks?: boolean; } /** Opens every picker in this pi process — the cwd picker and the aux pickers — diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index 5a7e64cca..8b4a7de9d 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -345,6 +345,7 @@ export default function fffExtension(pi: ExtensionAPI) { let enableFsRootScanning = false; let enableHomeDirScanning = true; let warnOnHomeDirScan = true; + let followSymlinks = false; function setMode(mode: FffMode): void { currentMode = mode; @@ -394,6 +395,15 @@ export default function fffExtension(pi: ExtensionAPI) { true, parseBoolean, ); + // Symlinked trees (git worktrees, stow layouts) are skipped by default, and + // loop protection is the caller's job, so following stays opt-in. + followSymlinks = getConfigValue( + "fff-follow-symlinks", + "FFF_FOLLOW_SYMLINKS", + config.followSymlinks, + false, + parseBoolean, + ); } function getMode(): FffMode { @@ -440,6 +450,7 @@ export default function fffExtension(pi: ExtensionAPI) { auxPool = new AuxFinderPool({ enableFsRootScanning, enableHomeDirScanning, + followSymlinks, onHomeDirScan: warnHomeDirScan, pickers, }); @@ -466,6 +477,7 @@ export default function fffExtension(pi: ExtensionAPI) { basePath: cwd, enableHomeDirScanning, enableFsRootScanning, + followSymlinks, }); finderCwd = cwd; return mainFinder; @@ -677,6 +689,12 @@ export default function fffExtension(pi: ExtensionAPI) { type: "boolean", }); + pi.registerFlag("fff-follow-symlinks", { + description: + "Index through directory symlinks, e.g. a git worktree or stow layout (also: FFF_FOLLOW_SYMLINKS env). Disabled by default; the tree must not contain symlink cycles", + type: "boolean", + }); + pi.registerFlag("fff-warn-home-scan", { description: "Warn when indexing $HOME (default true; silence with --fff-warn-home-scan=false or FFF_WARN_HOME_SCAN=0)", diff --git a/packages/pi-fff/test/aux-pool.test.ts b/packages/pi-fff/test/aux-pool.test.ts index 86aa56130..5d715367f 100644 --- a/packages/pi-fff/test/aux-pool.test.ts +++ b/packages/pi-fff/test/aux-pool.test.ts @@ -84,6 +84,13 @@ describe("AuxFinderPool covering reuse", () => { expect(created.length).toBe(1); }); + test("passes followSymlinks through to the aux picker", async () => { + const pool = makePool({ followSymlinks: true }); + await pool.acquire("/a/b/c"); + + expect(createOptions[0]?.followSymlinks).toBe(true); + }); + test("does not reuse a picker rooted deeper than the requested path", async () => { const pool = makePool(); await pool.acquire("/a/b/c"); diff --git a/packages/pi-fff/test/config.test.ts b/packages/pi-fff/test/config.test.ts index 85ade4f84..e21f3442a 100644 --- a/packages/pi-fff/test/config.test.ts +++ b/packages/pi-fff/test/config.test.ts @@ -32,6 +32,7 @@ describe("loadConfig", () => { enableFsRootScanning: true, enableHomeDirScanning: false, warnOnHomeDirScan: false, + followSymlinks: true, }; writeConfig(config); @@ -68,6 +69,7 @@ describe("loadConfig", () => { [{ enableFsRootScanning: 1 }, '"enableFsRootScanning" must be a boolean'], [{ enableHomeDirScanning: "false" }, '"enableHomeDirScanning" must be a boolean'], [{ warnOnHomeDirScan: "false" }, '"warnOnHomeDirScan" must be a boolean'], + [{ followSymlinks: "true" }, '"followSymlinks" must be a boolean'], ]; for (const [config, message] of cases) { diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts index c6820f207..901704bb3 100644 --- a/packages/pi-fff/test/extension.test.ts +++ b/packages/pi-fff/test/extension.test.ts @@ -191,6 +191,7 @@ const CONFIG_ENV_KEYS = [ "FFF_ENABLE_ROOT_SCAN", "FFF_ENABLE_HOME_SCAN", "FFF_WARN_HOME_SCAN", + "FFF_FOLLOW_SYMLINKS", ] as const; const savedEnv: Record = {}; @@ -227,6 +228,7 @@ describe("pi-fff global config", () => { historyDbPath: "/config/history", enableFsRootScanning: true, enableHomeDirScanning: false, + followSymlinks: true, }); const setup = await start(); @@ -242,6 +244,7 @@ describe("pi-fff global config", () => { aiMode: true, enableHomeDirScanning: false, enableFsRootScanning: true, + followSymlinks: true, }); await shutdown(setup); }); @@ -259,10 +262,12 @@ describe("pi-fff global config", () => { process.env.FFF_HISTORY_DB = "/env/history"; process.env.FFF_ENABLE_ROOT_SCAN = "1"; process.env.FFF_ENABLE_HOME_SCAN = "1"; + process.env.FFF_FOLLOW_SYMLINKS = "1"; const setup = await start("tools-and-ui", undefined, { "fff-frecency-db": "/flag/frecency", "fff-enable-root-scan": false, + "fff-follow-symlinks": false, }); const toolNames = setup.pi.registerTool.mock.calls.map(([tool]) => tool.name); @@ -275,10 +280,22 @@ describe("pi-fff global config", () => { aiMode: true, enableHomeDirScanning: true, enableFsRootScanning: false, + followSymlinks: false, }); await shutdown(setup); }); + // #627: worktree and stow layouts reach their files through symlinks, which the + // walker skips unless this is on. + test("follows symlinks when the environment enables them", async () => { + process.env.FFF_FOLLOW_SYMLINKS = "1"; + + const setup = await start(); + + expect((createCalls[0] as { followSymlinks: boolean }).followSymlinks).toBe(true); + await shutdown(setup); + }); + test("falls through invalid flag and environment modes", async () => { writeConfig({ mode: "override" }); process.env.PI_FFF_MODE = "invalid-env-mode"; @@ -476,6 +493,7 @@ describe("pi-fff autocomplete registration", () => { aiMode: true, enableHomeDirScanning: true, enableFsRootScanning: false, + followSymlinks: false, }, ]); }); From c2f4d819247221502a6c6b15e9f91341ad4a8832 Mon Sep 17 00:00:00 2001 From: Ohlulu Date: Wed, 26 Aug 2026 12:27:11 +0800 Subject: [PATCH 2/2] feat(pi-fff): follow symlinks by default Trees that reach their real files through links (git worktrees, stow layouts) were absent from the index with no visible sign, so the agent silently fell back to shell find/grep. Following is now the default and --fff-follow-symlinks=false / FFF_FOLLOW_SYMLINKS=0 is the opt-out. Also drops the stale cycle warning from the README: zlob 1.6.3 keeps a global (dev, ino) visited set and the ignore fallback detects ancestor loops, so cycles are broken by the walker, not by the caller. --- packages/pi-fff/README.md | 6 +++--- packages/pi-fff/pi-fff.schema.json | 4 ++-- packages/pi-fff/src/index.ts | 10 +++++----- packages/pi-fff/test/extension.test.ts | 16 ++++++++-------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/pi-fff/README.md b/packages/pi-fff/README.md index 91c65ced8..9375c5ab9 100644 --- a/packages/pi-fff/README.md +++ b/packages/pi-fff/README.md @@ -145,7 +145,7 @@ For persistent global configuration, create `pi-fff.json` in pi's agent director "enableFsRootScanning": false, "enableHomeDirScanning": true, "warnOnHomeDirScan": true, - "followSymlinks": false + "followSymlinks": true } ``` @@ -160,7 +160,7 @@ All fields are optional: | `enableFsRootScanning` | boolean | `false` | | `enableHomeDirScanning` | boolean | `true` | | `warnOnHomeDirScan` | boolean | `true` | -| `followSymlinks` | boolean | `false` | +| `followSymlinks` | boolean | `true` | CLI flags take precedence over environment variables, which take precedence over this file. A missing file is ignored. Malformed JSON, unknown fields, and invalid values stop the extension from loading and report the file path and error. `/fff-mode` changes the current session; it does not edit this file. @@ -174,7 +174,7 @@ The file is global only. Project-level config cannot safely control tool names b - `--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. - `--fff-warn-home-scan` — show the warning notification when `$HOME` is indexed (also: `FFF_WARN_HOME_SCAN` env). Enabled by default. Disable with `--fff-warn-home-scan=false`, `FFF_WARN_HOME_SCAN=0`, or `"warnOnHomeDirScan": false` in `pi-fff.json`. Indexing and the footer status are unaffected. -- `--fff-follow-symlinks` — index through directory symlinks (also: `FFF_FOLLOW_SYMLINKS=1` env, or `"followSymlinks": true` in `pi-fff.json`). Disabled by default, matching the Node SDK and fff.nvim. Enable it when the indexed tree reaches its real files through symlinks — a git worktree whose `docs/` links back to the main checkout, or a stowed dotfiles layout — otherwise those files are missing from `@`-mentions and from find/grep. The indexed tree must not contain symlink cycles: the walker does not de-duplicate visited targets, and a cycle can wedge the file watcher. +- `--fff-follow-symlinks` — index through directory symlinks (also: `FFF_FOLLOW_SYMLINKS` env, or `"followSymlinks"` in `pi-fff.json`). Enabled by default: trees that reach their real files through links — a git worktree whose `docs/` links back to the main checkout, or a stowed dotfiles layout — would otherwise be missing from `@`-mentions and from find/grep with no visible sign. Disable with `--fff-follow-symlinks=false` or `FFF_FOLLOW_SYMLINKS=0` to keep the walk inside the real tree, which is worth doing when a linked target pulls in a large tree outside the workspace. Symlink cycles are detected and broken by the walker. ## Data diff --git a/packages/pi-fff/pi-fff.schema.json b/packages/pi-fff/pi-fff.schema.json index 5aaaa623a..5f2783a4e 100644 --- a/packages/pi-fff/pi-fff.schema.json +++ b/packages/pi-fff/pi-fff.schema.json @@ -44,8 +44,8 @@ }, "followSymlinks": { "type": "boolean", - "default": false, - "description": "Indexes through directory symlinks, e.g. a git worktree or stow layout whose files live behind links. The tree must not contain symlink cycles." + "default": true, + "description": "Indexes through directory symlinks, e.g. a git worktree or stow layout whose files live behind links. Set to false to keep the walk inside the real tree." } } } diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index 8b4a7de9d..542bb277b 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -345,7 +345,7 @@ export default function fffExtension(pi: ExtensionAPI) { let enableFsRootScanning = false; let enableHomeDirScanning = true; let warnOnHomeDirScan = true; - let followSymlinks = false; + let followSymlinks = true; function setMode(mode: FffMode): void { currentMode = mode; @@ -395,13 +395,13 @@ export default function fffExtension(pi: ExtensionAPI) { true, parseBoolean, ); - // Symlinked trees (git worktrees, stow layouts) are skipped by default, and - // loop protection is the caller's job, so following stays opt-in. + // On by default: worktree and stow layouts reach their files through links, + // and an agent silently missing them is worse than the extra walk. followSymlinks = getConfigValue( "fff-follow-symlinks", "FFF_FOLLOW_SYMLINKS", config.followSymlinks, - false, + true, parseBoolean, ); } @@ -691,7 +691,7 @@ export default function fffExtension(pi: ExtensionAPI) { pi.registerFlag("fff-follow-symlinks", { description: - "Index through directory symlinks, e.g. a git worktree or stow layout (also: FFF_FOLLOW_SYMLINKS env). Disabled by default; the tree must not contain symlink cycles", + "Index through directory symlinks, e.g. a git worktree or stow layout (default true; disable with --fff-follow-symlinks=false or FFF_FOLLOW_SYMLINKS=0)", type: "boolean", }); diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts index 901704bb3..b41fafa29 100644 --- a/packages/pi-fff/test/extension.test.ts +++ b/packages/pi-fff/test/extension.test.ts @@ -228,7 +228,7 @@ describe("pi-fff global config", () => { historyDbPath: "/config/history", enableFsRootScanning: true, enableHomeDirScanning: false, - followSymlinks: true, + followSymlinks: false, }); const setup = await start(); @@ -244,7 +244,7 @@ describe("pi-fff global config", () => { aiMode: true, enableHomeDirScanning: false, enableFsRootScanning: true, - followSymlinks: true, + followSymlinks: false, }); await shutdown(setup); }); @@ -285,14 +285,14 @@ describe("pi-fff global config", () => { await shutdown(setup); }); - // #627: worktree and stow layouts reach their files through symlinks, which the - // walker skips unless this is on. - test("follows symlinks when the environment enables them", async () => { - process.env.FFF_FOLLOW_SYMLINKS = "1"; + // #627: worktree and stow layouts reach their files through symlinks, so following + // them is the default and the environment is the way out. + test("stops following symlinks when the environment disables them", async () => { + process.env.FFF_FOLLOW_SYMLINKS = "0"; const setup = await start(); - expect((createCalls[0] as { followSymlinks: boolean }).followSymlinks).toBe(true); + expect((createCalls[0] as { followSymlinks: boolean }).followSymlinks).toBe(false); await shutdown(setup); }); @@ -493,7 +493,7 @@ describe("pi-fff autocomplete registration", () => { aiMode: true, enableHomeDirScanning: true, enableFsRootScanning: false, - followSymlinks: false, + followSymlinks: true, }, ]); });