Compare commits

...

2 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 34758e1ac7 fix: Redesign the aux finder 2026-07-15 16:21:56 -07:00
gustav-fff 5d69126e51 fix(pi-fff): route out-of-workspace path constraints to a rotating aux finder pool
Hotfix prototype for #463. When the agent passes an absolute `path`
outside the workspace cwd to ffgrep/fffind, spin up (or reuse) a
FileFinder rooted at that path instead of throwing
"Path constraint must be relative to the workspace".

Pool keeps at most 3 aux finders, LRU-evicted, dropped after 5 minutes
of inactivity. Find pagination cursors carry the aux root so resumes
hit the same finder.
2026-07-15 16:13:04 -07:00
8 changed files with 622 additions and 309 deletions
-210
View File
@@ -1303,174 +1303,6 @@
"zod-to-json-schema": "^3.25.0"
}
},
"node_modules/@oven/bun-darwin-aarch64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz",
"integrity": "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@oven/bun-darwin-x64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.10.tgz",
"integrity": "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@oven/bun-darwin-x64-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.10.tgz",
"integrity": "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@oven/bun-linux-aarch64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.10.tgz",
"integrity": "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-aarch64-musl": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.10.tgz",
"integrity": "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.10.tgz",
"integrity": "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.10.tgz",
"integrity": "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64-musl": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.10.tgz",
"integrity": "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64-musl-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.10.tgz",
"integrity": "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-windows-aarch64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.10.tgz",
"integrity": "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@oven/bun-windows-x64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.10.tgz",
"integrity": "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@oven/bun-windows-x64-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.10.tgz",
"integrity": "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -2641,41 +2473,6 @@
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/bun": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/bun/-/bun-1.3.10.tgz",
"integrity": "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA==",
"cpu": [
"arm64",
"x64"
],
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"peer": true,
"bin": {
"bun": "bin/bun.exe",
"bunx": "bin/bunx.exe"
},
"optionalDependencies": {
"@oven/bun-darwin-aarch64": "1.3.10",
"@oven/bun-darwin-x64": "1.3.10",
"@oven/bun-darwin-x64-baseline": "1.3.10",
"@oven/bun-linux-aarch64": "1.3.10",
"@oven/bun-linux-aarch64-musl": "1.3.10",
"@oven/bun-linux-x64": "1.3.10",
"@oven/bun-linux-x64-baseline": "1.3.10",
"@oven/bun-linux-x64-musl": "1.3.10",
"@oven/bun-linux-x64-musl-baseline": "1.3.10",
"@oven/bun-windows-aarch64": "1.3.10",
"@oven/bun-windows-x64": "1.3.10",
"@oven/bun-windows-x64-baseline": "1.3.10"
}
},
"node_modules/bun-types": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.10.tgz",
@@ -4307,10 +4104,6 @@
"linux",
"win32"
],
"bin": {
"fff-demo": "examples/search.ts",
"fff-grep": "examples/grep.ts"
},
"devDependencies": {
"@types/bun": "^1.3.8",
"typescript": "^5.0.0"
@@ -4327,9 +4120,6 @@
"@ff-labs/fff-bin-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bin-win32-arm64": "0.0.0",
"@ff-labs/fff-bin-win32-x64": "0.0.0"
},
"peerDependencies": {
"bun": ">=1.0.0"
}
},
"packages/fff-bun/node_modules/@ff-labs/fff-bin-darwin-arm64": {
+159
View File
@@ -0,0 +1,159 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { FileFinderApi } from "@ff-labs/fff-node";
import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk";
export const MAX_AUX = 3;
export const IDLE_TTL_MS = 5 * 60 * 1000;
interface AuxPicker {
root: string;
finder: FileFinderApi;
lastUsed: number;
}
export interface AuxOpts {
frecencyDbPath?: string;
historyDbPath?: string;
enableFsRootScanning: boolean;
}
export class AuxFinderPool {
private entries: AuxPicker[] = [];
constructor(private opts: AuxOpts) {}
destroy(): void {
for (const e of this.entries) {
e.finder.destroy();
}
this.entries = [];
}
private sweepIdle(now = Date.now()): void {
const kept: AuxPicker[] = [];
for (const e of this.entries) {
if (now - e.lastUsed > IDLE_TTL_MS) {
if (!e.finder.isDestroyed) e.finder.destroy();
} else {
kept.push(e);
}
}
this.entries = kept;
}
async acquire(
maybeRoot: string,
opts?: { exact?: boolean },
): Promise<{ finder: FileFinderApi; root: string }> {
this.sweepIdle();
let covering: AuxPicker | null = null;
for (const e of this.entries) {
if (e.finder.isDestroyed) continue;
if (opts?.exact ? e.root !== maybeRoot : !rootCovers(e.root, maybeRoot)) continue;
if (!covering || e.root.length > covering.root.length) covering = e;
}
if (covering) {
covering.lastUsed = Date.now();
return { finder: covering.finder, root: covering.root };
}
if (this.entries.length >= MAX_AUX) {
let oldest = this.entries[0];
for (const e of this.entries)
if (e.lastUsed < oldest.lastUsed) oldest = e;
if (!oldest.finder.isDestroyed) oldest.finder.destroy();
this.entries = this.entries.filter((e) => e !== oldest);
}
const { FileFinder } = await loadSdk();
const result = FileFinder.create({
basePath: maybeRoot,
frecencyDbPath: this.opts.frecencyDbPath,
historyDbPath: this.opts.historyDbPath,
aiMode: true,
enableHomeDirScanning: true,
enableFsRootScanning: this.opts.enableFsRootScanning,
});
if (!result.ok)
throw new Error(
`Failed to create aux file finder for ${maybeRoot}: ${result.error}`,
);
await result.value.waitForScan(SCAN_TIMEOUT_MS);
this.entries.push({ root: maybeRoot, finder: result.value, lastUsed: Date.now() });
return { finder: result.value, root: maybeRoot };
}
size(): number {
this.sweepIdle();
return this.entries.length;
}
}
// Split an absolute path into an existing directory (the aux root) and a
// remainder usable as a fuzzy path constraint relative to that root. Glob and
// nonexistent segments both go into the suffix: we walk up to the nearest
// existing ancestor so partially-wrong paths still resolve to a search root.
export function resolveAuxRoot(
absPath: string,
): { root: string; suffix: string } | null {
const trimmed = path.normalize(absPath.trim()).replace(/\/+$/, "") || "/";
if (!path.isAbsolute(trimmed)) return null;
if (trimmed === path.sep) return { root: path.sep, suffix: "" };
const parts = trimmed.split(path.sep);
const firstGlob = parts.findIndex((p) => /[*?[{]/.test(p));
const boundary = firstGlob === -1 ? parts.length : firstGlob;
// Deepest existing non-glob prefix wins; everything below it is suffix.
for (let i = boundary; i > 0; i--) {
const candidate = parts.slice(0, i).join(path.sep) || path.sep;
let stat: fs.Stats;
try {
stat = fs.statSync(candidate);
} catch {
continue;
}
if (stat.isFile()) {
return {
root: parts.slice(0, i - 1).join(path.sep) || path.sep,
suffix: parts.slice(i - 1).join("/"),
};
}
return { root: candidate, suffix: parts.slice(i).join("/") };
}
return null;
}
// Decide whether a `path` parameter should route to the workspace finder or
// to an aux finder. Accepts absolute paths, `~`-prefixed paths, and relative
// paths escaping the workspace (`../other-project`); everything is resolved
// against cwd first. Returns null to signal "no rerouting" (workspace finder).
export function routePathConstraint(
pathConstraint: string | undefined,
cwd: string,
): { root: string; suffix: string } | null {
if (!pathConstraint) return null;
let candidate = pathConstraint.trim();
if (!candidate) return null;
if (candidate === "~" || candidate.startsWith("~/"))
candidate = path.join(os.homedir(), candidate.slice(1));
if (!path.isAbsolute(candidate)) {
// Plain workspace-relative constraints stay on the workspace finder.
if (candidate !== ".." && !candidate.startsWith("../")) return null;
candidate = path.resolve(cwd, candidate);
}
const rel = path.relative(cwd, candidate);
if (rel !== ".." && !rel.startsWith(`..${path.sep}`)) return null;
return resolveAuxRoot(candidate);
}
export function rootCovers(root: string, target: string): boolean {
if (root === target) return true;
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
return target.startsWith(prefix);
}
+168 -98
View File
@@ -5,6 +5,7 @@
* @-mention autocomplete suggestions to the interactive editor.
*/
import nodePath from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
type AutocompleteItem,
@@ -16,44 +17,15 @@ import type {
GrepCursor,
GrepMode,
GrepResult,
InitOptions,
MixedItem,
Result,
SearchResult,
} from "@ff-labs/fff-node";
import { Type } from "@sinclair/typebox";
import { AuxFinderPool, routePathConstraint } from "./aux-finders";
import { buildQuery } from "./query";
import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk";
// Isomorphic SDK loading. pi hosts run under either bun (omp / oh-my-pi) or
// node; the two SDKs implement the same FileFinderApi. Static imports of
// @ff-labs/fff-node pull ffi-rs + optional native binaries into the module
// graph, which trips oh-my-pi's static extension validator (issue #668). We
// defeat static resolution with a variable-name dynamic import.
type FileFinderStatic = {
create(options: InitOptions): Result<FileFinderApi>;
};
let sdkPromise: Promise<{ FileFinder: FileFinderStatic }> | null = null;
function detectRuntime(): "bun" | "node" {
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return "bun";
if (
typeof process !== "undefined" &&
(process as { versions?: { bun?: string } }).versions?.bun
)
return "bun";
return "node";
}
function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> {
if (sdkPromise) return sdkPromise;
// Fail loud on wrong-runtime SDK rather than falling back — a fallback would
// re-introduce the ffi-rs cost on bun (the whole point of the bun SDK is to
// avoid it) and mask packaging bugs where the correct SDK wasn't installed.
const pkg = detectRuntime() === "bun" ? "@ff-labs/fff-bun" : "@ff-labs/fff-node";
sdkPromise = import(pkg) as Promise<{ FileFinder: FileFinderStatic }>;
return sdkPromise;
}
export { SCAN_TIMEOUT_MS } from "./sdk";
// ---------------------------------------------------------------------------
// Constants
@@ -118,6 +90,7 @@ interface FindCursor {
pattern: string;
pageSize: number;
nextPageIndex: number;
auxRoot?: string;
}
const findCursorCache = new Map<string, FindCursor>();
@@ -288,7 +261,9 @@ function createFffMentionProvider(
const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
const items = await getItems(query, options.signal);
return options.signal.aborted || items.length === 0 ? null : { items, prefix };
return options.signal.aborted || items.length === 0
? null
: { items, prefix };
},
applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
const currentLine = _lines[cursorLine] || "";
@@ -297,7 +272,11 @@ function createFffMentionProvider(
const newLine = before + item.value + after;
const newCursorCol = cursorCol - prefix.length + item.value.length;
return {
lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)],
lines: [
..._lines.slice(0, cursorLine),
newLine,
..._lines.slice(cursorLine + 1),
],
cursorLine,
cursorCol: newCursorCol,
};
@@ -310,7 +289,7 @@ function createFffMentionProvider(
// ---------------------------------------------------------------------------
export default function fffExtension(pi: ExtensionAPI) {
let finder: FileFinderApi | null = null;
let mainFinder: FileFinderApi | null = null;
let finderCwd: string | null = null;
// Concurrent ensureFinder() callers share the same in-flight promise so
// FileFinder.create() (which takes native DB locks) runs at most once per
@@ -364,15 +343,23 @@ export default function fffExtension(pi: ExtensionAPI) {
return currentMode !== "tools-only";
}
let auxPool = new AuxFinderPool({
frecencyDbPath,
historyDbPath,
enableFsRootScanning,
});
// in case cwd changes we need to figure this out
function ensureFinder(cwd: string): Promise<FileFinderApi> {
if (finder && !finder.isDestroyed && finderCwd === cwd)
return Promise.resolve(finder);
if (mainFinder && !mainFinder.isDestroyed && finderCwd === cwd)
return Promise.resolve(mainFinder);
if (finderPromise) return finderPromise;
finderPromise = (async () => {
if (finder && !finder.isDestroyed) {
finder.destroy();
finder = null;
if (mainFinder && !mainFinder.isDestroyed) {
mainFinder.destroy();
mainFinder = null;
finderCwd = null;
}
@@ -389,10 +376,10 @@ export default function fffExtension(pi: ExtensionAPI) {
if (!result.ok)
throw new Error(`Failed to create FFF file finder: ${result.error}`);
finder = result.value;
mainFinder = result.value;
finderCwd = cwd;
await finder.waitForScan(15000);
return finder;
await mainFinder.waitForScan(SCAN_TIMEOUT_MS);
return mainFinder;
})().finally(() => {
finderPromise = null;
});
@@ -401,11 +388,33 @@ export default function fffExtension(pi: ExtensionAPI) {
}
function destroyFinder() {
if (finder && !finder.isDestroyed) {
finder.destroy();
finder = null;
if (mainFinder && !mainFinder.isDestroyed) {
mainFinder.destroy();
mainFinder = null;
finderCwd = null;
}
if (auxPool) {
auxPool.destroy();
}
}
async function resolveFinderForPath(
pathParam: string | undefined,
pattern: string,
exclude: string | string[] | undefined,
): Promise<{ finder: FileFinderApi; query: string; root: string } | null> {
const route = routePathConstraint(pathParam, activeCwd);
if (!route) return null;
const aux = await auxPool.acquire(route.root);
// A broader covering picker may have been reused; rebase the suffix so the
// constraint stays relative to the picker's actual root.
const rebase = nodePath
.relative(aux.root, route.root)
.replaceAll(nodePath.sep, "/");
const suffix = [rebase, route.suffix].filter(Boolean).join("/");
const query = buildQuery(suffix || undefined, pattern, exclude, aux.root);
return { finder: aux.finder, query, root: aux.root };
}
async function getMentionItems(
@@ -419,20 +428,22 @@ export default function fffExtension(pi: ExtensionAPI) {
const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
if (!result.ok) return [];
return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
if (mixed.type === "directory") {
return result.value.items
.slice(0, MENTION_MAX_RESULTS)
.map((mixed: MixedItem) => {
if (mixed.type === "directory") {
return {
value: buildAtCompletionValue(mixed.item.relativePath),
label: mixed.item.dirName,
description: mixed.item.relativePath,
};
}
return {
value: buildAtCompletionValue(mixed.item.relativePath),
label: mixed.item.dirName,
label: mixed.item.fileName,
description: mixed.item.relativePath,
};
}
return {
value: buildAtCompletionValue(mixed.item.relativePath),
label: mixed.item.fileName,
description: mixed.item.relativePath,
};
});
});
}
function registerAutocompleteProvider(ctx: {
@@ -468,11 +479,21 @@ export default function fffExtension(pi: ExtensionAPI) {
return current.getSuggestions(lines, cursorLine, cursorCol, options);
},
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
return current.applyCompletion(
lines,
cursorLine,
cursorCol,
item,
prefix,
);
},
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
return (
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true
current.shouldTriggerFileCompletion?.(
lines,
cursorLine,
cursorCol,
) ?? true
);
},
};
@@ -487,12 +508,14 @@ export default function fffExtension(pi: ExtensionAPI) {
});
pi.registerFlag("fff-frecency-db", {
description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
description:
"Path to the frecency database (overrides FFF_FRECENCY_DB env)",
type: "string",
});
pi.registerFlag("fff-history-db", {
description: "Path to the query history database (overrides FFF_HISTORY_DB env)",
description:
"Path to the query history database (overrides FFF_HISTORY_DB env)",
type: "string",
});
@@ -552,15 +575,20 @@ export default function fffExtension(pi: ExtensionAPI) {
context: any,
maxLines = 15,
) => {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
const text =
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const output =
result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
if (!output) {
text.setText(theme.fg("muted", "No output"));
return text;
}
const lines = output.split("\n");
const displayLines = lines.slice(0, options.expanded ? lines.length : maxLines);
const displayLines = lines.slice(
0,
options.expanded ? lines.length : maxLines,
);
let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (lines.length > displayLines.length) {
content += theme.fg(
@@ -581,7 +609,7 @@ export default function fffExtension(pi: ExtensionAPI) {
path: Type.Optional(
Type.String({
description:
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
"Path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Absolute, ~/, and ../ paths outside the workspace are also supported and searched with a separate index.",
}),
),
exclude: Type.Optional(
@@ -625,18 +653,29 @@ export default function fffExtension(pi: ExtensionAPI) {
async execute(_toolCallId, params, signal) {
if (signal?.aborted) throw new Error("Operation aborted");
const f = await ensureFinder(activeCwd);
const pattern = params.pattern;
const aux = await resolveFinderForPath(
params.path,
pattern,
params.exclude,
);
const picker = aux ? aux.finder : await ensureFinder(activeCwd);
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
const query = buildQuery(params.path, params.pattern, params.exclude, activeCwd);
const query = aux
? aux.query
: buildQuery(params.path, pattern, params.exclude, activeCwd);
// Auto-detect: regex if the pattern has regex metacharacters AND parses
// as a valid regex, otherwise plain literal. The fuzzy fallback below
// only kicks in for plain mode — regex queries are intentional.
const hasRegexSyntax =
params.pattern !== params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
pattern !== pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
if (mode === "regex") {
try {
new RegExp(params.pattern);
new RegExp(pattern);
} catch {
mode = "plain";
}
@@ -645,7 +684,7 @@ export default function fffExtension(pi: ExtensionAPI) {
// Guard: the agent keeps calling grep with '.*' or similar wildcard-only regex
// to try to read a whole file. That's not what grep is for — return a terse error
// steering them to a real pattern, preventing dozens of wasted retries.
const p = params.pattern.trim();
const p = pattern.trim();
const isWildcardOnly =
hasRegexSyntax &&
/^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
@@ -668,7 +707,7 @@ export default function fffExtension(pi: ExtensionAPI) {
// (case-insensitive when pattern is all lowercase).
const smartCase = params.caseSensitive !== true;
const grepResult = f.grep(query, {
const grepResult = picker.grep(query, {
mode,
smartCase,
maxMatchesPerFile: Math.min(effectiveLimit, 50),
@@ -685,7 +724,7 @@ export default function fffExtension(pi: ExtensionAPI) {
// automatic fuzzy fallback allows to broad the queries and find different cases
if (result.items.length === 0 && !params.cursor && mode !== "regex") {
const fuzzy = f.grep(params.pattern, {
const fuzzy = picker.grep(pattern, {
mode: "fuzzy",
smartCase,
maxMatchesPerFile: Math.min(effectiveLimit, 50),
@@ -704,10 +743,14 @@ export default function fffExtension(pi: ExtensionAPI) {
let output = formatGrepOutput(result);
const notices: string[] = [];
if (result.regexFallbackError) {
notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
notices.push(
`Invalid regex: ${result.regexFallbackError}, used literal match`,
);
}
if (result.nextCursor) {
notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
notices.push(
`Continue with cursor="${storeCursor(result.nextCursor)}"`,
);
}
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
@@ -723,7 +766,8 @@ export default function fffExtension(pi: ExtensionAPI) {
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const text =
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const pattern = args?.pattern ?? "";
const path = args?.path ?? ".";
let content =
@@ -753,7 +797,7 @@ export default function fffExtension(pi: ExtensionAPI) {
path: Type.Optional(
Type.String({
description:
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
"Path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Absolute, ~/, and ../ paths outside the workspace are also supported and searched with a separate index.",
}),
),
exclude: Type.Optional(
@@ -790,21 +834,38 @@ export default function fffExtension(pi: ExtensionAPI) {
async execute(_toolCallId, params, signal) {
if (signal?.aborted) throw new Error("Operation aborted");
const f = await ensureFinder(activeCwd);
// Resume from a prior cursor if supplied — cursor owns query+pageSize so
// the agent can't accidentally mix patterns across pages.
// if resumed we use the same picker as before
const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
const aux = resumed
? resumed.auxRoot
? {
finder: (await auxPool.acquire(resumed.auxRoot, { exact: true }))
.finder,
root: resumed.auxRoot,
}
: null
: await resolveFinderForPath(
params.path,
params.pattern,
params.exclude,
);
const picker = aux ? aux.finder : await ensureFinder(activeCwd);
const effectiveLimit = resumed
? resumed.pageSize
: Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
const query = resumed
? resumed.query
: buildQuery(params.path, params.pattern, params.exclude, activeCwd);
: aux && "query" in aux
? (aux as { query: string }).query
: buildQuery(params.path, params.pattern, params.exclude, activeCwd);
const pattern = resumed ? resumed.pattern : params.pattern;
const pageIndex = resumed?.nextPageIndex ?? 0;
const auxRoot = resumed?.auxRoot ?? aux?.root;
const searchResult = f.fileSearch(query, {
const searchResult = picker.fileSearch(query, {
pageIndex,
pageSize: effectiveLimit,
});
@@ -819,7 +880,8 @@ export default function fffExtension(pi: ExtensionAPI) {
// shown so far there's another page to fetch.
const shownSoFar = pageIndex * effectiveLimit + result.items.length;
const hasMore =
result.items.length >= effectiveLimit && result.totalMatched > shownSoFar;
result.items.length >= effectiveLimit &&
result.totalMatched > shownSoFar;
const notices: string[] = [];
if (formatted.weak && formatted.shownCount > 0)
@@ -834,6 +896,7 @@ export default function fffExtension(pi: ExtensionAPI) {
pattern,
pageSize: effectiveLimit,
nextPageIndex: pageIndex + 1,
auxRoot,
});
notices.push(
`${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
@@ -853,7 +916,8 @@ export default function fffExtension(pi: ExtensionAPI) {
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const text =
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const pattern = args?.pattern ?? "";
const path = args?.path ?? ".";
let content =
@@ -886,7 +950,9 @@ export default function fffExtension(pi: ExtensionAPI) {
constraints: Type.Optional(
Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }),
),
context: Type.Optional(Type.Number({ description: "Context lines before+after" })),
context: Type.Optional(
Type.Number({ description: "Context lines before+after" }),
),
limit: Type.Optional(
Type.Number({
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
@@ -952,7 +1018,8 @@ export default function fffExtension(pi: ExtensionAPI) {
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const text =
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const patterns = args?.patterns ?? [];
const constraints = args?.constraints;
let content =
@@ -974,7 +1041,8 @@ export default function fffExtension(pi: ExtensionAPI) {
// --- commands ---
pi.registerCommand("fff-mode", {
description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
description:
"Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
handler: async (args, ctx) => {
const arg = (args || "").trim();
@@ -988,7 +1056,10 @@ export default function fffExtension(pi: ExtensionAPI) {
// Validate and set mode
if (!VALID_MODES.includes(arg as FffMode)) {
ctx.ui.notify(`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, "warning");
ctx.ui.notify(
`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`,
"warning",
);
return;
}
@@ -1009,28 +1080,27 @@ export default function fffExtension(pi: ExtensionAPI) {
pi.registerCommand("fff-health", {
description: "Show FFF file finder health and status",
handler: async (_args, ctx) => {
if (!finder || finder.isDestroyed) {
if (!mainFinder || mainFinder.isDestroyed) {
ctx.ui.notify("FFF not initialized", "warning");
return;
}
const health = finder.healthCheck();
const health = mainFinder.healthCheck();
if (!health.ok) {
ctx.ui.notify(`Health check failed: ${health.error}`, "error");
return;
}
const h = health.value;
const lines = [
`FFF v${h.version}`,
`FFF v${health.value.version}`,
`Mode: ${getMode()}`,
`Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
`Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
`Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
`Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
`Git: ${health.value.git.repositoryFound ? `yes (${health.value.git.workdir ?? "unknown"})` : "no"}`,
`Picker: ${health.value.filePicker.initialized ? `${health.value.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
`Frecency: ${health.value.frecency.initialized ? "active" : "disabled"}`,
`Query tracker: ${health.value.queryTracker.initialized ? "active" : "disabled"}`,
];
const progress = finder.getScanProgress();
const progress = mainFinder.getScanProgress();
if (progress.ok) {
lines.push(
`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
@@ -1044,12 +1114,12 @@ export default function fffExtension(pi: ExtensionAPI) {
pi.registerCommand("fff-rescan", {
description: "Trigger FFF to rescan files",
handler: async (_args, ctx) => {
if (!finder || finder.isDestroyed) {
if (!mainFinder || mainFinder.isDestroyed) {
ctx.ui.notify("FFF not initialized", "warning");
return;
}
const result = finder.scanFiles();
const result = mainFinder.scanFiles();
if (!result.ok) {
ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
return;
+8 -1
View File
@@ -10,7 +10,11 @@ export function normalizePathConstraint(
if (path.isAbsolute(trimmed)) {
const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
if (relative === "") return null;
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
if (
relative.startsWith("../") ||
relative === ".." ||
path.isAbsolute(relative)
) {
throw new Error(
`Path constraint must be relative to the workspace: ${pathConstraint}`,
);
@@ -22,6 +26,9 @@ export function normalizePathConstraint(
// Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
// wif we left with the ** it means anything so treat it as a cwd path
if (trimmed === "**" || trimmed === "**/" || trimmed === "**/*") return null;
// FFF's glob matcher can treat a hidden directory root glob such as
// `.agents/**` as empty, while the tool contract says this means "inside
// this directory". Collapse simple trailing recursive directory globs to the
+29
View File
@@ -0,0 +1,29 @@
import type { FileFinderApi, InitOptions, Result } from "@ff-labs/fff-node";
export const SCAN_TIMEOUT_MS = 15_000;
/** pi can be run either under node or sdk, we resolve correct SDK version at runtime */
export type FileFinderStatic = {
create(options: InitOptions): Result<FileFinderApi>;
};
let sdkPromise: Promise<{ FileFinder: FileFinderStatic }> | null = null;
function detectRuntime(): "bun" | "node" {
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return "bun";
if (
typeof process !== "undefined" &&
(process as { versions?: { bun?: string } }).versions?.bun
)
return "bun";
return "node";
}
export function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> {
if (sdkPromise) return sdkPromise;
// default to node as it seems like default option
const pkg = detectRuntime() === "bun" ? "@ff-labs/fff-bun" : "@ff-labs/fff-node";
sdkPromise = import(pkg) as Promise<{ FileFinder: FileFinderStatic }>;
return sdkPromise;
}
+161
View File
@@ -0,0 +1,161 @@
import { afterAll, describe, expect, test } from "bun:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
resolveAuxRoot,
rootCovers,
routePathConstraint,
} from "../src/aux-finders";
describe("routePathConstraint", () => {
const cwd = "/tmp/workspace";
test("returns null for workspace-relative paths", () => {
expect(routePathConstraint("src/", cwd)).toBeNull();
expect(routePathConstraint("**/*.ts", cwd)).toBeNull();
expect(routePathConstraint(undefined, cwd)).toBeNull();
// Dot-prefixed names are not parent escapes.
expect(routePathConstraint("..foo/bar", cwd)).toBeNull();
});
test("returns null for absolute paths inside the workspace", () => {
expect(routePathConstraint("/tmp/workspace/src", cwd)).toBeNull();
});
test("routes absolute paths outside the workspace", () => {
const route = routePathConstraint("/tmp", cwd);
expect(route).toEqual({ root: "/tmp", suffix: "" });
});
test("splits glob suffix from existing dir prefix", () => {
const route = routePathConstraint("/tmp/**/*.ts", cwd);
expect(route).toEqual({ root: "/tmp", suffix: "**/*.ts" });
});
test("walks up to existing ancestor when tail does not exist", () => {
const route = routePathConstraint("/tmp/__nonexistent_dir_xyz__", cwd);
expect(route).toEqual({ root: "/tmp", suffix: "__nonexistent_dir_xyz__" });
});
test("expands ~ to the home directory", () => {
expect(routePathConstraint("~", cwd)).toEqual({
root: os.homedir(),
suffix: "",
});
expect(routePathConstraint("~/__fff_nope__", cwd)).toEqual({
root: os.homedir(),
suffix: "__fff_nope__",
});
});
describe("relative paths escaping the workspace", () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), "fff-route-"));
const workspace = path.join(base, "workspace");
const sibling = path.join(base, "fff-demo");
fs.mkdirSync(workspace);
fs.mkdirSync(sibling);
afterAll(() => {
fs.rmSync(base, { recursive: true, force: true });
});
test("routes ../sibling to the sibling directory", () => {
expect(routePathConstraint("../fff-demo/", workspace)).toEqual({
root: sibling,
suffix: "",
});
});
test("routes .. to the parent directory", () => {
expect(routePathConstraint("..", workspace)).toEqual({
root: base,
suffix: "",
});
});
test("keeps globs in the suffix", () => {
expect(routePathConstraint("../fff-demo/**/*.ts", workspace)).toEqual({
root: sibling,
suffix: "**/*.ts",
});
});
test("walks up when the sibling does not exist", () => {
expect(routePathConstraint("../missing-xyz/src", workspace)).toEqual({
root: base,
suffix: "missing-xyz/src",
});
});
test("returns null when .. resolves back inside the workspace", () => {
expect(
routePathConstraint("../workspace/src", workspace),
).toBeNull();
});
});
});
describe("rootCovers", () => {
test("covers itself and descendants only", () => {
expect(rootCovers("/a/b", "/a/b")).toBe(true);
expect(rootCovers("/a/b", "/a/b/c")).toBe(true);
expect(rootCovers("/", "/a")).toBe(true);
expect(rootCovers("/a/b", "/a")).toBe(false);
expect(rootCovers("/a/b", "/a/bc")).toBe(false);
});
});
describe("resolveAuxRoot", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fff-aux-"));
const tmpFile = path.join(tmpDir, "real-file.ts");
fs.writeFileSync(tmpFile, "");
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("returns null for relative input", () => {
expect(resolveAuxRoot("src/")).toBeNull();
});
test("existing directory becomes the root with empty suffix", () => {
expect(resolveAuxRoot(tmpDir)).toEqual({ root: tmpDir, suffix: "" });
});
test("existing file roots at its parent with basename suffix", () => {
expect(resolveAuxRoot(tmpFile)).toEqual({
root: tmpDir,
suffix: "real-file.ts",
});
});
test("nonexistent tail becomes a fuzzy suffix", () => {
expect(resolveAuxRoot(path.join(tmpDir, "nope", "foo.ts"))).toEqual({
root: tmpDir,
suffix: "nope/foo.ts",
});
});
test("glob after nonexistent segment stays in the suffix", () => {
expect(resolveAuxRoot(path.join(tmpDir, "nope", "**", "*.ts"))).toEqual({
root: tmpDir,
suffix: "nope/**/*.ts",
});
});
test("fully bogus path walks up to filesystem root", () => {
expect(resolveAuxRoot("/__fff_nope__/x/y.ts")).toEqual({
root: "/",
suffix: "__fff_nope__/x/y.ts",
});
});
test("bare filesystem root resolves to itself", () => {
expect(resolveAuxRoot("/")).toEqual({ root: "/", suffix: "" });
});
test("trailing slashes are ignored", () => {
expect(resolveAuxRoot(`${tmpDir}/`)).toEqual({ root: tmpDir, suffix: "" });
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, mock, test } from "bun:test";
interface MockFinder {
isDestroyed: boolean;
basePath: string;
waitForScan: (ms: number) => Promise<void>;
destroy: () => void;
}
const created: MockFinder[] = [];
function createMockFinder(basePath: string): MockFinder {
const finder: MockFinder = {
isDestroyed: false,
basePath,
waitForScan: async () => {},
destroy: () => {
finder.isDestroyed = true;
},
};
created.push(finder);
return finder;
}
const finderModule = {
FileFinder: {
create: (options: { basePath: string }) => ({
ok: true,
value: createMockFinder(options.basePath),
}),
},
};
mock.module("@ff-labs/fff-node", () => finderModule);
mock.module("@ff-labs/fff-bun", () => finderModule);
const { AuxFinderPool } = await import("../src/aux-finders");
function makePool() {
created.length = 0;
return new AuxFinderPool({ enableFsRootScanning: false });
}
describe("AuxFinderPool covering reuse", () => {
test("reuses a picker rooted at an ancestor of the requested path", async () => {
const pool = makePool();
const a = await pool.acquire("/a/b/c");
expect(a.root).toBe("/a/b/c");
const b = await pool.acquire("/a/b/c/d");
expect(b.finder).toBe(a.finder);
expect(b.root).toBe("/a/b/c");
expect(created.length).toBe(1);
});
test("does not reuse a picker rooted deeper than the requested path", async () => {
const pool = makePool();
await pool.acquire("/a/b/c");
const broad = await pool.acquire("/a/b");
expect(broad.root).toBe("/a/b");
expect(created.length).toBe(2);
});
test("prefers the deepest covering picker", async () => {
const pool = makePool();
const narrow = await pool.acquire("/a/b/c");
await pool.acquire("/a/b");
const again = await pool.acquire("/a/b/c/src");
expect(again.finder).toBe(narrow.finder);
expect(again.root).toBe("/a/b/c");
});
test("exact mode skips ancestor reuse", async () => {
const pool = makePool();
await pool.acquire("/a/b");
const exact = await pool.acquire("/a/b/c", { exact: true });
expect(exact.root).toBe("/a/b/c");
expect(created.length).toBe(2);
});
test("does not treat sibling prefixes as covering", async () => {
const pool = makePool();
await pool.acquire("/a/bc");
const other = await pool.acquire("/a/b");
expect(other.root).toBe("/a/b");
expect(created.length).toBe(2);
});
});
+8
View File
@@ -68,4 +68,12 @@ describe("path constraint normalization", () => {
"src/**/*.ts",
);
});
test("bare recursive globs constrain nothing", () => {
expect(normalizePathConstraint("**", cwd)).toBeNull();
expect(normalizePathConstraint("**/", cwd)).toBeNull();
expect(normalizePathConstraint("**/*", cwd)).toBeNull();
expect(normalizePathConstraint("./**", cwd)).toBeNull();
expect(buildQuery("**", "needle", undefined, cwd)).toBe("needle");
});
});