@ff-labs/pi-fff 0.10.5 → 0.10.6-nightly.21f6d25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,7 +116,7 @@ Parameters:
116
116
 
117
117
  - `/fff-health` — show FFF status (indexed files, git info, frecency/history DB status)
118
118
  - `/fff-rescan` — trigger a file rescan
119
- - `/fff-mode <mode>` — switch mode (tool name change requires restart)
119
+ - `/fff-mode <mode>` — switch mode (tool name changes require `/reload`)
120
120
 
121
121
  ## Modes
122
122
 
@@ -124,10 +124,45 @@ Parameters:
124
124
  - `tools-only`: additional tools only; keep pi's default `@` autocomplete
125
125
  - `override`: replaces pi's built-in `find`, `grep` and adds `multi_grep` + FFF-backed `@` autocomplete
126
126
 
127
- Mode precedence:
127
+ Startup mode precedence:
128
128
  1. `--fff-mode <mode>` CLI flag
129
129
  2. `PI_FFF_MODE=<mode>` environment variable
130
- 3. default (`tools-and-ui`)
130
+ 3. `mode` in the global config file
131
+ 4. default (`tools-and-ui`)
132
+
133
+ When a session resumes, its most recent `/fff-mode` selection takes precedence over the startup resolution above. Switching to or from `override` takes effect after `/reload`, when the tools are registered again.
134
+
135
+ ## Configuration
136
+
137
+ For persistent global configuration, create `pi-fff.json` in pi's agent directory (`~/.pi/agent/pi-fff.json` by default; `PI_CODING_AGENT_DIR` is respected):
138
+
139
+ ```json
140
+ {
141
+ "$schema": "https://raw.githubusercontent.com/dmtrKovalenko/fff/main/packages/pi-fff/pi-fff.schema.json",
142
+ "mode": "override",
143
+ "frecencyDbPath": "/path/to/frecency",
144
+ "historyDbPath": "/path/to/history",
145
+ "enableFsRootScanning": false,
146
+ "enableHomeDirScanning": true,
147
+ "warnOnHomeDirScan": true
148
+ }
149
+ ```
150
+
151
+ All fields are optional:
152
+
153
+ | Field | Type | Default |
154
+ |---|---|---|
155
+ | `$schema` | non-empty string | none |
156
+ | `mode` | `tools-and-ui`, `tools-only`, or `override` | `tools-and-ui` |
157
+ | `frecencyDbPath` | non-empty string | See [Data](#data) |
158
+ | `historyDbPath` | non-empty string | See [Data](#data) |
159
+ | `enableFsRootScanning` | boolean | `false` |
160
+ | `enableHomeDirScanning` | boolean | `true` |
161
+ | `warnOnHomeDirScan` | boolean | `true` |
162
+
163
+ 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.
164
+
165
+ The file is global only. Project-level config cannot safely control tool names because pi decides which tools an extension registers before project configuration can be trusted.
131
166
 
132
167
  ## Flags
133
168
 
@@ -136,6 +171,7 @@ Mode precedence:
136
171
  - `--fff-history-db <path>` — path to query history database (also: `FFF_HISTORY_DB` env). Optional; see [Data](#data) for the default.
137
172
  - `--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.
138
173
  - `--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.
174
+ - `--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.
139
175
 
140
176
  ## Data
141
177
 
@@ -147,11 +183,12 @@ Each path is resolved independently, in this order:
147
183
 
148
184
  1. CLI flag — `--fff-frecency-db` / `--fff-history-db`
149
185
  2. Env var — `FFF_FRECENCY_DB` / `FFF_HISTORY_DB`
150
- 3. An existing [fff.nvim](https://github.com/dmtrKovalenko/fff.nvim) database, so pi reuses the frecency you built up in your editor:
186
+ 3. Global config — `frecencyDbPath` / `historyDbPath`
187
+ 4. An existing [fff.nvim](https://github.com/dmtrKovalenko/fff.nvim) database, so pi reuses the frecency you built up in your editor:
151
188
  - frecency: `$XDG_CACHE_HOME/nvim/fff_nvim`
152
189
  - history: `$XDG_DATA_HOME/nvim/fff_queries`
153
190
  - `XDG_CACHE_HOME` defaults to `~/.cache` and `XDG_DATA_HOME` to `~/.local/share`; on Windows both fall back under `%LOCALAPPDATA%\nvim-data`. Only directories count — a plain file at those paths is ignored.
154
- 4. pi-local directory, created on demand — `$PI_CODING_AGENT_DIR/fff/{frecency,history}`, defaulting to `~/.pi/agent/fff/{frecency,history}`
191
+ 5. pi-local directory, created on demand — `$PI_CODING_AGENT_DIR/fff/{frecency,history}`, defaulting to `~/.pi/agent/fff/{frecency,history}`
155
192
 
156
193
  The extension only reads these databases; it never records the agent's own searches into your Neovim history. If a database cannot be opened, the finder starts without persistence and pi shows a warning instead of failing.
157
194
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ff-labs/pi-fff",
3
3
  "public": true,
4
- "version": "0.10.5",
4
+ "version": "0.10.6-nightly.21f6d25",
5
5
  "description": "pi extension: FFF-powered fuzzy file and content search",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -30,7 +30,8 @@
30
30
  ]
31
31
  },
32
32
  "files": [
33
- "src"
33
+ "src",
34
+ "pi-fff.schema.json"
34
35
  ],
35
36
  "publishConfig": {
36
37
  "access": "public"
@@ -40,8 +41,8 @@
40
41
  "typecheck": "tsc --noEmit"
41
42
  },
42
43
  "dependencies": {
43
- "@ff-labs/fff-bun": "0.10.5",
44
- "@ff-labs/fff-node": "0.10.5"
44
+ "@ff-labs/fff-bun": "0.10.6-nightly.21f6d25",
45
+ "@ff-labs/fff-node": "0.10.6-nightly.21f6d25"
45
46
  },
46
47
  "peerDependencies": {
47
48
  "@earendil-works/pi-coding-agent": "*",
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/dmtrKovalenko/fff/main/packages/pi-fff/pi-fff.schema.json",
4
+ "title": "pi-fff configuration",
5
+ "description": "Global startup configuration for the pi-fff extension.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string",
11
+ "minLength": 1,
12
+ "description": "Schema URL used by editors for validation and completion."
13
+ },
14
+ "mode": {
15
+ "type": "string",
16
+ "enum": ["tools-and-ui", "tools-only", "override"],
17
+ "default": "tools-and-ui",
18
+ "description": "Controls which FFF tools and autocomplete integrations are enabled."
19
+ },
20
+ "frecencyDbPath": {
21
+ "type": "string",
22
+ "minLength": 1,
23
+ "description": "Path to the frecency database."
24
+ },
25
+ "historyDbPath": {
26
+ "type": "string",
27
+ "minLength": 1,
28
+ "description": "Path to the query history database."
29
+ },
30
+ "enableFsRootScanning": {
31
+ "type": "boolean",
32
+ "default": false,
33
+ "description": "Allows indexing when pi is launched from the filesystem root."
34
+ },
35
+ "enableHomeDirScanning": {
36
+ "type": "boolean",
37
+ "default": true,
38
+ "description": "Allows indexing when pi is launched from the home directory."
39
+ },
40
+ "warnOnHomeDirScan": {
41
+ "type": "boolean",
42
+ "default": true,
43
+ "description": "Shows a warning notification when the home directory is indexed."
44
+ }
45
+ }
46
+ }
package/src/config.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { piDataDir } from "./paths";
4
+
5
+ export const CONFIG_FILE_NAME = "pi-fff.json";
6
+ export const VALID_MODES = ["tools-and-ui", "tools-only", "override"] as const;
7
+
8
+ export type FffMode = (typeof VALID_MODES)[number];
9
+
10
+ export interface FffConfig {
11
+ $schema?: string;
12
+ mode?: FffMode;
13
+ frecencyDbPath?: string;
14
+ historyDbPath?: string;
15
+ enableFsRootScanning?: boolean;
16
+ enableHomeDirScanning?: boolean;
17
+ warnOnHomeDirScan?: boolean;
18
+ }
19
+
20
+ const CONFIG_KEYS = new Set<keyof FffConfig>([
21
+ "$schema",
22
+ "mode",
23
+ "frecencyDbPath",
24
+ "historyDbPath",
25
+ "enableFsRootScanning",
26
+ "enableHomeDirScanning",
27
+ "warnOnHomeDirScan",
28
+ ]);
29
+
30
+ export function loadConfig(agentDir = piDataDir()): FffConfig {
31
+ const configPath = join(agentDir, CONFIG_FILE_NAME);
32
+ let contents: string;
33
+
34
+ try {
35
+ contents = readFileSync(configPath, "utf8");
36
+ } catch (error: unknown) {
37
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
38
+ throw new Error(
39
+ `Could not read pi-fff config at ${configPath}: ${errorMessage(error)}`,
40
+ );
41
+ }
42
+
43
+ let parsed: unknown;
44
+ try {
45
+ parsed = JSON.parse(contents);
46
+ } catch (error: unknown) {
47
+ throw invalidConfig(configPath, `not valid JSON (${errorMessage(error)})`);
48
+ }
49
+
50
+ if (!isRecord(parsed)) {
51
+ throw invalidConfig(configPath, "expected a JSON object");
52
+ }
53
+
54
+ for (const key of Object.keys(parsed)) {
55
+ if (!CONFIG_KEYS.has(key as keyof FffConfig)) {
56
+ throw invalidConfig(configPath, `unknown option "${key}"`);
57
+ }
58
+ }
59
+
60
+ if (parsed.mode !== undefined && !VALID_MODES.includes(parsed.mode as FffMode)) {
61
+ throw invalidConfig(configPath, `"mode" must be one of ${VALID_MODES.join(", ")}`);
62
+ }
63
+
64
+ validateString(configPath, parsed, "$schema");
65
+ validateString(configPath, parsed, "frecencyDbPath");
66
+ validateString(configPath, parsed, "historyDbPath");
67
+ validateBoolean(configPath, parsed, "enableFsRootScanning");
68
+ validateBoolean(configPath, parsed, "enableHomeDirScanning");
69
+ validateBoolean(configPath, parsed, "warnOnHomeDirScan");
70
+
71
+ return parsed as FffConfig;
72
+ }
73
+
74
+ function invalidConfig(configPath: string, reason: string): Error {
75
+ return new Error(`Invalid pi-fff config at ${configPath}: ${reason}`);
76
+ }
77
+
78
+ function errorMessage(error: unknown): string {
79
+ return error instanceof Error ? error.message : String(error);
80
+ }
81
+
82
+ function isRecord(value: unknown): value is Record<string, unknown> {
83
+ return typeof value === "object" && value !== null && !Array.isArray(value);
84
+ }
85
+
86
+ function validateString(
87
+ configPath: string,
88
+ config: Record<string, unknown>,
89
+ key: "$schema" | "frecencyDbPath" | "historyDbPath",
90
+ ): void {
91
+ const value = config[key];
92
+ if (value !== undefined && (typeof value !== "string" || value.length === 0)) {
93
+ throw invalidConfig(configPath, `"${key}" must be a non-empty string`);
94
+ }
95
+ }
96
+
97
+ function validateBoolean(
98
+ configPath: string,
99
+ config: Record<string, unknown>,
100
+ key: "enableFsRootScanning" | "enableHomeDirScanning" | "warnOnHomeDirScan",
101
+ ): void {
102
+ const value = config[key];
103
+ if (value !== undefined && typeof value !== "boolean") {
104
+ throw invalidConfig(configPath, `"${key}" must be a boolean`);
105
+ }
106
+ }
package/src/index.ts CHANGED
@@ -6,7 +6,11 @@
6
6
  */
7
7
 
8
8
  import nodePath from "node:path";
9
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import type {
10
+ ExtensionAPI,
11
+ ExtensionContext,
12
+ ToolDefinition,
13
+ } from "@earendil-works/pi-coding-agent";
10
14
  import {
11
15
  type AutocompleteItem,
12
16
  type AutocompleteProvider,
@@ -20,8 +24,9 @@ import type {
20
24
  MixedItem,
21
25
  SearchResult,
22
26
  } from "@ff-labs/fff-node";
23
- import { Type } from "@sinclair/typebox";
27
+ import { Type, type TSchema } from "@sinclair/typebox";
24
28
  import { AuxFinderPool, routePathConstraint } from "./aux-finders";
29
+ import { type FffMode, loadConfig, VALID_MODES } from "./config";
25
30
  import { FilePickerFactory } from "./file-picker";
26
31
  import { isHomeDir, resolveDbPaths } from "./paths";
27
32
  import { buildQuery } from "./query";
@@ -35,6 +40,10 @@ export { SCAN_TIMEOUT_MS } from "./sdk";
35
40
  const DEFAULT_GREP_LIMIT = 20;
36
41
  const DEFAULT_FIND_LIMIT = 30;
37
42
  const GREP_PAGE_SIZE_MAX = 50;
43
+ // Per-file match cap. Must stay decoupled from pageSize: grep cursors advance by
44
+ // file offset, so clamping this to pageSize makes same-file overflow unreachable
45
+ // on later pages (#825). Matches the engine default.
46
+ const GREP_MAX_MATCHES_PER_FILE = 200;
38
47
  const GREP_CONTEXT_MAX = 20;
39
48
  const GREP_MAX_LINE_LENGTH = 500;
40
49
  const MENTION_MAX_RESULTS = 20;
@@ -45,11 +54,8 @@ const GREP_TIME_BUDGET_MS = 10_000;
45
54
  const HOME_SCAN_STATUS_KEY = "fff";
46
55
  const HOME_SCAN_POLL_MS = 1_000;
47
56
  const HOME_SCAN_DISABLE_HINT =
48
- "You can prevent home dir indexing with --fff-enable-home-scan=false (or FFF_ENABLE_HOME_SCAN=0).";
49
-
50
- type FffMode = "tools-and-ui" | "tools-only" | "override";
51
-
52
- const VALID_MODES: FffMode[] = ["tools-and-ui", "tools-only", "override"];
57
+ 'You can prevent home dir indexing with --fff-enable-home-scan=false, FFF_ENABLE_HOME_SCAN=0, or "enableHomeDirScanning": false in pi-fff.json. ' +
58
+ 'To keep indexing but silence this warning use --fff-warn-home-scan=false, FFF_WARN_HOME_SCAN=0, or "warnOnHomeDirScan": false in pi-fff.json.';
53
59
 
54
60
  interface ToolNames {
55
61
  grep: string;
@@ -299,52 +305,103 @@ export default function fffExtension(pi: ExtensionAPI) {
299
305
  let finderPromise: Promise<FileFinderApi> | null = null;
300
306
  let activeCwd = process.cwd();
301
307
 
302
- // Mode resolution: flag > env > default
303
- let currentMode: FffMode =
304
- (pi.getFlag("fff-mode") as FffMode) ??
305
- (process.env.PI_FFF_MODE as FffMode) ??
306
- "tools-and-ui";
307
-
308
- const toolNames = resolveToolNames(currentMode);
309
-
310
- // DB path resolution: flag > env > existing fff.nvim db > pi-local data dir.
311
- const resolvedDbPaths = resolveDbPaths({
312
- frecency:
313
- (pi.getFlag("fff-frecency-db") as string | undefined) ??
314
- process.env.FFF_FRECENCY_DB,
315
- history:
316
- (pi.getFlag("fff-history-db") as string | undefined) ?? process.env.FFF_HISTORY_DB,
317
- });
308
+ const config = loadConfig();
309
+
310
+ // All startup options use the same flag > env > file > fallback order.
311
+ function getConfigValue<T>(
312
+ flagName: string,
313
+ envName: string,
314
+ fileValue: T | undefined,
315
+ fallback: T,
316
+ parse: (value: unknown) => T | undefined = (value) => value as T,
317
+ ): T {
318
+ const flagValue = pi.getFlag(flagName);
319
+ if (flagValue !== undefined) {
320
+ const value = parse(flagValue);
321
+ if (value !== undefined) return value;
322
+ }
318
323
 
319
- // flag (boolean) > env ("1"/"true", or "0"/"false") > default.
320
- function resolveBoolOpt(flagName: string, envName: string, fallback = false): boolean {
321
- const flag = pi.getFlag(flagName);
322
- if (typeof flag === "boolean") return flag;
323
- if (typeof flag === "string") return flag === "true" || flag === "1";
324
- const env = process.env[envName];
325
- if (env === "1" || env === "true") return true;
326
- if (env === "0" || env === "false") return false;
327
- return fallback;
324
+ const envValue = process.env[envName];
325
+ if (envValue !== undefined) {
326
+ const value = parse(envValue);
327
+ if (value !== undefined) return value;
328
+ }
329
+
330
+ return fileValue ?? fallback;
328
331
  }
329
- // Root scanning opt-in: FFF refuses to init at / unless this is set.
330
- const enableFsRootScanning = resolveBoolOpt(
331
- "fff-enable-root-scan",
332
- "FFF_ENABLE_ROOT_SCAN",
333
- );
334
- // Home dir scanning is on by default (launching pi from $HOME is a normal
335
- // flow), but configurable so users with huge $HOME trees can opt out.
336
- const enableHomeDirScanning = resolveBoolOpt(
337
- "fff-enable-home-scan",
338
- "FFF_ENABLE_HOME_SCAN",
339
- true,
340
- );
341
332
 
342
- function getMode(): FffMode {
343
- return currentMode;
333
+ function parseBoolean(value: unknown): boolean | undefined {
334
+ if (typeof value === "boolean") return value;
335
+ if (value === "1" || value === "true") return true;
336
+ if (value === "0" || value === "false") return false;
337
+ return undefined;
338
+ }
339
+
340
+ function parseMode(value: unknown): FffMode | undefined {
341
+ return typeof value === "string" && VALID_MODES.includes(value as FffMode)
342
+ ? (value as FffMode)
343
+ : undefined;
344
344
  }
345
345
 
346
+ let currentMode: FffMode = "tools-and-ui";
347
+ let toolNames = resolveToolNames(currentMode);
348
+ let resolvedDbPaths: ReturnType<typeof resolveDbPaths>;
349
+ let enableFsRootScanning = false;
350
+ let enableHomeDirScanning = true;
351
+ let warnOnHomeDirScan = true;
352
+
346
353
  function setMode(mode: FffMode): void {
347
354
  currentMode = mode;
355
+ toolNames = resolveToolNames(mode);
356
+ }
357
+
358
+ function resolveStartupConfig(): void {
359
+ setMode(
360
+ getConfigValue("fff-mode", "PI_FFF_MODE", config.mode, "tools-and-ui", parseMode),
361
+ );
362
+ resolvedDbPaths = resolveDbPaths({
363
+ frecency: getConfigValue(
364
+ "fff-frecency-db",
365
+ "FFF_FRECENCY_DB",
366
+ config.frecencyDbPath,
367
+ undefined,
368
+ ),
369
+ history: getConfigValue(
370
+ "fff-history-db",
371
+ "FFF_HISTORY_DB",
372
+ config.historyDbPath,
373
+ undefined,
374
+ ),
375
+ });
376
+
377
+ // Root scanning opt-in: FFF refuses to init at / unless this is set.
378
+ enableFsRootScanning = getConfigValue(
379
+ "fff-enable-root-scan",
380
+ "FFF_ENABLE_ROOT_SCAN",
381
+ config.enableFsRootScanning,
382
+ false,
383
+ parseBoolean,
384
+ );
385
+ // Home dir scanning is on by default (launching pi from $HOME is a normal
386
+ // flow), but configurable so users with huge $HOME trees can opt out.
387
+ enableHomeDirScanning = getConfigValue(
388
+ "fff-enable-home-scan",
389
+ "FFF_ENABLE_HOME_SCAN",
390
+ config.enableHomeDirScanning,
391
+ true,
392
+ parseBoolean,
393
+ );
394
+ warnOnHomeDirScan = getConfigValue(
395
+ "fff-warn-home-scan",
396
+ "FFF_WARN_HOME_SCAN",
397
+ config.warnOnHomeDirScan,
398
+ true,
399
+ parseBoolean,
400
+ );
401
+ }
402
+
403
+ function getMode(): FffMode {
404
+ return currentMode;
348
405
  }
349
406
 
350
407
  function shouldEnableMentions(): boolean {
@@ -362,28 +419,35 @@ export default function fffExtension(pi: ExtensionAPI) {
362
419
  let homeScanTimer: ReturnType<typeof setInterval> | null = null;
363
420
 
364
421
  function warnHomeDirScan(root: string): void {
422
+ if (!warnOnHomeDirScan) return;
365
423
  uiCtx?.ui.notify(
366
424
  `(fff): Your cwd (${root}) is too large. Indexing will take additional time and resources.\n${HOME_SCAN_DISABLE_HINT}`,
367
425
  "warning",
368
426
  );
369
427
  }
370
428
 
371
- const pickers = new FilePickerFactory({
372
- frecencyDbPath: resolvedDbPaths.frecency,
373
- historyDbPath: resolvedDbPaths.history,
374
- onDbFailure: (error) =>
375
- uiCtx?.ui.notify(
376
- `(fff): Failed to open frecency/history database (${error}). Continuing without frecency persistence.`,
377
- "error",
378
- ),
379
- });
429
+ let pickers: FilePickerFactory | null = null;
430
+ let auxPool: AuxFinderPool | null = null;
380
431
 
381
- const auxPool = new AuxFinderPool({
382
- enableFsRootScanning,
383
- enableHomeDirScanning,
384
- onHomeDirScan: warnHomeDirScan,
385
- pickers,
386
- });
432
+ function initializeFinderFactories(): void {
433
+ if (pickers) return;
434
+
435
+ pickers = new FilePickerFactory({
436
+ frecencyDbPath: resolvedDbPaths.frecency,
437
+ historyDbPath: resolvedDbPaths.history,
438
+ onDbFailure: (error) =>
439
+ uiCtx?.ui.notify(
440
+ `(fff): Failed to open frecency/history database (${error}). Continuing without frecency persistence.`,
441
+ "error",
442
+ ),
443
+ });
444
+ auxPool = new AuxFinderPool({
445
+ enableFsRootScanning,
446
+ enableHomeDirScanning,
447
+ onHomeDirScan: warnHomeDirScan,
448
+ pickers,
449
+ });
450
+ }
387
451
 
388
452
  // in case cwd changes we need to figure this out
389
453
  function ensureFinder(cwd: string): Promise<FileFinderApi> {
@@ -401,6 +465,7 @@ export default function fffExtension(pi: ExtensionAPI) {
401
465
 
402
466
  // if the dbs can't be opened the factory falls back to a db-less picker,
403
467
  // e.g. when some other process corrupts the lock
468
+ if (!pickers) throw new Error("FFF picker factory is not initialized");
404
469
  mainFinder = await pickers.create({
405
470
  basePath: cwd,
406
471
  enableHomeDirScanning,
@@ -455,9 +520,9 @@ export default function fffExtension(pi: ExtensionAPI) {
455
520
  finderCwd = null;
456
521
  }
457
522
 
458
- if (auxPool) {
459
- auxPool.destroy();
460
- }
523
+ auxPool?.destroy();
524
+ auxPool = null;
525
+ pickers = null;
461
526
  }
462
527
 
463
528
  async function resolveFinderForPath(
@@ -467,6 +532,7 @@ export default function fffExtension(pi: ExtensionAPI) {
467
532
  ): Promise<{ finder: FileFinderApi; query: string; root: string } | null> {
468
533
  const route = routePathConstraint(pathParam, activeCwd);
469
534
  if (!route) return null;
535
+ if (!auxPool) throw new Error("FFF auxiliary finder pool is not initialized");
470
536
  const aux = await auxPool.acquire(route.root);
471
537
  // A broader covering picker may have been reused; rebase the suffix so the
472
538
  // constraint stays relative to the picker's actual root.
@@ -547,6 +613,45 @@ export default function fffExtension(pi: ExtensionAPI) {
547
613
  });
548
614
  }
549
615
 
616
+ type PendingToolDefinition<
617
+ TParams extends TSchema,
618
+ TDetails = unknown,
619
+ TState = any,
620
+ > = Omit<
621
+ ToolDefinition<TParams, TDetails, TState>,
622
+ "name" | "label" | "promptGuidelines"
623
+ > & {
624
+ promptGuidelines?: (names: ToolNames) => string[];
625
+ };
626
+
627
+ const pendingTools: (() => string)[] = [];
628
+ let toolsRegistered = false;
629
+
630
+ function queueTool<TParams extends TSchema, TDetails = unknown, TState = any>(
631
+ resolveName: () => string,
632
+ definition: PendingToolDefinition<TParams, TDetails, TState>,
633
+ ): void {
634
+ pendingTools.push(() => {
635
+ const { promptGuidelines, ...tool } = definition;
636
+ const resolvedName = resolveName();
637
+ pi.registerTool({
638
+ ...tool,
639
+ name: resolvedName,
640
+ label: resolvedName,
641
+ promptGuidelines: promptGuidelines?.(toolNames),
642
+ });
643
+ return resolvedName;
644
+ });
645
+ }
646
+
647
+ function registerPendingTools(): void {
648
+ if (toolsRegistered) return;
649
+
650
+ const registeredNames = pendingTools.map((register) => register());
651
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), ...registeredNames])]);
652
+ toolsRegistered = true;
653
+ }
654
+
550
655
  // --- Flags / lifecycle ---
551
656
 
552
657
  pi.registerFlag("fff-mode", {
@@ -576,34 +681,54 @@ export default function fffExtension(pi: ExtensionAPI) {
576
681
  type: "boolean",
577
682
  });
578
683
 
579
- pi.on("session_start", async (_event, ctx) => {
580
- try {
581
- activeCwd = ctx.cwd;
582
- uiCtx = ctx as unknown as typeof uiCtx;
583
-
584
- // Restore persisted mode from session entries. This handles session
585
- // resume after process restart where env vars are lost, and ensures
586
- // the env var is set for the next /reload in the same session.
587
- const entries = ctx.sessionManager?.getEntries();
588
- if (entries) {
589
- const modeEntry = [...entries]
590
- .reverse()
591
- .find(
592
- (e: { type: string; customType?: string }) =>
593
- e.type === "custom" && e.customType === "fff-mode",
594
- );
595
- if (
596
- modeEntry &&
597
- typeof (modeEntry as any).data?.mode === "string" &&
598
- VALID_MODES.includes((modeEntry as any).data.mode as FffMode)
599
- ) {
600
- const restored = (modeEntry as any).data.mode as FffMode;
601
- if (restored !== currentMode) {
602
- currentMode = restored;
603
- }
604
- }
684
+ pi.registerFlag("fff-warn-home-scan", {
685
+ description:
686
+ "Warn when indexing $HOME (default true; silence with --fff-warn-home-scan=false or FFF_WARN_HOME_SCAN=0)",
687
+ type: "boolean",
688
+ });
689
+
690
+ function reportInitFailure(ctx: ExtensionContext, error: unknown): void {
691
+ ctx.ui.notify(
692
+ `FFF init failed: ${error instanceof Error ? error.message : String(error)}`,
693
+ "error",
694
+ );
695
+ }
696
+
697
+ function prepareSession(ctx: ExtensionContext): void {
698
+ activeCwd = ctx.cwd;
699
+ uiCtx = ctx;
700
+ if (toolsRegistered) return;
701
+
702
+ // Pi populates extension flag values after loading extensions.
703
+ resolveStartupConfig();
704
+
705
+ // Restore persisted mode before registering tools so a saved override
706
+ // can safely change their names after /reload or session resume.
707
+ const entries = ctx.sessionManager?.getEntries();
708
+ if (entries) {
709
+ const modeEntry = [...entries]
710
+ .reverse()
711
+ .find(
712
+ (e: { type: string; customType?: string }) =>
713
+ e.type === "custom" && e.customType === "fff-mode",
714
+ );
715
+ if (
716
+ modeEntry &&
717
+ typeof (modeEntry as any).data?.mode === "string" &&
718
+ VALID_MODES.includes((modeEntry as any).data.mode as FffMode)
719
+ ) {
720
+ const restored = (modeEntry as any).data.mode as FffMode;
721
+ if (restored !== currentMode) setMode(restored);
605
722
  }
723
+ }
606
724
 
725
+ initializeFinderFactories();
726
+ registerPendingTools();
727
+ }
728
+
729
+ pi.on("session_start", async (_event, ctx) => {
730
+ try {
731
+ prepareSession(ctx);
607
732
  registerAutocompleteProvider(ctx);
608
733
  await ensureFinder(activeCwd);
609
734
 
@@ -621,11 +746,19 @@ export default function fffExtension(pi: ExtensionAPI) {
621
746
  // waitForScan() also resolves on timeout, so poll until the scan really
622
747
  // settles before clearing the footer.
623
748
  if (atHome) trackHomeScanStatus();
624
- } catch (e: unknown) {
625
- ctx.ui.notify(
626
- `FFF init failed: ${e instanceof Error ? e.message : String(e)}`,
627
- "error",
628
- );
749
+ } catch (error: unknown) {
750
+ reportInitFailure(ctx, error);
751
+ }
752
+ });
753
+
754
+ // SDK callers can prompt without binding session_start. Prepare on the first
755
+ // agent turn as a fallback so the tools still reach that turn's tool set.
756
+ pi.on("before_agent_start", (_event, ctx) => {
757
+ if (toolsRegistered) return;
758
+ try {
759
+ prepareSession(ctx);
760
+ } catch (error: unknown) {
761
+ reportInitFailure(ctx, error);
629
762
  }
630
763
  });
631
764
 
@@ -701,16 +834,14 @@ export default function fffExtension(pi: ExtensionAPI) {
701
834
  ),
702
835
  });
703
836
 
704
- pi.registerTool({
705
- name: toolNames.grep,
706
- label: toolNames.grep,
837
+ queueTool(() => toolNames.grep, {
707
838
  description: `Grep file contents. Smart-case, auto-detects regex vs literal, git-aware. Results are ranked by frecency (most-accessed files first); matches within a file stay in source order. Default limit ${DEFAULT_GREP_LIMIT}.`,
708
839
  promptSnippet: "Grep contents",
709
- promptGuidelines: [
710
- `${toolNames.grep}: prefer bare identifiers as patterns. Literal queries are most efficient.`,
711
- `${toolNames.grep}: use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').`,
712
- `${toolNames.grep}: caseSensitive: true when you need exact case (smart-case otherwise).`,
713
- `${toolNames.grep}: after 1-2 greps, read the top match instead of more greps.`,
840
+ promptGuidelines: (names) => [
841
+ `${names.grep}: prefer bare identifiers as patterns. Literal queries are most efficient.`,
842
+ `${names.grep}: use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').`,
843
+ `${names.grep}: caseSensitive: true when you need exact case (smart-case otherwise).`,
844
+ `${names.grep}: after 1-2 greps, read the top match instead of more greps.`,
714
845
  ],
715
846
  parameters: grepSchema,
716
847
 
@@ -722,8 +853,9 @@ export default function fffExtension(pi: ExtensionAPI) {
722
853
 
723
854
  const picker = aux ? aux.finder : await ensureFinder(activeCwd);
724
855
  const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
725
- // pageSize caps TOTAL matches across all files; maxMatchesPerFile alone
726
- // only caps per-file, so limit=5 could still return a full SDK page.
856
+ // pageSize caps TOTAL matches across all files (soft cap: the current file
857
+ // is always finished first). maxMatchesPerFile stays decoupled at the engine
858
+ // default so same-file overflow remains reachable via cursor (#825).
727
859
  const pageSize = Math.min(effectiveLimit, GREP_PAGE_SIZE_MAX);
728
860
  const context = clampContext(params.context);
729
861
  const query = aux
@@ -773,7 +905,7 @@ export default function fffExtension(pi: ExtensionAPI) {
773
905
  const grepResult = picker.grep(query, {
774
906
  mode,
775
907
  smartCase,
776
- maxMatchesPerFile: pageSize,
908
+ maxMatchesPerFile: GREP_MAX_MATCHES_PER_FILE,
777
909
  pageSize,
778
910
  cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
779
911
  beforeContext: context,
@@ -806,7 +938,7 @@ export default function fffExtension(pi: ExtensionAPI) {
806
938
  const fuzzy = picker.grep(fuzzyQuery, {
807
939
  mode: "fuzzy",
808
940
  smartCase,
809
- maxMatchesPerFile: pageSize,
941
+ maxMatchesPerFile: GREP_MAX_MATCHES_PER_FILE,
810
942
  pageSize,
811
943
  cursor: null,
812
944
  beforeContext: 0,
@@ -892,18 +1024,16 @@ export default function fffExtension(pi: ExtensionAPI) {
892
1024
  ),
893
1025
  });
894
1026
 
895
- pi.registerTool({
896
- name: toolNames.find,
897
- label: toolNames.find,
1027
+ queueTool(() => toolNames.find, {
898
1028
  description: `Fuzzy path search and glob search. Matches against the whole repo-relative path, not just the filename. Frecency-ranked, git-aware. Multi-word = narrower (AND). Default limit ${DEFAULT_FIND_LIMIT}.`,
899
1029
  promptSnippet: "Find files by path or glob",
900
- promptGuidelines: [
901
- `${toolNames.find}: matches the WHOLE path, not just the filename — \`profile\` hits \`chrome/browser/profiles/x.cc\` too.`,
902
- `${toolNames.find}: keep queries to 1-2 terms; extra words narrow.`,
903
- `${toolNames.find}: use for paths, not content. Use ${toolNames.grep} for content.`,
904
- `${toolNames.find}: for exact path matches use a glob in \`path\` — e.g. path: '**/profile.h' for exact filename, or path: 'src/**/profile.h' scoped to a subtree. Bare patterns are fuzzy.`,
905
- `${toolNames.find}: to list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.`,
906
- `${toolNames.find}: use exclude: 'test/,*.min.js' to cut noise in large repos.`,
1030
+ promptGuidelines: (names) => [
1031
+ `${names.find}: matches the WHOLE path, not just the filename — \`profile\` hits \`chrome/browser/profiles/x.cc\` too.`,
1032
+ `${names.find}: keep queries to 1-2 terms; extra words narrow.`,
1033
+ `${names.find}: use for paths, not content. Use ${names.grep} for content.`,
1034
+ `${names.find}: for exact path matches use a glob in \`path\` — e.g. path: '**/profile.h' for exact filename, or path: 'src/**/profile.h' scoped to a subtree. Bare patterns are fuzzy.`,
1035
+ `${names.find}: to list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.`,
1036
+ `${names.find}: use exclude: 'test/,*.min.js' to cut noise in large repos.`,
907
1037
  ],
908
1038
  parameters: findSchema,
909
1039
 
@@ -912,10 +1042,12 @@ export default function fffExtension(pi: ExtensionAPI) {
912
1042
 
913
1043
  // if resumed we use the same picker as before
914
1044
  const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
1045
+ const pool = auxPool;
1046
+ if (!pool) throw new Error("FFF auxiliary finder pool is not initialized");
915
1047
  const aux = resumed
916
1048
  ? resumed.auxRoot
917
1049
  ? {
918
- finder: (await auxPool.acquire(resumed.auxRoot, { exact: true })).finder,
1050
+ finder: (await pool.acquire(resumed.auxRoot, { exact: true })).finder,
919
1051
  root: resumed.auxRoot,
920
1052
  }
921
1053
  : null
@@ -1032,16 +1164,14 @@ export default function fffExtension(pi: ExtensionAPI) {
1032
1164
  cursor: Type.Optional(Type.String({ description: "Pagination cursor" })),
1033
1165
  });
1034
1166
 
1035
- pi.registerTool({
1036
- name: toolNames.multiGrep,
1037
- label: toolNames.multiGrep,
1167
+ queueTool(() => toolNames.multiGrep, {
1038
1168
  description:
1039
1169
  "Search file contents for ANY of multiple literal patterns (OR, SIMD Aho-Corasick). Faster than regex alternation.",
1040
1170
  promptSnippet: "Multi-pattern OR content search",
1041
- promptGuidelines: [
1042
- `${toolNames.multiGrep}: use when searching for several identifiers at once.`,
1043
- `${toolNames.multiGrep}: include all naming-convention variants (snake/camel/Pascal).`,
1044
- `${toolNames.multiGrep}: patterns are literal. Use constraints for file filters.`,
1171
+ promptGuidelines: (names) => [
1172
+ `${names.multiGrep}: use when searching for several identifiers at once.`,
1173
+ `${names.multiGrep}: include all naming-convention variants (snake/camel/Pascal).`,
1174
+ `${names.multiGrep}: patterns are literal. Use constraints for file filters.`,
1045
1175
  ],
1046
1176
  parameters: multiGrepSchema,
1047
1177
 
@@ -1058,7 +1188,7 @@ export default function fffExtension(pi: ExtensionAPI) {
1058
1188
  const grepResult = f.multiGrep({
1059
1189
  patterns: params.patterns,
1060
1190
  constraints: params.constraints,
1061
- maxMatchesPerFile: pageSize,
1191
+ maxMatchesPerFile: GREP_MAX_MATCHES_PER_FILE,
1062
1192
  pageSize,
1063
1193
  smartCase: true,
1064
1194
  cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
@@ -1116,6 +1246,15 @@ export default function fffExtension(pi: ExtensionAPI) {
1116
1246
  pi.registerCommand("fff-mode", {
1117
1247
  description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
1118
1248
  handler: async (args, ctx) => {
1249
+ if (!toolsRegistered) {
1250
+ try {
1251
+ prepareSession(ctx);
1252
+ } catch (error: unknown) {
1253
+ reportInitFailure(ctx, error);
1254
+ return;
1255
+ }
1256
+ }
1257
+
1119
1258
  const arg = (args || "").trim();
1120
1259
 
1121
1260
  // No args - show current mode
@@ -1134,15 +1273,18 @@ export default function fffExtension(pi: ExtensionAPI) {
1134
1273
 
1135
1274
  const newMode = arg as FffMode;
1136
1275
  const oldMode = getMode();
1137
- setMode(newMode);
1138
-
1139
1276
  pi.appendEntry("fff-mode", { mode: newMode });
1140
1277
 
1141
- const note =
1142
- (oldMode === "override") !== (newMode === "override")
1143
- ? " (tool name change requires /reload)"
1144
- : "";
1145
- ctx.ui.notify(`Mode changed: '${oldMode}' → '${newMode}'${note}`, "info");
1278
+ if ((oldMode === "override") !== (newMode === "override")) {
1279
+ ctx.ui.notify(
1280
+ `Mode '${newMode}' saved. Run /reload to apply the tool name change.`,
1281
+ "info",
1282
+ );
1283
+ return;
1284
+ }
1285
+
1286
+ setMode(newMode);
1287
+ ctx.ui.notify(`Mode changed: '${oldMode}' → '${newMode}'`, "info");
1146
1288
  },
1147
1289
  });
1148
1290
 
package/src/paths.ts CHANGED
@@ -52,7 +52,7 @@ function nvimDataDir(): string {
52
52
  return path.join(HOME_DIR, ".local", "share", "nvim");
53
53
  }
54
54
 
55
- function piDataDir(): string {
55
+ export function piDataDir(): string {
56
56
  return process.env.PI_CODING_AGENT_DIR ?? path.join(HOME_DIR, ".pi", "agent");
57
57
  }
58
58