@caupulican/pi-adaptative 0.80.99 → 0.80.102

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.
Files changed (129) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/dist/core/agent-session.d.ts +72 -0
  3. package/dist/core/agent-session.d.ts.map +1 -1
  4. package/dist/core/agent-session.js +437 -46
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/autonomy/envelope-enforcement.d.ts +5 -0
  7. package/dist/core/autonomy/envelope-enforcement.d.ts.map +1 -1
  8. package/dist/core/autonomy/envelope-enforcement.js +28 -4
  9. package/dist/core/autonomy/envelope-enforcement.js.map +1 -1
  10. package/dist/core/autonomy/path-scope.d.ts.map +1 -1
  11. package/dist/core/autonomy/path-scope.js +33 -2
  12. package/dist/core/autonomy/path-scope.js.map +1 -1
  13. package/dist/core/bash-executor.d.ts.map +1 -1
  14. package/dist/core/bash-executor.js +15 -7
  15. package/dist/core/bash-executor.js.map +1 -1
  16. package/dist/core/context/brain-curator.d.ts +10 -0
  17. package/dist/core/context/brain-curator.d.ts.map +1 -1
  18. package/dist/core/context/brain-curator.js +19 -2
  19. package/dist/core/context/brain-curator.js.map +1 -1
  20. package/dist/core/context-gc.d.ts +8 -3
  21. package/dist/core/context-gc.d.ts.map +1 -1
  22. package/dist/core/context-gc.js +5 -0
  23. package/dist/core/context-gc.js.map +1 -1
  24. package/dist/core/delegation/worker-actions.d.ts +0 -10
  25. package/dist/core/delegation/worker-actions.d.ts.map +1 -1
  26. package/dist/core/delegation/worker-actions.js +5 -7
  27. package/dist/core/delegation/worker-actions.js.map +1 -1
  28. package/dist/core/delegation/worker-result.d.ts +6 -0
  29. package/dist/core/delegation/worker-result.d.ts.map +1 -1
  30. package/dist/core/delegation/worker-result.js +10 -4
  31. package/dist/core/delegation/worker-result.js.map +1 -1
  32. package/dist/core/delegation/worker-runner.d.ts +3 -0
  33. package/dist/core/delegation/worker-runner.d.ts.map +1 -1
  34. package/dist/core/delegation/worker-runner.js +6 -1
  35. package/dist/core/delegation/worker-runner.js.map +1 -1
  36. package/dist/core/doctor.d.ts +66 -0
  37. package/dist/core/doctor.d.ts.map +1 -0
  38. package/dist/core/doctor.js +144 -0
  39. package/dist/core/doctor.js.map +1 -0
  40. package/dist/core/learning/learning-audit.d.ts +13 -1
  41. package/dist/core/learning/learning-audit.d.ts.map +1 -1
  42. package/dist/core/learning/learning-audit.js +22 -1
  43. package/dist/core/learning/learning-audit.js.map +1 -1
  44. package/dist/core/learning/learning-gate.d.ts +3 -0
  45. package/dist/core/learning/learning-gate.d.ts.map +1 -1
  46. package/dist/core/learning/learning-gate.js +13 -4
  47. package/dist/core/learning/learning-gate.js.map +1 -1
  48. package/dist/core/models/default-model-suggestions.d.ts +34 -0
  49. package/dist/core/models/default-model-suggestions.d.ts.map +1 -0
  50. package/dist/core/models/default-model-suggestions.js +58 -0
  51. package/dist/core/models/default-model-suggestions.js.map +1 -0
  52. package/dist/core/models/local-registration.d.ts +2 -0
  53. package/dist/core/models/local-registration.d.ts.map +1 -1
  54. package/dist/core/models/local-registration.js +2 -1
  55. package/dist/core/models/local-registration.js.map +1 -1
  56. package/dist/core/models/local-runtime.d.ts +76 -2
  57. package/dist/core/models/local-runtime.d.ts.map +1 -1
  58. package/dist/core/models/local-runtime.js +233 -21
  59. package/dist/core/models/local-runtime.js.map +1 -1
  60. package/dist/core/profile-resource-selection.d.ts +15 -3
  61. package/dist/core/profile-resource-selection.d.ts.map +1 -1
  62. package/dist/core/profile-resource-selection.js +19 -9
  63. package/dist/core/profile-resource-selection.js.map +1 -1
  64. package/dist/core/resource-loader.d.ts +20 -0
  65. package/dist/core/resource-loader.d.ts.map +1 -1
  66. package/dist/core/resource-loader.js +42 -16
  67. package/dist/core/resource-loader.js.map +1 -1
  68. package/dist/core/settings-manager.d.ts +30 -0
  69. package/dist/core/settings-manager.d.ts.map +1 -1
  70. package/dist/core/settings-manager.js +65 -3
  71. package/dist/core/settings-manager.js.map +1 -1
  72. package/dist/core/skills.d.ts +14 -0
  73. package/dist/core/skills.d.ts.map +1 -1
  74. package/dist/core/skills.js +8 -0
  75. package/dist/core/skills.js.map +1 -1
  76. package/dist/core/slash-commands.d.ts.map +1 -1
  77. package/dist/core/slash-commands.js +1 -1
  78. package/dist/core/slash-commands.js.map +1 -1
  79. package/dist/core/tools/fff-search-backend.d.ts +34 -1
  80. package/dist/core/tools/fff-search-backend.d.ts.map +1 -1
  81. package/dist/core/tools/fff-search-backend.js +43 -4
  82. package/dist/core/tools/fff-search-backend.js.map +1 -1
  83. package/dist/core/tools/find.d.ts +16 -0
  84. package/dist/core/tools/find.d.ts.map +1 -1
  85. package/dist/core/tools/find.js +17 -3
  86. package/dist/core/tools/find.js.map +1 -1
  87. package/dist/core/tools/grep.d.ts +20 -0
  88. package/dist/core/tools/grep.d.ts.map +1 -1
  89. package/dist/core/tools/grep.js +15 -3
  90. package/dist/core/tools/grep.js.map +1 -1
  91. package/dist/main.d.ts.map +1 -1
  92. package/dist/main.js +17 -0
  93. package/dist/main.js.map +1 -1
  94. package/dist/modes/interactive/components/fitness-role-selector.d.ts +6 -2
  95. package/dist/modes/interactive/components/fitness-role-selector.d.ts.map +1 -1
  96. package/dist/modes/interactive/components/fitness-role-selector.js +55 -45
  97. package/dist/modes/interactive/components/fitness-role-selector.js.map +1 -1
  98. package/dist/modes/interactive/components/model-suggestion-selector.d.ts +13 -0
  99. package/dist/modes/interactive/components/model-suggestion-selector.d.ts.map +1 -0
  100. package/dist/modes/interactive/components/model-suggestion-selector.js +40 -0
  101. package/dist/modes/interactive/components/model-suggestion-selector.js.map +1 -0
  102. package/dist/modes/interactive/components/profile-resource-editor.d.ts +3 -0
  103. package/dist/modes/interactive/components/profile-resource-editor.d.ts.map +1 -1
  104. package/dist/modes/interactive/components/profile-resource-editor.js +16 -2
  105. package/dist/modes/interactive/components/profile-resource-editor.js.map +1 -1
  106. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  107. package/dist/modes/interactive/components/settings-selector.js +98 -0
  108. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  109. package/dist/modes/interactive/interactive-mode.d.ts +8 -1
  110. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  111. package/dist/modes/interactive/interactive-mode.js +57 -10
  112. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  113. package/dist/utils/safe-write-stream.d.ts +8 -0
  114. package/dist/utils/safe-write-stream.d.ts.map +1 -1
  115. package/dist/utils/safe-write-stream.js +32 -0
  116. package/dist/utils/safe-write-stream.js.map +1 -1
  117. package/dist/utils/tools-manager.d.ts +92 -2
  118. package/dist/utils/tools-manager.d.ts.map +1 -1
  119. package/dist/utils/tools-manager.js +172 -32
  120. package/dist/utils/tools-manager.js.map +1 -1
  121. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  122. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  123. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  124. package/examples/extensions/sandbox/package-lock.json +2 -2
  125. package/examples/extensions/sandbox/package.json +1 -1
  126. package/examples/extensions/with-deps/package-lock.json +2 -2
  127. package/examples/extensions/with-deps/package.json +1 -1
  128. package/npm-shrinkwrap.json +12 -36
  129. package/package.json +4 -4
@@ -7,4 +7,12 @@ import { type WriteStream } from "node:fs";
7
7
  * an uncaught exception. Errors are reported through the optional callback instead.
8
8
  */
9
9
  export declare function createSafeWriteStream(path: string, onError?: (error: Error) => void): WriteStream;
10
+ /**
11
+ * End a write stream and resolve only once its buffered data has been flushed and the file closed, so
12
+ * a subsequent read of the path sees the COMPLETE content. `stream.end()` alone is fire-and-forget:
13
+ * the flush runs asynchronously, so returning a temp-file path right after `.end()` lets a fast reader
14
+ * observe a partial (or empty) file. Resolves (never rejects) on "error" too — best-effort artifact
15
+ * writes must not hang or crash the caller; the caller drops the artifact on error separately.
16
+ */
17
+ export declare function endWriteStream(stream: WriteStream): Promise<void>;
10
18
  //# sourceMappingURL=safe-write-stream.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"safe-write-stream.d.ts","sourceRoot":"","sources":["../../src/utils/safe-write-stream.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAE9D;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,WAAW,CAMjG","sourcesContent":["import { createWriteStream, type WriteStream } from \"node:fs\";\n\n/**\n * Create a WriteStream whose \"error\" event is always handled.\n *\n * Best-effort artifact writes (full-output temp files, overflow spills) must never\n * crash the host process: an fs.WriteStream \"error\" event with no listener becomes\n * an uncaught exception. Errors are reported through the optional callback instead.\n */\nexport function createSafeWriteStream(path: string, onError?: (error: Error) => void): WriteStream {\n\tconst stream = createWriteStream(path);\n\tstream.on(\"error\", (error: Error) => {\n\t\tonError?.(error);\n\t});\n\treturn stream;\n}\n"]}
1
+ {"version":3,"file":"safe-write-stream.d.ts","sourceRoot":"","sources":["../../src/utils/safe-write-stream.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAE9D;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,WAAW,CAMjG;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBjE","sourcesContent":["import { createWriteStream, type WriteStream } from \"node:fs\";\n\n/**\n * Create a WriteStream whose \"error\" event is always handled.\n *\n * Best-effort artifact writes (full-output temp files, overflow spills) must never\n * crash the host process: an fs.WriteStream \"error\" event with no listener becomes\n * an uncaught exception. Errors are reported through the optional callback instead.\n */\nexport function createSafeWriteStream(path: string, onError?: (error: Error) => void): WriteStream {\n\tconst stream = createWriteStream(path);\n\tstream.on(\"error\", (error: Error) => {\n\t\tonError?.(error);\n\t});\n\treturn stream;\n}\n\n/**\n * End a write stream and resolve only once its buffered data has been flushed and the file closed, so\n * a subsequent read of the path sees the COMPLETE content. `stream.end()` alone is fire-and-forget:\n * the flush runs asynchronously, so returning a temp-file path right after `.end()` lets a fast reader\n * observe a partial (or empty) file. Resolves (never rejects) on \"error\" too — best-effort artifact\n * writes must not hang or crash the caller; the caller drops the artifact on error separately.\n */\nexport function endWriteStream(stream: WriteStream): Promise<void> {\n\treturn new Promise((resolve) => {\n\t\t// A stream that already reached a terminal state (finished, destroyed, closed, or\n\t\t// errored) before this call will never re-emit \"close\"/\"finish\"/\"error\": EventEmitter\n\t\t// does not replay past events, so listeners attached below would wait forever. Resolve\n\t\t// immediately instead of racing events that already happened.\n\t\tif (stream.writableFinished || stream.destroyed || stream.closed || stream.errored !== null) {\n\t\t\tresolve();\n\t\t\treturn;\n\t\t}\n\t\tlet settled = false;\n\t\tconst done = () => {\n\t\t\tif (settled) return;\n\t\t\tsettled = true;\n\t\t\tresolve();\n\t\t};\n\t\tstream.once(\"close\", done);\n\t\tstream.once(\"finish\", done);\n\t\tstream.once(\"error\", done);\n\t\tif (!stream.writableEnded) {\n\t\t\tstream.end();\n\t\t}\n\t});\n}\n"]}
@@ -13,4 +13,36 @@ export function createSafeWriteStream(path, onError) {
13
13
  });
14
14
  return stream;
15
15
  }
16
+ /**
17
+ * End a write stream and resolve only once its buffered data has been flushed and the file closed, so
18
+ * a subsequent read of the path sees the COMPLETE content. `stream.end()` alone is fire-and-forget:
19
+ * the flush runs asynchronously, so returning a temp-file path right after `.end()` lets a fast reader
20
+ * observe a partial (or empty) file. Resolves (never rejects) on "error" too — best-effort artifact
21
+ * writes must not hang or crash the caller; the caller drops the artifact on error separately.
22
+ */
23
+ export function endWriteStream(stream) {
24
+ return new Promise((resolve) => {
25
+ // A stream that already reached a terminal state (finished, destroyed, closed, or
26
+ // errored) before this call will never re-emit "close"/"finish"/"error": EventEmitter
27
+ // does not replay past events, so listeners attached below would wait forever. Resolve
28
+ // immediately instead of racing events that already happened.
29
+ if (stream.writableFinished || stream.destroyed || stream.closed || stream.errored !== null) {
30
+ resolve();
31
+ return;
32
+ }
33
+ let settled = false;
34
+ const done = () => {
35
+ if (settled)
36
+ return;
37
+ settled = true;
38
+ resolve();
39
+ };
40
+ stream.once("close", done);
41
+ stream.once("finish", done);
42
+ stream.once("error", done);
43
+ if (!stream.writableEnded) {
44
+ stream.end();
45
+ }
46
+ });
47
+ }
16
48
  //# sourceMappingURL=safe-write-stream.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"safe-write-stream.js","sourceRoot":"","sources":["../../src/utils/safe-write-stream.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAoB,MAAM,SAAS,CAAC;AAE9D;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAAY,EAAE,OAAgC,EAAe;IAClG,MAAM,MAAM,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAC;IACvC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAY,EAAE,EAAE,CAAC;QACpC,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC;IAAA,CACjB,CAAC,CAAC;IACH,OAAO,MAAM,CAAC;AAAA,CACd","sourcesContent":["import { createWriteStream, type WriteStream } from \"node:fs\";\n\n/**\n * Create a WriteStream whose \"error\" event is always handled.\n *\n * Best-effort artifact writes (full-output temp files, overflow spills) must never\n * crash the host process: an fs.WriteStream \"error\" event with no listener becomes\n * an uncaught exception. Errors are reported through the optional callback instead.\n */\nexport function createSafeWriteStream(path: string, onError?: (error: Error) => void): WriteStream {\n\tconst stream = createWriteStream(path);\n\tstream.on(\"error\", (error: Error) => {\n\t\tonError?.(error);\n\t});\n\treturn stream;\n}\n"]}
1
+ {"version":3,"file":"safe-write-stream.js","sourceRoot":"","sources":["../../src/utils/safe-write-stream.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAoB,MAAM,SAAS,CAAC;AAE9D;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAAY,EAAE,OAAgC,EAAe;IAClG,MAAM,MAAM,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAC;IACvC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAY,EAAE,EAAE,CAAC;QACpC,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC;IAAA,CACjB,CAAC,CAAC;IACH,OAAO,MAAM,CAAC;AAAA,CACd;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,MAAmB,EAAiB;IAClE,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;QAC/B,kFAAkF;QAClF,sFAAsF;QACtF,uFAAuF;QACvF,8DAA8D;QAC9D,IAAI,MAAM,CAAC,gBAAgB,IAAI,MAAM,CAAC,SAAS,IAAI,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,OAAO,KAAK,IAAI,EAAE,CAAC;YAC7F,OAAO,EAAE,CAAC;YACV,OAAO;QACR,CAAC;QACD,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,MAAM,IAAI,GAAG,GAAG,EAAE,CAAC;YAClB,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,OAAO,EAAE,CAAC;QAAA,CACV,CAAC;QACF,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QAC3B,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QAC5B,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QAC3B,IAAI,CAAC,MAAM,CAAC,aAAa,EAAE,CAAC;YAC3B,MAAM,CAAC,GAAG,EAAE,CAAC;QACd,CAAC;IAAA,CACD,CAAC,CAAC;AAAA,CACH","sourcesContent":["import { createWriteStream, type WriteStream } from \"node:fs\";\n\n/**\n * Create a WriteStream whose \"error\" event is always handled.\n *\n * Best-effort artifact writes (full-output temp files, overflow spills) must never\n * crash the host process: an fs.WriteStream \"error\" event with no listener becomes\n * an uncaught exception. Errors are reported through the optional callback instead.\n */\nexport function createSafeWriteStream(path: string, onError?: (error: Error) => void): WriteStream {\n\tconst stream = createWriteStream(path);\n\tstream.on(\"error\", (error: Error) => {\n\t\tonError?.(error);\n\t});\n\treturn stream;\n}\n\n/**\n * End a write stream and resolve only once its buffered data has been flushed and the file closed, so\n * a subsequent read of the path sees the COMPLETE content. `stream.end()` alone is fire-and-forget:\n * the flush runs asynchronously, so returning a temp-file path right after `.end()` lets a fast reader\n * observe a partial (or empty) file. Resolves (never rejects) on \"error\" too — best-effort artifact\n * writes must not hang or crash the caller; the caller drops the artifact on error separately.\n */\nexport function endWriteStream(stream: WriteStream): Promise<void> {\n\treturn new Promise((resolve) => {\n\t\t// A stream that already reached a terminal state (finished, destroyed, closed, or\n\t\t// errored) before this call will never re-emit \"close\"/\"finish\"/\"error\": EventEmitter\n\t\t// does not replay past events, so listeners attached below would wait forever. Resolve\n\t\t// immediately instead of racing events that already happened.\n\t\tif (stream.writableFinished || stream.destroyed || stream.closed || stream.errored !== null) {\n\t\t\tresolve();\n\t\t\treturn;\n\t\t}\n\t\tlet settled = false;\n\t\tconst done = () => {\n\t\t\tif (settled) return;\n\t\t\tsettled = true;\n\t\t\tresolve();\n\t\t};\n\t\tstream.once(\"close\", done);\n\t\tstream.once(\"finish\", done);\n\t\tstream.once(\"error\", done);\n\t\tif (!stream.writableEnded) {\n\t\t\tstream.end();\n\t\t}\n\t});\n}\n"]}
@@ -1,5 +1,95 @@
1
+ type ModuleRequire = ((id: string) => unknown) & {
2
+ resolve?: (id: string) => string;
3
+ };
1
4
  export declare function getToolPath(tool: "fd" | "rg"): string | null;
2
- export declare function loadAvailableFffNodePackage(): unknown | undefined;
3
- export declare function ensureFffNodePackage(silent?: boolean, forceManagedInstall?: boolean): Promise<unknown | undefined>;
5
+ /** Presence check for a SYSTEM tool the doctor only ever reports on -- never installs. */
6
+ export interface SystemToolStatus {
7
+ present: boolean;
8
+ command?: string;
9
+ version?: string;
10
+ }
11
+ /**
12
+ * Detect a usable Python interpreter. SYSTEM tool (see src/core/doctor.ts):
13
+ * the doctor reports presence/version, it never installs this itself.
14
+ *
15
+ * @param commands Override the candidate command names, for tests.
16
+ */
17
+ export declare function detectPython(commands?: readonly string[]): SystemToolStatus;
18
+ /**
19
+ * Runs `<command> --version` (or `versionArgs`) and returns its trimmed
20
+ * combined stdout+stderr, or undefined if the command can't be run. Used by
21
+ * the doctor (src/core/doctor.ts) to show a version alongside a tool it has
22
+ * already located by some other means (e.g. getToolPath("rg"), or the binary
23
+ * path OllamaRuntime.detect() reports) -- callers that only need a yes/no
24
+ * presence check should use commandExists/getToolPath/detectPython instead.
25
+ */
26
+ export declare function probeVersion(command: string, versionArgs?: readonly string[]): string | undefined;
27
+ /**
28
+ * @param requires Override the resolution candidates, for tests (mirrors
29
+ * fff-search-backend.ts's loadFffModule(requires?)). Whether this resolves
30
+ * depends on the ambient environment -- @ff-labs/fff-node is a real npm
31
+ * dependency (package.json), so moduleRequire succeeds wherever a normal
32
+ * `npm install`/`npm ci` provisioned it (e.g. CI) even though it fails on a
33
+ * dev checkout that never ran that install here (this repo resolves fff-node
34
+ * via the separate managed-dir path instead). A test asserting "nothing is
35
+ * available" must pass `[]` here rather than relying on that being true.
36
+ */
37
+ export declare function loadAvailableFffNodePackage(requires?: readonly ModuleRequire[]): unknown | undefined;
38
+ /**
39
+ * Outcome of the most recent {@link ensureFffNodePackage} call, kept for
40
+ * observability (e.g. a future `doctor` check) since the function itself
41
+ * only ever returns the loaded module or `undefined` either way.
42
+ *
43
+ * `install-failed` is distinguished from `offline`/`unsupported-platform`
44
+ * because it is the only one worth *retrying*: offline mode and an
45
+ * unsupported platform are stable for the life of the process, but a real
46
+ * install attempt can fail on a transient issue (registry hiccup, timeout)
47
+ * that may no longer apply on the next search. See
48
+ * DefaultFffSearchBackend.getFinder in fff-search-backend.ts, which uses
49
+ * this distinction to decide whether a failed finder is retryable.
50
+ */
51
+ export type FffInstallOutcome = {
52
+ status: "already-available";
53
+ } | {
54
+ status: "offline";
55
+ } | {
56
+ status: "unsupported-platform";
57
+ } | {
58
+ status: "installed";
59
+ } | {
60
+ status: "install-failed";
61
+ reason: string;
62
+ };
63
+ /**
64
+ * How long a genuine install failure gates out a NEW npm spawn. An agent turn
65
+ * can fire several find/grep calls in quick succession, and each one now
66
+ * primes the finder in the background (see tryFffFind/tryFffGrep) -- without
67
+ * this, a persistently-failing install (registry down, disk full, ...) would
68
+ * re-spawn npm on every single one of those calls instead of once.
69
+ *
70
+ * This gates the SPAWN inside ensureFffNodePackage, not whether a failed
71
+ * finder is retryable (see isFffInstallRetryable): DefaultFffSearchBackend
72
+ * always evicts a failed finder so the next search re-enters this function,
73
+ * and it is THIS cooldown check -- evaluated fresh, at call time -- that
74
+ * decides whether that re-entry is a real attempt or a fast, spawn-free bail.
75
+ * (An earlier version conflated the two: it gated eviction itself on the
76
+ * cooldown, which is checked once, immediately after the failure it's timing
77
+ * -- i.e. always still within the window -- so the failed finder was never
78
+ * evicted and the retry never got a chance to happen at all.)
79
+ */
80
+ export declare const FFF_INSTALL_RETRY_COOLDOWN_MS = 30000;
81
+ /** The outcome of the last {@link ensureFffNodePackage} call, if any. */
82
+ export declare function getLastFffInstallOutcome(): FffInstallOutcome | undefined;
83
+ /** Whether the last install outcome was a genuine failure worth retrying (as opposed to a stable "not applicable" result). Cooldown-independent by design -- see FFF_INSTALL_RETRY_COOLDOWN_MS. */
84
+ export declare function isFffInstallRetryable(): boolean;
85
+ /**
86
+ * Pure decision logic behind {@link isFffInstallCoolingDown}, exposed directly
87
+ * so tests can assert the cooldown boundary without faking the system clock.
88
+ */
89
+ export declare function computeIsFffInstallCoolingDown(outcome: FffInstallOutcome | undefined, failedAt: number | undefined, now: number): boolean;
90
+ export declare function ensureFffNodePackage(silent?: boolean, forceManagedInstall?: boolean,
91
+ /** Override the "is it already available" resolution candidates, for tests. See loadAvailableFffNodePackage's doc. */
92
+ requires?: readonly ModuleRequire[]): Promise<unknown | undefined>;
4
93
  export declare function ensureTool(tool: "fd" | "rg", silent?: boolean): Promise<string | undefined>;
94
+ export {};
5
95
  //# sourceMappingURL=tools-manager.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tools-manager.d.ts","sourceRoot":"","sources":["../../src/utils/tools-manager.ts"],"names":[],"mappings":"AAwGA,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAmB5D;AAgSD,wBAAgB,2BAA2B,IAAI,OAAO,GAAG,SAAS,CAQjE;AAuDD,wBAAsB,oBAAoB,CACzC,MAAM,GAAE,OAAe,EACvB,mBAAmB,GAAE,OAAe,GAClC,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAuB9B;AAID,wBAAsB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,EAAE,MAAM,GAAE,OAAe,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA2CxG","sourcesContent":["import chalk from \"chalk\";\nimport { type SpawnSyncReturns, spawnSync } from \"child_process\";\nimport {\n\tchmodSync,\n\tcreateWriteStream,\n\texistsSync,\n\tmkdirSync,\n\treaddirSync,\n\trenameSync,\n\trmSync,\n\twriteFileSync,\n} from \"fs\";\nimport { createRequire } from \"module\";\nimport { arch, platform } from \"os\";\nimport { dirname, join } from \"path\";\nimport { Readable } from \"stream\";\nimport { pipeline } from \"stream/promises\";\nimport { pathToFileURL } from \"url\";\nimport { APP_NAME, getBinDir } from \"../config.ts\";\nimport { spawnProcess, waitForChildProcess } from \"./child-process.ts\";\n\nconst TOOLS_DIR = getBinDir();\nconst NETWORK_TIMEOUT_MS = 10_000;\nconst DOWNLOAD_TIMEOUT_MS = 120_000;\nconst FFF_NODE_VERSION = \"0.9.6\";\nconst FFF_MANAGED_DIR = join(TOOLS_DIR, \"fff-node\");\nconst FFF_MANAGED_PACKAGE_JSON = join(FFF_MANAGED_DIR, \"package.json\");\n\ntype ModuleRequire = ((id: string) => unknown) & { resolve?: (id: string) => string };\n\nconst moduleRequire = createRequire(import.meta.url);\nconst executableDirRequire = createRequire(pathToFileURL(join(dirname(process.execPath), \"package.json\")).href);\n\nfunction isOfflineModeEnabled(): boolean {\n\tconst value = process.env.PI_OFFLINE;\n\tif (!value) return false;\n\treturn value === \"1\" || value.toLowerCase() === \"true\" || value.toLowerCase() === \"yes\";\n}\n\ninterface ToolConfig {\n\tname: string;\n\trepo: string; // GitHub repo (e.g., \"sharkdp/fd\")\n\tbinaryName: string; // Name of the binary inside the archive\n\tsystemBinaryNames?: string[]; // Alternative system command names to try before downloading\n\ttagPrefix: string; // Prefix for tags (e.g., \"v\" for v1.0.0, \"\" for 1.0.0)\n\tgetAssetName: (version: string, plat: string, architecture: string) => string | null;\n}\n\nconst TOOLS: Record<string, ToolConfig> = {\n\tfd: {\n\t\tname: \"fd\",\n\t\trepo: \"sharkdp/fd\",\n\t\tbinaryName: \"fd\",\n\t\tsystemBinaryNames: [\"fd\", \"fdfind\"],\n\t\ttagPrefix: \"v\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\trg: {\n\t\tname: \"ripgrep\",\n\t\trepo: \"BurntSushi/ripgrep\",\n\t\tbinaryName: \"rg\",\n\t\ttagPrefix: \"\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tif (architecture === \"arm64\") {\n\t\t\t\t\treturn `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;\n\t\t\t\t}\n\t\t\t\treturn `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n};\n\n// Check if a command exists in PATH by trying to run it\nfunction commandExists(cmd: string): boolean {\n\ttry {\n\t\tconst result = spawnSync(cmd, [\"--version\"], { stdio: \"pipe\" });\n\t\t// Check for ENOENT error (command not found)\n\t\treturn result.error === undefined || result.error === null;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n// Get the path to a tool (system-wide or in our tools dir)\nexport function getToolPath(tool: \"fd\" | \"rg\"): string | null {\n\tconst config = TOOLS[tool];\n\tif (!config) return null;\n\n\t// Check our tools directory first\n\tconst localPath = join(TOOLS_DIR, config.binaryName + (platform() === \"win32\" ? \".exe\" : \"\"));\n\tif (existsSync(localPath)) {\n\t\treturn localPath;\n\t}\n\n\t// Check system PATH - if found, just return the command name (it's in PATH)\n\tconst systemBinaryNames = config.systemBinaryNames ?? [config.binaryName];\n\tfor (const systemBinaryName of systemBinaryNames) {\n\t\tif (commandExists(systemBinaryName)) {\n\t\t\treturn systemBinaryName;\n\t\t}\n\t}\n\n\treturn null;\n}\n\n// Fetch latest release version from GitHub\nasync function getLatestVersion(repo: string): Promise<string> {\n\tconst response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {\n\t\theaders: { \"User-Agent\": `${APP_NAME}-coding-agent` },\n\t\tsignal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(`GitHub API error: ${response.status}`);\n\t}\n\n\tconst data = (await response.json()) as { tag_name: string };\n\treturn data.tag_name.replace(/^v/, \"\");\n}\n\n// Download a file from URL\nasync function downloadFile(url: string, dest: string): Promise<void> {\n\tconst response = await fetch(url, {\n\t\tsignal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(`Failed to download: ${response.status}`);\n\t}\n\n\tif (!response.body) {\n\t\tthrow new Error(\"No response body\");\n\t}\n\n\tconst fileStream = createWriteStream(dest);\n\tawait pipeline(Readable.fromWeb(response.body as any), fileStream);\n}\n\nfunction findBinaryRecursively(rootDir: string, binaryFileName: string): string | null {\n\tconst stack: string[] = [rootDir];\n\n\twhile (stack.length > 0) {\n\t\tconst currentDir = stack.pop();\n\t\tif (!currentDir) continue;\n\n\t\tconst entries = readdirSync(currentDir, { withFileTypes: true });\n\t\tfor (const entry of entries) {\n\t\t\tconst fullPath = join(currentDir, entry.name);\n\t\t\tif (entry.isFile() && entry.name === binaryFileName) {\n\t\t\t\treturn fullPath;\n\t\t\t}\n\t\t\tif (entry.isDirectory()) {\n\t\t\t\tstack.push(fullPath);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn null;\n}\n\nfunction formatSpawnFailure(result: SpawnSyncReturns<Buffer>): string {\n\tif (result.error?.message) {\n\t\treturn result.error.message;\n\t}\n\tconst stderr = result.stderr?.toString().trim();\n\tif (stderr) {\n\t\treturn stderr;\n\t}\n\tconst stdout = result.stdout?.toString().trim();\n\tif (stdout) {\n\t\treturn stdout;\n\t}\n\treturn `exit status ${result.status ?? \"unknown\"}`;\n}\n\nfunction runExtractionCommand(command: string, args: string[]): string | null {\n\tconst result = spawnSync(command, args, { stdio: \"pipe\" });\n\tif (!result.error && result.status === 0) {\n\t\treturn null;\n\t}\n\treturn `${command}: ${formatSpawnFailure(result)}`;\n}\n\nfunction extractTarGzArchive(archivePath: string, extractDir: string, assetName: string): void {\n\tconst failure = runExtractionCommand(\"tar\", [\"xzf\", archivePath, \"-C\", extractDir]);\n\tif (failure) {\n\t\tthrow new Error(`Failed to extract ${assetName}: ${failure}`);\n\t}\n}\n\nfunction getWindowsTarCommand(): string {\n\tconst systemRoot = process.env.SystemRoot ?? process.env.WINDIR;\n\tif (systemRoot) {\n\t\tconst systemTar = join(systemRoot, \"System32\", \"tar.exe\");\n\t\tif (existsSync(systemTar)) {\n\t\t\treturn systemTar;\n\t\t}\n\t}\n\treturn \"tar.exe\";\n}\n\nfunction extractZipArchive(archivePath: string, extractDir: string, assetName: string): void {\n\tconst failures: string[] = [];\n\n\tif (platform() === \"win32\") {\n\t\t// Windows ships bsdtar as tar.exe, which supports zip files. Prefer the\n\t\t// System32 binary over Git Bash's GNU tar, which does not handle zip archives.\n\t\tconst tarFailure = runExtractionCommand(getWindowsTarCommand(), [\"xf\", archivePath, \"-C\", extractDir]);\n\t\tif (!tarFailure) return;\n\t\tfailures.push(tarFailure);\n\n\t\tconst script =\n\t\t\t\"& { param($archive, $destination) $ErrorActionPreference = 'Stop'; Expand-Archive -LiteralPath $archive -DestinationPath $destination -Force }\";\n\t\tconst powershellFailure = runExtractionCommand(\"powershell.exe\", [\n\t\t\t\"-NoLogo\",\n\t\t\t\"-NoProfile\",\n\t\t\t\"-NonInteractive\",\n\t\t\t\"-ExecutionPolicy\",\n\t\t\t\"Bypass\",\n\t\t\t\"-Command\",\n\t\t\tscript,\n\t\t\tarchivePath,\n\t\t\textractDir,\n\t\t]);\n\t\tif (!powershellFailure) return;\n\t\tfailures.push(powershellFailure);\n\t} else {\n\t\tconst unzipFailure = runExtractionCommand(\"unzip\", [\"-q\", archivePath, \"-d\", extractDir]);\n\t\tif (!unzipFailure) return;\n\t\tfailures.push(unzipFailure);\n\n\t\tconst tarFailure = runExtractionCommand(\"tar\", [\"xf\", archivePath, \"-C\", extractDir]);\n\t\tif (!tarFailure) return;\n\t\tfailures.push(tarFailure);\n\t}\n\n\tthrow new Error(`Failed to extract ${assetName}: ${failures.join(\"; \")}`);\n}\n\n// Download and install a tool\nasync function downloadTool(tool: \"fd\" | \"rg\"): Promise<string> {\n\tconst config = TOOLS[tool];\n\tif (!config) throw new Error(`Unknown tool: ${tool}`);\n\n\tconst plat = platform();\n\tconst architecture = arch();\n\n\t// Get latest version\n\tlet version = await getLatestVersion(config.repo);\n\tif (tool === \"fd\" && plat === \"darwin\" && architecture === \"x64\") {\n\t\tversion = \"10.3.0\";\n\t}\n\n\t// Get asset name for this platform\n\tconst assetName = config.getAssetName(version, plat, architecture);\n\tif (!assetName) {\n\t\tthrow new Error(`Unsupported platform: ${plat}/${architecture}`);\n\t}\n\n\t// Create tools directory\n\tmkdirSync(TOOLS_DIR, { recursive: true });\n\n\tconst downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;\n\tconst archivePath = join(TOOLS_DIR, assetName);\n\tconst binaryExt = plat === \"win32\" ? \".exe\" : \"\";\n\tconst binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);\n\n\t// Download\n\tawait downloadFile(downloadUrl, archivePath);\n\n\t// Extract into a unique temp directory. fd and rg downloads can run concurrently\n\t// during startup, so sharing a fixed directory causes races.\n\tconst extractDir = join(\n\t\tTOOLS_DIR,\n\t\t`extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,\n\t);\n\tmkdirSync(extractDir, { recursive: true });\n\n\ttry {\n\t\tif (assetName.endsWith(\".tar.gz\")) {\n\t\t\textractTarGzArchive(archivePath, extractDir, assetName);\n\t\t} else if (assetName.endsWith(\".zip\")) {\n\t\t\textractZipArchive(archivePath, extractDir, assetName);\n\t\t} else {\n\t\t\tthrow new Error(`Unsupported archive format: ${assetName}`);\n\t\t}\n\n\t\t// Find the binary in extracted files. Some archives contain files directly\n\t\t// at root, others nest under a versioned subdirectory.\n\t\tconst binaryFileName = config.binaryName + binaryExt;\n\t\tconst extractedDir = join(extractDir, assetName.replace(/\\.(tar\\.gz|zip)$/, \"\"));\n\t\tconst extractedBinaryCandidates = [join(extractedDir, binaryFileName), join(extractDir, binaryFileName)];\n\t\tlet extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate));\n\n\t\tif (!extractedBinary) {\n\t\t\textractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined;\n\t\t}\n\n\t\tif (extractedBinary) {\n\t\t\trenameSync(extractedBinary, binaryPath);\n\t\t} else {\n\t\t\tthrow new Error(`Binary not found in archive: expected ${binaryFileName} under ${extractDir}`);\n\t\t}\n\n\t\t// Make executable (Unix only)\n\t\tif (plat !== \"win32\") {\n\t\t\tchmodSync(binaryPath, 0o755);\n\t\t}\n\t} finally {\n\t\t// Cleanup\n\t\trmSync(archivePath, { force: true });\n\t\trmSync(extractDir, { recursive: true, force: true });\n\t}\n\n\treturn binaryPath;\n}\n\n// Termux package names for tools\nconst TERMUX_PACKAGES: Record<string, string> = {\n\tfd: \"fd\",\n\trg: \"ripgrep\",\n};\n\nconst FFF_PLATFORM_PACKAGES: Record<string, string> = {\n\t\"darwin/arm64\": \"@ff-labs/fff-bin-darwin-arm64\",\n\t\"darwin/x64\": \"@ff-labs/fff-bin-darwin-x64\",\n\t\"linux/arm64/glibc\": \"@ff-labs/fff-bin-linux-arm64-gnu\",\n\t\"linux/arm64/musl\": \"@ff-labs/fff-bin-linux-arm64-musl\",\n\t\"linux/x64/glibc\": \"@ff-labs/fff-bin-linux-x64-gnu\",\n\t\"linux/x64/musl\": \"@ff-labs/fff-bin-linux-x64-musl\",\n\t\"win32/arm64\": \"@ff-labs/fff-bin-win32-arm64\",\n\t\"win32/x64\": \"@ff-labs/fff-bin-win32-x64\",\n};\n\nlet fffNodeInstallPromise: Promise<unknown | undefined> | undefined;\n\nfunction detectLinuxLibc(): \"glibc\" | \"musl\" {\n\tlet output = \"\";\n\ttry {\n\t\tconst result = spawnSync(\"ldd\", [\"--version\"], { encoding: \"utf-8\", stdio: \"pipe\", timeout: 5000 });\n\t\toutput = `${result.stdout ?? \"\"}${result.stderr ?? \"\"}`;\n\t} catch (e: unknown) {\n\t\tconst err = e as { stdout?: string | Buffer; stderr?: string | Buffer };\n\t\toutput = `${String(err.stdout ?? \"\")}${String(err.stderr ?? \"\")}`;\n\t}\n\treturn output.toLowerCase().includes(\"musl\") ? \"musl\" : \"glibc\";\n}\n\nfunction getFffPlatformPackageName(): string | undefined {\n\tconst plat = platform();\n\tconst architecture = arch();\n\tif (plat === \"linux\") {\n\t\treturn FFF_PLATFORM_PACKAGES[`${plat}/${architecture}/${detectLinuxLibc()}`];\n\t}\n\treturn FFF_PLATFORM_PACKAGES[`${plat}/${architecture}`];\n}\n\nfunction createManagedFffRequire(): ModuleRequire | undefined {\n\tif (!existsSync(FFF_MANAGED_PACKAGE_JSON)) return undefined;\n\treturn createRequire(pathToFileURL(FFF_MANAGED_PACKAGE_JSON).href);\n}\n\nfunction findFffNodeDistEntry(startPath: string): string | undefined {\n\tlet currentDir = dirname(startPath);\n\twhile (currentDir !== dirname(currentDir)) {\n\t\tconst candidate = join(currentDir, \"node_modules\", \"@ff-labs\", \"fff-node\", \"dist\", \"src\", \"index.js\");\n\t\tif (existsSync(candidate)) return candidate;\n\t\tcurrentDir = dirname(currentDir);\n\t}\n\treturn undefined;\n}\n\nfunction loadFffNodeDistEntry(requireFff: ModuleRequire): unknown | undefined {\n\tif (!requireFff.resolve) return undefined;\n\ttry {\n\t\tconst ffiPath = requireFff.resolve(\"ffi-rs\");\n\t\tconst fffEntry = findFffNodeDistEntry(ffiPath);\n\t\treturn fffEntry ? requireFff(fffEntry) : undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction loadFffNodeWith(requireFff: ModuleRequire): unknown | undefined {\n\ttry {\n\t\treturn requireFff(\"@ff-labs/fff-node\");\n\t} catch {\n\t\treturn loadFffNodeDistEntry(requireFff);\n\t}\n}\n\nexport function loadAvailableFffNodePackage(): unknown | undefined {\n\tfor (const requireFff of [moduleRequire, executableDirRequire, createManagedFffRequire()].filter(\n\t\t(candidate): candidate is ModuleRequire => Boolean(candidate),\n\t)) {\n\t\tconst loaded = loadFffNodeWith(requireFff);\n\t\tif (loaded) return loaded;\n\t}\n\treturn undefined;\n}\n\nasync function runNpmInstall(args: string[]): Promise<{ code: number | null; stderr: string }> {\n\ttry {\n\t\tconst child = spawnProcess(\"npm\", args, { stdio: [\"ignore\", \"pipe\", \"pipe\"] });\n\t\tlet stderr = \"\";\n\t\tchild.stderr?.on(\"data\", (chunk) => {\n\t\t\tstderr += chunk.toString();\n\t\t});\n\t\tconst code = await waitForChildProcess(child);\n\t\treturn { code, stderr };\n\t} catch (error) {\n\t\treturn { code: 1, stderr: error instanceof Error ? error.message : String(error) };\n\t}\n}\n\nasync function installManagedFffNodePackage(platformPackage: string, silent: boolean): Promise<unknown | undefined> {\n\tmkdirSync(FFF_MANAGED_DIR, { recursive: true });\n\tif (!existsSync(FFF_MANAGED_PACKAGE_JSON)) {\n\t\twriteFileSync(FFF_MANAGED_PACKAGE_JSON, '{\"name\":\"pi-managed-fff-node\",\"private\":true,\"version\":\"0.0.0\"}\\n');\n\t}\n\n\tif (!silent) {\n\t\tconsole.log(chalk.dim(\"FFF native search not found. Installing managed FFF package...\"));\n\t}\n\n\tconst args = [\n\t\t\"install\",\n\t\t\"--ignore-scripts\",\n\t\t\"--omit=dev\",\n\t\t\"--include=optional\",\n\t\t\"--no-audit\",\n\t\t\"--no-fund\",\n\t\t\"--package-lock=false\",\n\t\t\"--prefix\",\n\t\tFFF_MANAGED_DIR,\n\t\t`@ff-labs/fff-node@${FFF_NODE_VERSION}`,\n\t\t`${platformPackage}@${FFF_NODE_VERSION}`,\n\t];\n\tconst result = await runNpmInstall(args);\n\tif (result.code !== 0) {\n\t\tif (!silent) {\n\t\t\tconsole.log(\n\t\t\t\tchalk.yellow(`Failed to install FFF native search: ${result.stderr.trim() || `exit code ${result.code}`}`),\n\t\t\t);\n\t\t}\n\t\treturn undefined;\n\t}\n\tconst loaded = loadFffNodeWith(createRequire(pathToFileURL(FFF_MANAGED_PACKAGE_JSON).href));\n\tif (!loaded && !silent) {\n\t\tconsole.log(chalk.yellow(\"Managed FFF install completed but @ff-labs/fff-node could not be loaded.\"));\n\t}\n\treturn loaded;\n}\n\nexport async function ensureFffNodePackage(\n\tsilent: boolean = false,\n\tforceManagedInstall: boolean = false,\n): Promise<unknown | undefined> {\n\tconst existing = forceManagedInstall ? undefined : loadAvailableFffNodePackage();\n\tif (existing) return existing;\n\n\tif (isOfflineModeEnabled()) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(\"FFF native search not found. Offline mode enabled, skipping install.\"));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tconst platformPackage = getFffPlatformPackageName();\n\tif (!platformPackage) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`FFF native search is not available for ${platform()}/${arch()}.`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tfffNodeInstallPromise ??= installManagedFffNodePackage(platformPackage, silent).finally(() => {\n\t\tfffNodeInstallPromise = undefined;\n\t});\n\treturn fffNodeInstallPromise;\n}\n\n// Ensure a tool is available, downloading if necessary\n// Returns the path to the tool, or null if unavailable\nexport async function ensureTool(tool: \"fd\" | \"rg\", silent: boolean = false): Promise<string | undefined> {\n\tconst existingPath = getToolPath(tool);\n\tif (existingPath) {\n\t\treturn existingPath;\n\t}\n\n\tconst config = TOOLS[tool];\n\tif (!config) return undefined;\n\n\tif (isOfflineModeEnabled()) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.\n\t// Users must install via pkg.\n\tif (platform() === \"android\") {\n\t\tconst pkgName = TERMUX_PACKAGES[tool] ?? tool;\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// Tool not found - download it\n\tif (!silent) {\n\t\tconsole.log(chalk.dim(`${config.name} not found. Downloading...`));\n\t}\n\n\ttry {\n\t\tconst path = await downloadTool(tool);\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.dim(`${config.name} installed to ${path}`));\n\t\t}\n\t\treturn path;\n\t} catch (e) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));\n\t\t}\n\t\treturn undefined;\n\t}\n}\n"]}
1
+ {"version":3,"file":"tools-manager.d.ts","sourceRoot":"","sources":["../../src/utils/tools-manager.ts"],"names":[],"mappings":"AA4BA,KAAK,aAAa,GAAG,CAAC,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG;IAAE,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAA;CAAE,CAAC;AA4EtF,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAmB5D;AAED,0FAA0F;AAC1F,MAAM,WAAW,gBAAgB;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAID;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,QAAQ,GAAE,SAAS,MAAM,EAAoB,GAAG,gBAAgB,CAe5F;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,GAAE,SAAS,MAAM,EAAkB,GAAG,MAAM,GAAG,SAAS,CAShH;AAgSD;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,OAAO,GAAG,SAAS,CAWpG;AAgBD;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,iBAAiB,GAC1B;IAAE,MAAM,EAAE,mBAAmB,CAAA;CAAE,GAC/B;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GACrB;IAAE,MAAM,EAAE,sBAAsB,CAAA;CAAE,GAClC;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GACvB;IAAE,MAAM,EAAE,gBAAgB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,6BAA6B,QAAS,CAAC;AAKpD,yEAAyE;AACzE,wBAAgB,wBAAwB,IAAI,iBAAiB,GAAG,SAAS,CAExE;AAED,mMAAmM;AACnM,wBAAgB,qBAAqB,IAAI,OAAO,CAE/C;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE,iBAAiB,GAAG,SAAS,EACtC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,GAAG,EAAE,MAAM,GACT,OAAO,CAIT;AAsED,wBAAsB,oBAAoB,CACzC,MAAM,GAAE,OAAe,EACvB,mBAAmB,GAAE,OAAe;AACpC,sHAAsH;AACtH,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GACjC,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAoC9B;AAID,wBAAsB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,EAAE,MAAM,GAAE,OAAe,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA2CxG","sourcesContent":["import chalk from \"chalk\";\nimport { type SpawnSyncReturns, spawnSync } from \"child_process\";\nimport {\n\tchmodSync,\n\tcreateWriteStream,\n\texistsSync,\n\tmkdirSync,\n\treaddirSync,\n\trenameSync,\n\trmSync,\n\twriteFileSync,\n} from \"fs\";\nimport { createRequire } from \"module\";\nimport { arch, platform } from \"os\";\nimport { dirname, join } from \"path\";\nimport { Readable } from \"stream\";\nimport { pipeline } from \"stream/promises\";\nimport { pathToFileURL } from \"url\";\nimport { APP_NAME, getBinDir } from \"../config.ts\";\nimport { spawnProcess, waitForChildProcess } from \"./child-process.ts\";\n\nconst TOOLS_DIR = getBinDir();\nconst NETWORK_TIMEOUT_MS = 10_000;\nconst DOWNLOAD_TIMEOUT_MS = 120_000;\nconst FFF_NODE_VERSION = \"0.9.6\";\nconst FFF_MANAGED_DIR = join(TOOLS_DIR, \"fff-node\");\nconst FFF_MANAGED_PACKAGE_JSON = join(FFF_MANAGED_DIR, \"package.json\");\n\ntype ModuleRequire = ((id: string) => unknown) & { resolve?: (id: string) => string };\n\nconst moduleRequire = createRequire(import.meta.url);\nconst executableDirRequire = createRequire(pathToFileURL(join(dirname(process.execPath), \"package.json\")).href);\n\nfunction isOfflineModeEnabled(): boolean {\n\tconst value = process.env.PI_OFFLINE;\n\tif (!value) return false;\n\treturn value === \"1\" || value.toLowerCase() === \"true\" || value.toLowerCase() === \"yes\";\n}\n\ninterface ToolConfig {\n\tname: string;\n\trepo: string; // GitHub repo (e.g., \"sharkdp/fd\")\n\tbinaryName: string; // Name of the binary inside the archive\n\tsystemBinaryNames?: string[]; // Alternative system command names to try before downloading\n\ttagPrefix: string; // Prefix for tags (e.g., \"v\" for v1.0.0, \"\" for 1.0.0)\n\tgetAssetName: (version: string, plat: string, architecture: string) => string | null;\n}\n\nconst TOOLS: Record<string, ToolConfig> = {\n\tfd: {\n\t\tname: \"fd\",\n\t\trepo: \"sharkdp/fd\",\n\t\tbinaryName: \"fd\",\n\t\tsystemBinaryNames: [\"fd\", \"fdfind\"],\n\t\ttagPrefix: \"v\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\trg: {\n\t\tname: \"ripgrep\",\n\t\trepo: \"BurntSushi/ripgrep\",\n\t\tbinaryName: \"rg\",\n\t\ttagPrefix: \"\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tif (architecture === \"arm64\") {\n\t\t\t\t\treturn `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;\n\t\t\t\t}\n\t\t\t\treturn `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n};\n\n// Check if a command exists in PATH by trying to run it\nfunction commandExists(cmd: string): boolean {\n\ttry {\n\t\tconst result = spawnSync(cmd, [\"--version\"], { stdio: \"pipe\" });\n\t\t// Check for ENOENT error (command not found)\n\t\treturn result.error === undefined || result.error === null;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n// Get the path to a tool (system-wide or in our tools dir)\nexport function getToolPath(tool: \"fd\" | \"rg\"): string | null {\n\tconst config = TOOLS[tool];\n\tif (!config) return null;\n\n\t// Check our tools directory first\n\tconst localPath = join(TOOLS_DIR, config.binaryName + (platform() === \"win32\" ? \".exe\" : \"\"));\n\tif (existsSync(localPath)) {\n\t\treturn localPath;\n\t}\n\n\t// Check system PATH - if found, just return the command name (it's in PATH)\n\tconst systemBinaryNames = config.systemBinaryNames ?? [config.binaryName];\n\tfor (const systemBinaryName of systemBinaryNames) {\n\t\tif (commandExists(systemBinaryName)) {\n\t\t\treturn systemBinaryName;\n\t\t}\n\t}\n\n\treturn null;\n}\n\n/** Presence check for a SYSTEM tool the doctor only ever reports on -- never installs. */\nexport interface SystemToolStatus {\n\tpresent: boolean;\n\tcommand?: string;\n\tversion?: string;\n}\n\nconst PYTHON_COMMANDS = [\"python3\", \"python\"];\n\n/**\n * Detect a usable Python interpreter. SYSTEM tool (see src/core/doctor.ts):\n * the doctor reports presence/version, it never installs this itself.\n *\n * @param commands Override the candidate command names, for tests.\n */\nexport function detectPython(commands: readonly string[] = PYTHON_COMMANDS): SystemToolStatus {\n\tfor (const command of commands) {\n\t\ttry {\n\t\t\tconst result = spawnSync(command, [\"--version\"], { encoding: \"utf-8\", stdio: \"pipe\", timeout: 5_000 });\n\t\t\tif (result.error) continue;\n\t\t\t// Python 2 prints its version to stderr; Python 3 prints to stdout. Some\n\t\t\t// platforms' `python` alias is one or the other, so check both.\n\t\t\tconst version = `${result.stdout ?? \"\"}${result.stderr ?? \"\"}`.trim() || undefined;\n\t\t\tif (result.status !== 0 && !version) continue;\n\t\t\treturn { present: true, command, version };\n\t\t} catch {\n\t\t\t// Try the next candidate.\n\t\t}\n\t}\n\treturn { present: false };\n}\n\n/**\n * Runs `<command> --version` (or `versionArgs`) and returns its trimmed\n * combined stdout+stderr, or undefined if the command can't be run. Used by\n * the doctor (src/core/doctor.ts) to show a version alongside a tool it has\n * already located by some other means (e.g. getToolPath(\"rg\"), or the binary\n * path OllamaRuntime.detect() reports) -- callers that only need a yes/no\n * presence check should use commandExists/getToolPath/detectPython instead.\n */\nexport function probeVersion(command: string, versionArgs: readonly string[] = [\"--version\"]): string | undefined {\n\ttry {\n\t\tconst result = spawnSync(command, versionArgs, { encoding: \"utf-8\", stdio: \"pipe\", timeout: 5_000 });\n\t\tif (result.error) return undefined;\n\t\tconst output = `${result.stdout ?? \"\"}${result.stderr ?? \"\"}`.trim();\n\t\treturn output || undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n// Fetch latest release version from GitHub\nasync function getLatestVersion(repo: string): Promise<string> {\n\tconst response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {\n\t\theaders: { \"User-Agent\": `${APP_NAME}-coding-agent` },\n\t\tsignal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(`GitHub API error: ${response.status}`);\n\t}\n\n\tconst data = (await response.json()) as { tag_name: string };\n\treturn data.tag_name.replace(/^v/, \"\");\n}\n\n// Download a file from URL\nasync function downloadFile(url: string, dest: string): Promise<void> {\n\tconst response = await fetch(url, {\n\t\tsignal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(`Failed to download: ${response.status}`);\n\t}\n\n\tif (!response.body) {\n\t\tthrow new Error(\"No response body\");\n\t}\n\n\tconst fileStream = createWriteStream(dest);\n\tawait pipeline(Readable.fromWeb(response.body as any), fileStream);\n}\n\nfunction findBinaryRecursively(rootDir: string, binaryFileName: string): string | null {\n\tconst stack: string[] = [rootDir];\n\n\twhile (stack.length > 0) {\n\t\tconst currentDir = stack.pop();\n\t\tif (!currentDir) continue;\n\n\t\tconst entries = readdirSync(currentDir, { withFileTypes: true });\n\t\tfor (const entry of entries) {\n\t\t\tconst fullPath = join(currentDir, entry.name);\n\t\t\tif (entry.isFile() && entry.name === binaryFileName) {\n\t\t\t\treturn fullPath;\n\t\t\t}\n\t\t\tif (entry.isDirectory()) {\n\t\t\t\tstack.push(fullPath);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn null;\n}\n\nfunction formatSpawnFailure(result: SpawnSyncReturns<Buffer>): string {\n\tif (result.error?.message) {\n\t\treturn result.error.message;\n\t}\n\tconst stderr = result.stderr?.toString().trim();\n\tif (stderr) {\n\t\treturn stderr;\n\t}\n\tconst stdout = result.stdout?.toString().trim();\n\tif (stdout) {\n\t\treturn stdout;\n\t}\n\treturn `exit status ${result.status ?? \"unknown\"}`;\n}\n\nfunction runExtractionCommand(command: string, args: string[]): string | null {\n\tconst result = spawnSync(command, args, { stdio: \"pipe\" });\n\tif (!result.error && result.status === 0) {\n\t\treturn null;\n\t}\n\treturn `${command}: ${formatSpawnFailure(result)}`;\n}\n\nfunction extractTarGzArchive(archivePath: string, extractDir: string, assetName: string): void {\n\tconst failure = runExtractionCommand(\"tar\", [\"xzf\", archivePath, \"-C\", extractDir]);\n\tif (failure) {\n\t\tthrow new Error(`Failed to extract ${assetName}: ${failure}`);\n\t}\n}\n\nfunction getWindowsTarCommand(): string {\n\tconst systemRoot = process.env.SystemRoot ?? process.env.WINDIR;\n\tif (systemRoot) {\n\t\tconst systemTar = join(systemRoot, \"System32\", \"tar.exe\");\n\t\tif (existsSync(systemTar)) {\n\t\t\treturn systemTar;\n\t\t}\n\t}\n\treturn \"tar.exe\";\n}\n\nfunction extractZipArchive(archivePath: string, extractDir: string, assetName: string): void {\n\tconst failures: string[] = [];\n\n\tif (platform() === \"win32\") {\n\t\t// Windows ships bsdtar as tar.exe, which supports zip files. Prefer the\n\t\t// System32 binary over Git Bash's GNU tar, which does not handle zip archives.\n\t\tconst tarFailure = runExtractionCommand(getWindowsTarCommand(), [\"xf\", archivePath, \"-C\", extractDir]);\n\t\tif (!tarFailure) return;\n\t\tfailures.push(tarFailure);\n\n\t\tconst script =\n\t\t\t\"& { param($archive, $destination) $ErrorActionPreference = 'Stop'; Expand-Archive -LiteralPath $archive -DestinationPath $destination -Force }\";\n\t\tconst powershellFailure = runExtractionCommand(\"powershell.exe\", [\n\t\t\t\"-NoLogo\",\n\t\t\t\"-NoProfile\",\n\t\t\t\"-NonInteractive\",\n\t\t\t\"-ExecutionPolicy\",\n\t\t\t\"Bypass\",\n\t\t\t\"-Command\",\n\t\t\tscript,\n\t\t\tarchivePath,\n\t\t\textractDir,\n\t\t]);\n\t\tif (!powershellFailure) return;\n\t\tfailures.push(powershellFailure);\n\t} else {\n\t\tconst unzipFailure = runExtractionCommand(\"unzip\", [\"-q\", archivePath, \"-d\", extractDir]);\n\t\tif (!unzipFailure) return;\n\t\tfailures.push(unzipFailure);\n\n\t\tconst tarFailure = runExtractionCommand(\"tar\", [\"xf\", archivePath, \"-C\", extractDir]);\n\t\tif (!tarFailure) return;\n\t\tfailures.push(tarFailure);\n\t}\n\n\tthrow new Error(`Failed to extract ${assetName}: ${failures.join(\"; \")}`);\n}\n\n// Download and install a tool\nasync function downloadTool(tool: \"fd\" | \"rg\"): Promise<string> {\n\tconst config = TOOLS[tool];\n\tif (!config) throw new Error(`Unknown tool: ${tool}`);\n\n\tconst plat = platform();\n\tconst architecture = arch();\n\n\t// Get latest version\n\tlet version = await getLatestVersion(config.repo);\n\tif (tool === \"fd\" && plat === \"darwin\" && architecture === \"x64\") {\n\t\tversion = \"10.3.0\";\n\t}\n\n\t// Get asset name for this platform\n\tconst assetName = config.getAssetName(version, plat, architecture);\n\tif (!assetName) {\n\t\tthrow new Error(`Unsupported platform: ${plat}/${architecture}`);\n\t}\n\n\t// Create tools directory\n\tmkdirSync(TOOLS_DIR, { recursive: true });\n\n\tconst downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;\n\tconst archivePath = join(TOOLS_DIR, assetName);\n\tconst binaryExt = plat === \"win32\" ? \".exe\" : \"\";\n\tconst binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);\n\n\t// Download\n\tawait downloadFile(downloadUrl, archivePath);\n\n\t// Extract into a unique temp directory. fd and rg downloads can run concurrently\n\t// during startup, so sharing a fixed directory causes races.\n\tconst extractDir = join(\n\t\tTOOLS_DIR,\n\t\t`extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,\n\t);\n\tmkdirSync(extractDir, { recursive: true });\n\n\ttry {\n\t\tif (assetName.endsWith(\".tar.gz\")) {\n\t\t\textractTarGzArchive(archivePath, extractDir, assetName);\n\t\t} else if (assetName.endsWith(\".zip\")) {\n\t\t\textractZipArchive(archivePath, extractDir, assetName);\n\t\t} else {\n\t\t\tthrow new Error(`Unsupported archive format: ${assetName}`);\n\t\t}\n\n\t\t// Find the binary in extracted files. Some archives contain files directly\n\t\t// at root, others nest under a versioned subdirectory.\n\t\tconst binaryFileName = config.binaryName + binaryExt;\n\t\tconst extractedDir = join(extractDir, assetName.replace(/\\.(tar\\.gz|zip)$/, \"\"));\n\t\tconst extractedBinaryCandidates = [join(extractedDir, binaryFileName), join(extractDir, binaryFileName)];\n\t\tlet extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate));\n\n\t\tif (!extractedBinary) {\n\t\t\textractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined;\n\t\t}\n\n\t\tif (extractedBinary) {\n\t\t\trenameSync(extractedBinary, binaryPath);\n\t\t} else {\n\t\t\tthrow new Error(`Binary not found in archive: expected ${binaryFileName} under ${extractDir}`);\n\t\t}\n\n\t\t// Make executable (Unix only)\n\t\tif (plat !== \"win32\") {\n\t\t\tchmodSync(binaryPath, 0o755);\n\t\t}\n\t} finally {\n\t\t// Cleanup\n\t\trmSync(archivePath, { force: true });\n\t\trmSync(extractDir, { recursive: true, force: true });\n\t}\n\n\treturn binaryPath;\n}\n\n// Termux package names for tools\nconst TERMUX_PACKAGES: Record<string, string> = {\n\tfd: \"fd\",\n\trg: \"ripgrep\",\n};\n\nconst FFF_PLATFORM_PACKAGES: Record<string, string> = {\n\t\"darwin/arm64\": \"@ff-labs/fff-bin-darwin-arm64\",\n\t\"darwin/x64\": \"@ff-labs/fff-bin-darwin-x64\",\n\t\"linux/arm64/glibc\": \"@ff-labs/fff-bin-linux-arm64-gnu\",\n\t\"linux/arm64/musl\": \"@ff-labs/fff-bin-linux-arm64-musl\",\n\t\"linux/x64/glibc\": \"@ff-labs/fff-bin-linux-x64-gnu\",\n\t\"linux/x64/musl\": \"@ff-labs/fff-bin-linux-x64-musl\",\n\t\"win32/arm64\": \"@ff-labs/fff-bin-win32-arm64\",\n\t\"win32/x64\": \"@ff-labs/fff-bin-win32-x64\",\n};\n\nlet fffNodeInstallPromise: Promise<unknown | undefined> | undefined;\n\nfunction detectLinuxLibc(): \"glibc\" | \"musl\" {\n\tlet output = \"\";\n\ttry {\n\t\tconst result = spawnSync(\"ldd\", [\"--version\"], { encoding: \"utf-8\", stdio: \"pipe\", timeout: 5000 });\n\t\toutput = `${result.stdout ?? \"\"}${result.stderr ?? \"\"}`;\n\t} catch (e: unknown) {\n\t\tconst err = e as { stdout?: string | Buffer; stderr?: string | Buffer };\n\t\toutput = `${String(err.stdout ?? \"\")}${String(err.stderr ?? \"\")}`;\n\t}\n\treturn output.toLowerCase().includes(\"musl\") ? \"musl\" : \"glibc\";\n}\n\nfunction getFffPlatformPackageName(): string | undefined {\n\tconst plat = platform();\n\tconst architecture = arch();\n\tif (plat === \"linux\") {\n\t\treturn FFF_PLATFORM_PACKAGES[`${plat}/${architecture}/${detectLinuxLibc()}`];\n\t}\n\treturn FFF_PLATFORM_PACKAGES[`${plat}/${architecture}`];\n}\n\nfunction createManagedFffRequire(): ModuleRequire | undefined {\n\tif (!existsSync(FFF_MANAGED_PACKAGE_JSON)) return undefined;\n\treturn createRequire(pathToFileURL(FFF_MANAGED_PACKAGE_JSON).href);\n}\n\nfunction findFffNodeDistEntry(startPath: string): string | undefined {\n\tlet currentDir = dirname(startPath);\n\twhile (currentDir !== dirname(currentDir)) {\n\t\tconst candidate = join(currentDir, \"node_modules\", \"@ff-labs\", \"fff-node\", \"dist\", \"src\", \"index.js\");\n\t\tif (existsSync(candidate)) return candidate;\n\t\tcurrentDir = dirname(currentDir);\n\t}\n\treturn undefined;\n}\n\nfunction loadFffNodeDistEntry(requireFff: ModuleRequire): unknown | undefined {\n\tif (!requireFff.resolve) return undefined;\n\ttry {\n\t\tconst ffiPath = requireFff.resolve(\"ffi-rs\");\n\t\tconst fffEntry = findFffNodeDistEntry(ffiPath);\n\t\treturn fffEntry ? requireFff(fffEntry) : undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction loadFffNodeWith(requireFff: ModuleRequire): unknown | undefined {\n\ttry {\n\t\treturn requireFff(\"@ff-labs/fff-node\");\n\t} catch {\n\t\treturn loadFffNodeDistEntry(requireFff);\n\t}\n}\n\n/**\n * @param requires Override the resolution candidates, for tests (mirrors\n * fff-search-backend.ts's loadFffModule(requires?)). Whether this resolves\n * depends on the ambient environment -- @ff-labs/fff-node is a real npm\n * dependency (package.json), so moduleRequire succeeds wherever a normal\n * `npm install`/`npm ci` provisioned it (e.g. CI) even though it fails on a\n * dev checkout that never ran that install here (this repo resolves fff-node\n * via the separate managed-dir path instead). A test asserting \"nothing is\n * available\" must pass `[]` here rather than relying on that being true.\n */\nexport function loadAvailableFffNodePackage(requires?: readonly ModuleRequire[]): unknown | undefined {\n\tconst candidates =\n\t\trequires ??\n\t\t[moduleRequire, executableDirRequire, createManagedFffRequire()].filter((candidate): candidate is ModuleRequire =>\n\t\t\tBoolean(candidate),\n\t\t);\n\tfor (const requireFff of candidates) {\n\t\tconst loaded = loadFffNodeWith(requireFff);\n\t\tif (loaded) return loaded;\n\t}\n\treturn undefined;\n}\n\nasync function runNpmInstall(args: string[]): Promise<{ code: number | null; stderr: string }> {\n\ttry {\n\t\tconst child = spawnProcess(\"npm\", args, { stdio: [\"ignore\", \"pipe\", \"pipe\"] });\n\t\tlet stderr = \"\";\n\t\tchild.stderr?.on(\"data\", (chunk) => {\n\t\t\tstderr += chunk.toString();\n\t\t});\n\t\tconst code = await waitForChildProcess(child);\n\t\treturn { code, stderr };\n\t} catch (error) {\n\t\treturn { code: 1, stderr: error instanceof Error ? error.message : String(error) };\n\t}\n}\n\n/**\n * Outcome of the most recent {@link ensureFffNodePackage} call, kept for\n * observability (e.g. a future `doctor` check) since the function itself\n * only ever returns the loaded module or `undefined` either way.\n *\n * `install-failed` is distinguished from `offline`/`unsupported-platform`\n * because it is the only one worth *retrying*: offline mode and an\n * unsupported platform are stable for the life of the process, but a real\n * install attempt can fail on a transient issue (registry hiccup, timeout)\n * that may no longer apply on the next search. See\n * DefaultFffSearchBackend.getFinder in fff-search-backend.ts, which uses\n * this distinction to decide whether a failed finder is retryable.\n */\nexport type FffInstallOutcome =\n\t| { status: \"already-available\" }\n\t| { status: \"offline\" }\n\t| { status: \"unsupported-platform\" }\n\t| { status: \"installed\" }\n\t| { status: \"install-failed\"; reason: string };\n\n/**\n * How long a genuine install failure gates out a NEW npm spawn. An agent turn\n * can fire several find/grep calls in quick succession, and each one now\n * primes the finder in the background (see tryFffFind/tryFffGrep) -- without\n * this, a persistently-failing install (registry down, disk full, ...) would\n * re-spawn npm on every single one of those calls instead of once.\n *\n * This gates the SPAWN inside ensureFffNodePackage, not whether a failed\n * finder is retryable (see isFffInstallRetryable): DefaultFffSearchBackend\n * always evicts a failed finder so the next search re-enters this function,\n * and it is THIS cooldown check -- evaluated fresh, at call time -- that\n * decides whether that re-entry is a real attempt or a fast, spawn-free bail.\n * (An earlier version conflated the two: it gated eviction itself on the\n * cooldown, which is checked once, immediately after the failure it's timing\n * -- i.e. always still within the window -- so the failed finder was never\n * evicted and the retry never got a chance to happen at all.)\n */\nexport const FFF_INSTALL_RETRY_COOLDOWN_MS = 30_000;\n\nlet lastFffInstallOutcome: FffInstallOutcome | undefined;\nlet lastFffInstallFailureAt: number | undefined;\n\n/** The outcome of the last {@link ensureFffNodePackage} call, if any. */\nexport function getLastFffInstallOutcome(): FffInstallOutcome | undefined {\n\treturn lastFffInstallOutcome;\n}\n\n/** Whether the last install outcome was a genuine failure worth retrying (as opposed to a stable \"not applicable\" result). Cooldown-independent by design -- see FFF_INSTALL_RETRY_COOLDOWN_MS. */\nexport function isFffInstallRetryable(): boolean {\n\treturn lastFffInstallOutcome?.status === \"install-failed\";\n}\n\n/**\n * Pure decision logic behind {@link isFffInstallCoolingDown}, exposed directly\n * so tests can assert the cooldown boundary without faking the system clock.\n */\nexport function computeIsFffInstallCoolingDown(\n\toutcome: FffInstallOutcome | undefined,\n\tfailedAt: number | undefined,\n\tnow: number,\n): boolean {\n\tif (outcome?.status !== \"install-failed\") return false;\n\tif (failedAt === undefined) return false;\n\treturn now - failedAt < FFF_INSTALL_RETRY_COOLDOWN_MS;\n}\n\n/** Whether a real install attempt happened too recently to try again right now. */\nfunction isFffInstallCoolingDown(): boolean {\n\treturn computeIsFffInstallCoolingDown(lastFffInstallOutcome, lastFffInstallFailureAt, Date.now());\n}\n\n/** Records a genuine install failure and stamps when it happened, so the cooldown above has a start time to measure from. */\nfunction recordFffInstallFailure(reason: string): void {\n\tlastFffInstallOutcome = { status: \"install-failed\", reason };\n\tlastFffInstallFailureAt = Date.now();\n}\n\nasync function installManagedFffNodePackage(platformPackage: string, silent: boolean): Promise<unknown | undefined> {\n\ttry {\n\t\tmkdirSync(FFF_MANAGED_DIR, { recursive: true });\n\t\tif (!existsSync(FFF_MANAGED_PACKAGE_JSON)) {\n\t\t\twriteFileSync(FFF_MANAGED_PACKAGE_JSON, '{\"name\":\"pi-managed-fff-node\",\"private\":true,\"version\":\"0.0.0\"}\\n');\n\t\t}\n\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.dim(\"FFF native search not found. Installing managed FFF package...\"));\n\t\t}\n\n\t\tconst args = [\n\t\t\t\"install\",\n\t\t\t\"--ignore-scripts\",\n\t\t\t\"--omit=dev\",\n\t\t\t\"--include=optional\",\n\t\t\t\"--no-audit\",\n\t\t\t\"--no-fund\",\n\t\t\t\"--package-lock=false\",\n\t\t\t\"--prefix\",\n\t\t\tFFF_MANAGED_DIR,\n\t\t\t`@ff-labs/fff-node@${FFF_NODE_VERSION}`,\n\t\t\t`${platformPackage}@${FFF_NODE_VERSION}`,\n\t\t];\n\t\tconst result = await runNpmInstall(args);\n\t\tif (result.code !== 0) {\n\t\t\tconst reason = result.stderr.trim() || `npm exited with code ${result.code}`;\n\t\t\tif (!silent) {\n\t\t\t\tconsole.log(chalk.yellow(`Failed to install FFF native search: ${reason}`));\n\t\t\t}\n\t\t\trecordFffInstallFailure(reason);\n\t\t\treturn undefined;\n\t\t}\n\t\tconst loaded = loadFffNodeWith(createRequire(pathToFileURL(FFF_MANAGED_PACKAGE_JSON).href));\n\t\tif (!loaded) {\n\t\t\tconst reason = \"Managed FFF install completed but @ff-labs/fff-node could not be loaded.\";\n\t\t\tif (!silent) {\n\t\t\t\tconsole.log(chalk.yellow(reason));\n\t\t\t}\n\t\t\trecordFffInstallFailure(reason);\n\t\t\treturn undefined;\n\t\t}\n\t\tlastFffInstallOutcome = { status: \"installed\" };\n\t\treturn loaded;\n\t} catch (error) {\n\t\t// Never let a filesystem/spawn surprise (e.g. a read-only home directory)\n\t\t// crash the caller: fall back like any other install failure, but keep\n\t\t// the reason observable.\n\t\tconst reason = error instanceof Error ? error.message : String(error);\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`Failed to install FFF native search: ${reason}`));\n\t\t}\n\t\trecordFffInstallFailure(reason);\n\t\treturn undefined;\n\t}\n}\n\nexport async function ensureFffNodePackage(\n\tsilent: boolean = false,\n\tforceManagedInstall: boolean = false,\n\t/** Override the \"is it already available\" resolution candidates, for tests. See loadAvailableFffNodePackage's doc. */\n\trequires?: readonly ModuleRequire[],\n): Promise<unknown | undefined> {\n\tconst existing = forceManagedInstall ? undefined : loadAvailableFffNodePackage(requires);\n\tif (existing) {\n\t\tlastFffInstallOutcome = { status: \"already-available\" };\n\t\treturn existing;\n\t}\n\n\tif (isOfflineModeEnabled()) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(\"FFF native search not found. Offline mode enabled, skipping install.\"));\n\t\t}\n\t\tlastFffInstallOutcome = { status: \"offline\" };\n\t\treturn undefined;\n\t}\n\n\t// A prior attempt failed too recently to try again: bail out fast (no npm\n\t// spawn, no platform/libc probing) rather than repeating a doomed attempt.\n\t// Leaves lastFffInstallOutcome/lastFffInstallFailureAt untouched -- this\n\t// isn't a new attempt, so there's nothing new to record.\n\tif (isFffInstallCoolingDown()) {\n\t\treturn undefined;\n\t}\n\n\tconst platformPackage = getFffPlatformPackageName();\n\tif (!platformPackage) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`FFF native search is not available for ${platform()}/${arch()}.`));\n\t\t}\n\t\tlastFffInstallOutcome = { status: \"unsupported-platform\" };\n\t\treturn undefined;\n\t}\n\n\tfffNodeInstallPromise ??= installManagedFffNodePackage(platformPackage, silent).finally(() => {\n\t\tfffNodeInstallPromise = undefined;\n\t});\n\treturn fffNodeInstallPromise;\n}\n\n// Ensure a tool is available, downloading if necessary\n// Returns the path to the tool, or null if unavailable\nexport async function ensureTool(tool: \"fd\" | \"rg\", silent: boolean = false): Promise<string | undefined> {\n\tconst existingPath = getToolPath(tool);\n\tif (existingPath) {\n\t\treturn existingPath;\n\t}\n\n\tconst config = TOOLS[tool];\n\tif (!config) return undefined;\n\n\tif (isOfflineModeEnabled()) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.\n\t// Users must install via pkg.\n\tif (platform() === \"android\") {\n\t\tconst pkgName = TERMUX_PACKAGES[tool] ?? tool;\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// Tool not found - download it\n\tif (!silent) {\n\t\tconsole.log(chalk.dim(`${config.name} not found. Downloading...`));\n\t}\n\n\ttry {\n\t\tconst path = await downloadTool(tool);\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.dim(`${config.name} installed to ${path}`));\n\t\t}\n\t\treturn path;\n\t} catch (e) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));\n\t\t}\n\t\treturn undefined;\n\t}\n}\n"]}
@@ -100,6 +100,52 @@ export function getToolPath(tool) {
100
100
  }
101
101
  return null;
102
102
  }
103
+ const PYTHON_COMMANDS = ["python3", "python"];
104
+ /**
105
+ * Detect a usable Python interpreter. SYSTEM tool (see src/core/doctor.ts):
106
+ * the doctor reports presence/version, it never installs this itself.
107
+ *
108
+ * @param commands Override the candidate command names, for tests.
109
+ */
110
+ export function detectPython(commands = PYTHON_COMMANDS) {
111
+ for (const command of commands) {
112
+ try {
113
+ const result = spawnSync(command, ["--version"], { encoding: "utf-8", stdio: "pipe", timeout: 5_000 });
114
+ if (result.error)
115
+ continue;
116
+ // Python 2 prints its version to stderr; Python 3 prints to stdout. Some
117
+ // platforms' `python` alias is one or the other, so check both.
118
+ const version = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() || undefined;
119
+ if (result.status !== 0 && !version)
120
+ continue;
121
+ return { present: true, command, version };
122
+ }
123
+ catch {
124
+ // Try the next candidate.
125
+ }
126
+ }
127
+ return { present: false };
128
+ }
129
+ /**
130
+ * Runs `<command> --version` (or `versionArgs`) and returns its trimmed
131
+ * combined stdout+stderr, or undefined if the command can't be run. Used by
132
+ * the doctor (src/core/doctor.ts) to show a version alongside a tool it has
133
+ * already located by some other means (e.g. getToolPath("rg"), or the binary
134
+ * path OllamaRuntime.detect() reports) -- callers that only need a yes/no
135
+ * presence check should use commandExists/getToolPath/detectPython instead.
136
+ */
137
+ export function probeVersion(command, versionArgs = ["--version"]) {
138
+ try {
139
+ const result = spawnSync(command, versionArgs, { encoding: "utf-8", stdio: "pipe", timeout: 5_000 });
140
+ if (result.error)
141
+ return undefined;
142
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
143
+ return output || undefined;
144
+ }
145
+ catch {
146
+ return undefined;
147
+ }
148
+ }
103
149
  // Fetch latest release version from GitHub
104
150
  async function getLatestVersion(repo) {
105
151
  const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
@@ -356,8 +402,20 @@ function loadFffNodeWith(requireFff) {
356
402
  return loadFffNodeDistEntry(requireFff);
357
403
  }
358
404
  }
359
- export function loadAvailableFffNodePackage() {
360
- for (const requireFff of [moduleRequire, executableDirRequire, createManagedFffRequire()].filter((candidate) => Boolean(candidate))) {
405
+ /**
406
+ * @param requires Override the resolution candidates, for tests (mirrors
407
+ * fff-search-backend.ts's loadFffModule(requires?)). Whether this resolves
408
+ * depends on the ambient environment -- @ff-labs/fff-node is a real npm
409
+ * dependency (package.json), so moduleRequire succeeds wherever a normal
410
+ * `npm install`/`npm ci` provisioned it (e.g. CI) even though it fails on a
411
+ * dev checkout that never ran that install here (this repo resolves fff-node
412
+ * via the separate managed-dir path instead). A test asserting "nothing is
413
+ * available" must pass `[]` here rather than relying on that being true.
414
+ */
415
+ export function loadAvailableFffNodePackage(requires) {
416
+ const candidates = requires ??
417
+ [moduleRequire, executableDirRequire, createManagedFffRequire()].filter((candidate) => Boolean(candidate));
418
+ for (const requireFff of candidates) {
361
419
  const loaded = loadFffNodeWith(requireFff);
362
420
  if (loaded)
363
421
  return loaded;
@@ -378,48 +436,129 @@ async function runNpmInstall(args) {
378
436
  return { code: 1, stderr: error instanceof Error ? error.message : String(error) };
379
437
  }
380
438
  }
439
+ /**
440
+ * How long a genuine install failure gates out a NEW npm spawn. An agent turn
441
+ * can fire several find/grep calls in quick succession, and each one now
442
+ * primes the finder in the background (see tryFffFind/tryFffGrep) -- without
443
+ * this, a persistently-failing install (registry down, disk full, ...) would
444
+ * re-spawn npm on every single one of those calls instead of once.
445
+ *
446
+ * This gates the SPAWN inside ensureFffNodePackage, not whether a failed
447
+ * finder is retryable (see isFffInstallRetryable): DefaultFffSearchBackend
448
+ * always evicts a failed finder so the next search re-enters this function,
449
+ * and it is THIS cooldown check -- evaluated fresh, at call time -- that
450
+ * decides whether that re-entry is a real attempt or a fast, spawn-free bail.
451
+ * (An earlier version conflated the two: it gated eviction itself on the
452
+ * cooldown, which is checked once, immediately after the failure it's timing
453
+ * -- i.e. always still within the window -- so the failed finder was never
454
+ * evicted and the retry never got a chance to happen at all.)
455
+ */
456
+ export const FFF_INSTALL_RETRY_COOLDOWN_MS = 30_000;
457
+ let lastFffInstallOutcome;
458
+ let lastFffInstallFailureAt;
459
+ /** The outcome of the last {@link ensureFffNodePackage} call, if any. */
460
+ export function getLastFffInstallOutcome() {
461
+ return lastFffInstallOutcome;
462
+ }
463
+ /** Whether the last install outcome was a genuine failure worth retrying (as opposed to a stable "not applicable" result). Cooldown-independent by design -- see FFF_INSTALL_RETRY_COOLDOWN_MS. */
464
+ export function isFffInstallRetryable() {
465
+ return lastFffInstallOutcome?.status === "install-failed";
466
+ }
467
+ /**
468
+ * Pure decision logic behind {@link isFffInstallCoolingDown}, exposed directly
469
+ * so tests can assert the cooldown boundary without faking the system clock.
470
+ */
471
+ export function computeIsFffInstallCoolingDown(outcome, failedAt, now) {
472
+ if (outcome?.status !== "install-failed")
473
+ return false;
474
+ if (failedAt === undefined)
475
+ return false;
476
+ return now - failedAt < FFF_INSTALL_RETRY_COOLDOWN_MS;
477
+ }
478
+ /** Whether a real install attempt happened too recently to try again right now. */
479
+ function isFffInstallCoolingDown() {
480
+ return computeIsFffInstallCoolingDown(lastFffInstallOutcome, lastFffInstallFailureAt, Date.now());
481
+ }
482
+ /** Records a genuine install failure and stamps when it happened, so the cooldown above has a start time to measure from. */
483
+ function recordFffInstallFailure(reason) {
484
+ lastFffInstallOutcome = { status: "install-failed", reason };
485
+ lastFffInstallFailureAt = Date.now();
486
+ }
381
487
  async function installManagedFffNodePackage(platformPackage, silent) {
382
- mkdirSync(FFF_MANAGED_DIR, { recursive: true });
383
- if (!existsSync(FFF_MANAGED_PACKAGE_JSON)) {
384
- writeFileSync(FFF_MANAGED_PACKAGE_JSON, '{"name":"pi-managed-fff-node","private":true,"version":"0.0.0"}\n');
488
+ try {
489
+ mkdirSync(FFF_MANAGED_DIR, { recursive: true });
490
+ if (!existsSync(FFF_MANAGED_PACKAGE_JSON)) {
491
+ writeFileSync(FFF_MANAGED_PACKAGE_JSON, '{"name":"pi-managed-fff-node","private":true,"version":"0.0.0"}\n');
492
+ }
493
+ if (!silent) {
494
+ console.log(chalk.dim("FFF native search not found. Installing managed FFF package..."));
495
+ }
496
+ const args = [
497
+ "install",
498
+ "--ignore-scripts",
499
+ "--omit=dev",
500
+ "--include=optional",
501
+ "--no-audit",
502
+ "--no-fund",
503
+ "--package-lock=false",
504
+ "--prefix",
505
+ FFF_MANAGED_DIR,
506
+ `@ff-labs/fff-node@${FFF_NODE_VERSION}`,
507
+ `${platformPackage}@${FFF_NODE_VERSION}`,
508
+ ];
509
+ const result = await runNpmInstall(args);
510
+ if (result.code !== 0) {
511
+ const reason = result.stderr.trim() || `npm exited with code ${result.code}`;
512
+ if (!silent) {
513
+ console.log(chalk.yellow(`Failed to install FFF native search: ${reason}`));
514
+ }
515
+ recordFffInstallFailure(reason);
516
+ return undefined;
517
+ }
518
+ const loaded = loadFffNodeWith(createRequire(pathToFileURL(FFF_MANAGED_PACKAGE_JSON).href));
519
+ if (!loaded) {
520
+ const reason = "Managed FFF install completed but @ff-labs/fff-node could not be loaded.";
521
+ if (!silent) {
522
+ console.log(chalk.yellow(reason));
523
+ }
524
+ recordFffInstallFailure(reason);
525
+ return undefined;
526
+ }
527
+ lastFffInstallOutcome = { status: "installed" };
528
+ return loaded;
385
529
  }
386
- if (!silent) {
387
- console.log(chalk.dim("FFF native search not found. Installing managed FFF package..."));
388
- }
389
- const args = [
390
- "install",
391
- "--ignore-scripts",
392
- "--omit=dev",
393
- "--include=optional",
394
- "--no-audit",
395
- "--no-fund",
396
- "--package-lock=false",
397
- "--prefix",
398
- FFF_MANAGED_DIR,
399
- `@ff-labs/fff-node@${FFF_NODE_VERSION}`,
400
- `${platformPackage}@${FFF_NODE_VERSION}`,
401
- ];
402
- const result = await runNpmInstall(args);
403
- if (result.code !== 0) {
530
+ catch (error) {
531
+ // Never let a filesystem/spawn surprise (e.g. a read-only home directory)
532
+ // crash the caller: fall back like any other install failure, but keep
533
+ // the reason observable.
534
+ const reason = error instanceof Error ? error.message : String(error);
404
535
  if (!silent) {
405
- console.log(chalk.yellow(`Failed to install FFF native search: ${result.stderr.trim() || `exit code ${result.code}`}`));
536
+ console.log(chalk.yellow(`Failed to install FFF native search: ${reason}`));
406
537
  }
538
+ recordFffInstallFailure(reason);
407
539
  return undefined;
408
540
  }
409
- const loaded = loadFffNodeWith(createRequire(pathToFileURL(FFF_MANAGED_PACKAGE_JSON).href));
410
- if (!loaded && !silent) {
411
- console.log(chalk.yellow("Managed FFF install completed but @ff-labs/fff-node could not be loaded."));
412
- }
413
- return loaded;
414
541
  }
415
- export async function ensureFffNodePackage(silent = false, forceManagedInstall = false) {
416
- const existing = forceManagedInstall ? undefined : loadAvailableFffNodePackage();
417
- if (existing)
542
+ export async function ensureFffNodePackage(silent = false, forceManagedInstall = false,
543
+ /** Override the "is it already available" resolution candidates, for tests. See loadAvailableFffNodePackage's doc. */
544
+ requires) {
545
+ const existing = forceManagedInstall ? undefined : loadAvailableFffNodePackage(requires);
546
+ if (existing) {
547
+ lastFffInstallOutcome = { status: "already-available" };
418
548
  return existing;
549
+ }
419
550
  if (isOfflineModeEnabled()) {
420
551
  if (!silent) {
421
552
  console.log(chalk.yellow("FFF native search not found. Offline mode enabled, skipping install."));
422
553
  }
554
+ lastFffInstallOutcome = { status: "offline" };
555
+ return undefined;
556
+ }
557
+ // A prior attempt failed too recently to try again: bail out fast (no npm
558
+ // spawn, no platform/libc probing) rather than repeating a doomed attempt.
559
+ // Leaves lastFffInstallOutcome/lastFffInstallFailureAt untouched -- this
560
+ // isn't a new attempt, so there's nothing new to record.
561
+ if (isFffInstallCoolingDown()) {
423
562
  return undefined;
424
563
  }
425
564
  const platformPackage = getFffPlatformPackageName();
@@ -427,6 +566,7 @@ export async function ensureFffNodePackage(silent = false, forceManagedInstall =
427
566
  if (!silent) {
428
567
  console.log(chalk.yellow(`FFF native search is not available for ${platform()}/${arch()}.`));
429
568
  }
569
+ lastFffInstallOutcome = { status: "unsupported-platform" };
430
570
  return undefined;
431
571
  }
432
572
  fffNodeInstallPromise ??= installManagedFffNodePackage(platformPackage, silent).finally(() => {