@nklisch/pi-fff-compat 0.1.1 → 0.1.2

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.
@@ -26,6 +26,7 @@ import { statSync } from "node:fs";
26
26
  import path from "node:path";
27
27
  import { Type } from "@earendil-works/pi-ai";
28
28
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
29
+ import { createGenerationGuardedFinderLifecycle } from "./finder-lifecycle.js";
29
30
 
30
31
  const EXTENSION_NAME = "pi-fff-compat";
31
32
  const PACKAGE_HINT = "bundled dependency of @nklisch/pi-fff-compat";
@@ -154,9 +155,6 @@ type Truncation = {
154
155
  };
155
156
 
156
157
  let fffModulePromise: Promise<FffModule> | null = null;
157
- let finder: FffFinder | null = null;
158
- let finderCwd: string | null = null;
159
- let finderPromise: { cwd: string; promise: Promise<FffFinder> } | null = null;
160
158
  let activeCwd = process.cwd();
161
159
 
162
160
  function envFlagEnabled(value: string | undefined): boolean {
@@ -195,7 +193,7 @@ function loadFffModule(): Promise<FffModule> {
195
193
  if (!fffModulePromise) {
196
194
  fffModulePromise = (async () => {
197
195
  try {
198
- return (await import("@ff-labs/fff-node")) as FffModule;
196
+ return (await import("@ff-labs/fff-node")) as unknown as FffModule;
199
197
  } catch (error) {
200
198
  throw new Error(
201
199
  `Failed to load @ff-labs/fff-node (${PACKAGE_HINT}): ${error instanceof Error ? error.message : String(error)}`,
@@ -206,44 +204,38 @@ function loadFffModule(): Promise<FffModule> {
206
204
  return fffModulePromise;
207
205
  }
208
206
 
209
- async function ensureFinder(cwd: string): Promise<FffFinder> {
210
- if (finder && !finder.isDestroyed && finderCwd === cwd) return finder;
211
- if (finderPromise && finderPromise.cwd === cwd) return finderPromise.promise;
212
-
213
- finderPromise = {
214
- cwd,
215
- promise: (async () => {
216
- destroyFinder();
217
- const { FileFinder } = await loadFffModule();
218
- const created = FileFinder.create({
219
- basePath: cwd,
220
- frecencyDbPath: process.env.FFF_FRECENCY_DB,
221
- historyDbPath: process.env.FFF_HISTORY_DB,
222
- aiMode: true,
223
- enableHomeDirScanning: envFlagEnabled(process.env[HOME_SCAN_ENV]),
224
- enableFsRootScanning: envFlagEnabled(process.env.FFF_ENABLE_ROOT_SCAN),
225
- disableWatch: envFlagEnabled(process.env[DISABLE_WATCH_ENV]),
226
- });
227
- if (!created.ok) throw new Error(`Failed to create FFF finder: ${created.error}`);
228
-
229
- finder = created.value;
230
- finderCwd = cwd;
231
-
232
- const scan = await finder.waitForScan(INITIAL_SCAN_WAIT_MS);
233
- if (!scan.ok) throw new Error(`FFF scan failed: ${scan.error}`);
234
- return finder;
235
- })().finally(() => {
236
- finderPromise = null;
237
- }),
238
- };
207
+ const finderLifecycle = createGenerationGuardedFinderLifecycle<FffFinder>(async (cwd) => {
208
+ const { FileFinder } = await loadFffModule();
209
+ const created = FileFinder.create({
210
+ basePath: cwd,
211
+ frecencyDbPath: process.env.FFF_FRECENCY_DB,
212
+ historyDbPath: process.env.FFF_HISTORY_DB,
213
+ aiMode: true,
214
+ enableHomeDirScanning: envFlagEnabled(process.env[HOME_SCAN_ENV]),
215
+ enableFsRootScanning: envFlagEnabled(process.env.FFF_ENABLE_ROOT_SCAN),
216
+ disableWatch: envFlagEnabled(process.env[DISABLE_WATCH_ENV]),
217
+ });
218
+ if (!created.ok) throw new Error(`Failed to create FFF finder: ${created.error}`);
239
219
 
240
- return finderPromise.promise;
241
- }
220
+ const candidate = created.value;
221
+ try {
222
+ const scan = await candidate.waitForScan(INITIAL_SCAN_WAIT_MS);
223
+ if (!scan.ok) throw new Error(`FFF scan failed: ${scan.error}`);
224
+ return candidate;
225
+ } catch (error) {
226
+ if (!candidate.isDestroyed) {
227
+ try {
228
+ candidate.destroy();
229
+ } catch {
230
+ // Preserve the scan/create failure; lifecycle cleanup is best effort.
231
+ }
232
+ }
233
+ throw error;
234
+ }
235
+ });
242
236
 
243
- function destroyFinder(): void {
244
- if (finder && !finder.isDestroyed) finder.destroy();
245
- finder = null;
246
- finderCwd = null;
237
+ function ensureFinder(cwd: string): Promise<FffFinder> {
238
+ return finderLifecycle.ensure(cwd);
247
239
  }
248
240
 
249
241
  function truncateLine(line: string): { text: string; wasTruncated: boolean } {
@@ -408,6 +400,21 @@ const findSchema = Type.Object({
408
400
  limit: Type.Optional(Type.Number({ description: `Maximum number of results (default ${DEFAULT_FIND_LIMIT})` })),
409
401
  });
410
402
 
403
+ function reportInitializationFailure(ctx: ExtensionContext, error: unknown): void {
404
+ let detail = "unknown failure";
405
+ try { detail = error instanceof Error ? error.message : String(error); }
406
+ catch { detail = "unreadable failure"; }
407
+ const message = `${EXTENSION_NAME} init failed: ${detail}`;
408
+ try {
409
+ ctx.ui.notify(message, "error");
410
+ } catch {
411
+ // Initialization can settle after session replacement. The old UI is then
412
+ // intentionally stale, so fall back to a process diagnostic without
413
+ // allowing either reporting sink to escape the awaited host boundary.
414
+ try { console.error(message); } catch { /* no safe reporting sink remains */ }
415
+ }
416
+ }
417
+
411
418
  const grepSchema = Type.Object({
412
419
  pattern: Type.String({ description: "Search pattern" }),
413
420
  path: Type.Optional(Type.String({ description: "Directory or file to search in (default: current directory)" })),
@@ -449,12 +456,12 @@ export default function fffCompatSearch(pi: ExtensionAPI) {
449
456
  try {
450
457
  await ensureFinder(activeCwd);
451
458
  } catch (error) {
452
- ctx.ui.notify(`${EXTENSION_NAME} init failed: ${error instanceof Error ? error.message : String(error)}`, "error");
459
+ reportInitializationFailure(ctx, error);
453
460
  }
454
461
  });
455
462
 
456
463
  pi.on("session_shutdown", async () => {
457
- destroyFinder();
464
+ finderLifecycle.revoke();
458
465
  });
459
466
 
460
467
  pi.registerTool({
@@ -0,0 +1,92 @@
1
+ export interface FinderLike {
2
+ readonly isDestroyed: boolean;
3
+ destroy(): void;
4
+ }
5
+
6
+ interface CurrentFinder<F extends FinderLike> {
7
+ cwd: string;
8
+ generation: number;
9
+ finder: F;
10
+ }
11
+
12
+ interface PendingFinder<F extends FinderLike> {
13
+ cwd: string;
14
+ generation: number;
15
+ promise: Promise<F>;
16
+ }
17
+
18
+ /**
19
+ * Own the asynchronous finder lifecycle separately from the Pi event seam.
20
+ * A scan can outlive session_shutdown; the generation check prevents that late
21
+ * completion from becoming the finder for a replacement session.
22
+ */
23
+ export function createGenerationGuardedFinderLifecycle<F extends FinderLike>(
24
+ createFinder: (cwd: string) => Promise<F>,
25
+ ): {
26
+ ensure(cwd: string): Promise<F>;
27
+ revoke(): void;
28
+ } {
29
+ let generation = 0;
30
+ let current: CurrentFinder<F> | null = null;
31
+ let pending: PendingFinder<F> | null = null;
32
+
33
+ function destroyQuietly(candidate: F | null): void {
34
+ if (candidate === null || candidate.isDestroyed) return;
35
+ // A stale finder has no live session context in which to report a cleanup
36
+ // failure. Do not let best-effort shutdown cleanup reject the host event.
37
+ try {
38
+ candidate.destroy();
39
+ } catch {
40
+ // The lifecycle is already revoked; there is no safe session state to
41
+ // repopulate, which is the guarantee this cleanup protects.
42
+ }
43
+ }
44
+
45
+ function revoke(): void {
46
+ generation++;
47
+ const old = current;
48
+ current = null;
49
+ destroyQuietly(old?.finder ?? null);
50
+ }
51
+
52
+ function ensure(cwd: string): Promise<F> {
53
+ if (current?.finder.isDestroyed) current = null;
54
+ if (current?.cwd === cwd) return Promise.resolve(current.finder);
55
+ if (pending?.cwd === cwd && pending.generation === generation) return pending.promise;
56
+
57
+ // A different workspace, or a new session after revocation, invalidates
58
+ // every earlier initialization. Its late result will be destroyed below.
59
+ generation++;
60
+ const requestedGeneration = generation;
61
+ const old = current;
62
+ current = null;
63
+ destroyQuietly(old?.finder ?? null);
64
+
65
+ const initialization = (async (): Promise<F> => {
66
+ let candidate: F | null = null;
67
+ try {
68
+ candidate = await createFinder(cwd);
69
+ if (requestedGeneration !== generation) {
70
+ throw new Error("FFF finder initialization was revoked");
71
+ }
72
+ current = { cwd, generation: requestedGeneration, finder: candidate };
73
+ return candidate;
74
+ } catch (error) {
75
+ // If creation itself fails after allocating a finder, the factory owns
76
+ // cleanup. A completed-but-invalidated candidate is closed exactly once
77
+ // here, before its rejected promise reaches the old session callback.
78
+ if (candidate !== null && current?.finder !== candidate) destroyQuietly(candidate);
79
+ throw error;
80
+ }
81
+ })();
82
+
83
+ let guardedPromise!: Promise<F>;
84
+ guardedPromise = initialization.finally(() => {
85
+ if (pending?.promise === guardedPromise) pending = null;
86
+ });
87
+ pending = { cwd, generation: requestedGeneration, promise: guardedPromise };
88
+ return guardedPromise;
89
+ }
90
+
91
+ return { ensure, revoke };
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nklisch/pi-fff-compat",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Fast FFF-backed file search through Pi-native find/grep semantics — glob-only file lookup and exact regex/literal grep with no fuzzy fallback.",
5
5
  "author": {
6
6
  "name": "nklisch"