failproofai 0.0.9 → 0.0.10-beta.1

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 (202) hide show
  1. package/.next/standalone/.cursor/hooks.json +47 -0
  2. package/.next/standalone/.gemini/settings.json +147 -0
  3. package/.next/standalone/.next/BUILD_ID +1 -1
  4. package/.next/standalone/.next/build-manifest.json +3 -3
  5. package/.next/standalone/.next/prerender-manifest.json +3 -3
  6. package/.next/standalone/.next/required-server-files.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  14. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  15. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  16. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  23. package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
  24. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
  25. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  26. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
  27. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  30. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +2 -1
  31. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +16 -16
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
  36. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  37. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
  38. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  39. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  40. package/.next/standalone/.next/server/app/page.js +1 -1
  41. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  44. package/.next/standalone/.next/server/app/policies/page.js +1 -1
  45. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +5 -5
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  57. package/.next/standalone/.next/server/app/projects/page.js +2 -2
  58. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0.~nmr9._.js +3 -0
  61. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0yspgjy._.js → [root-of-the-server]__010i6f5._.js} +2 -2
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08px0ym._.js +3 -0
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0b57.gk._.js +3 -0
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0dtn9lr._.js +3 -0
  65. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0kjo7d_._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0wu7fr7._.js +3 -0
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +3 -0
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z4c5dj._.js +3 -0
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zso~62._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-2wr.c._.js +4 -0
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0.~m-w2._.js +4 -0
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09icjsf._.js → [root-of-the-server]__0709m8.._.js} +3 -3
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bz245.._.js +4 -0
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dl0kgt._.js +4 -0
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gmhxyo._.js +4 -0
  78. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0_b7pgn._.js → [root-of-the-server]__0lkkjl_._.js} +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01g_w_e._.js → [root-of-the-server]__0mb9b9d._.js} +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mup1hi._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ohb3gc._.js +4 -0
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qbpe_v._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s~gy6y._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t5l7a5._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +152 -6
  86. package/.next/standalone/.next/server/chunks/ssr/_03d7qyt._.js +3 -0
  87. package/.next/standalone/.next/server/chunks/ssr/{_07a1g.3._.js → _0zx~s__._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/lib_codex-projects_ts_0eosib~._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/lib_copilot-projects_ts_0r8xkn8._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/lib_cursor-projects_ts_0qt1scg._.js +3 -0
  95. package/.next/standalone/.next/server/chunks/ssr/lib_gemini-projects_ts_0sl~yqr._.js +3 -0
  96. package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +3 -0
  97. package/.next/standalone/.next/server/chunks/ssr/lib_pi-projects_ts_103tsh1._.js +3 -0
  98. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0ef3uwk.js +1 -1
  99. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  100. package/.next/standalone/.next/server/pages/404.html +2 -2
  101. package/.next/standalone/.next/server/pages/500.html +1 -1
  102. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  103. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  104. package/.next/standalone/.next/static/chunks/{0n-_j_6fo6jex.js → 0-wd3kiz5wrsz.js} +2 -2
  105. package/.next/standalone/.next/static/chunks/{0756i.7omnnl6.js → 0222q~_4u7p6h.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{0t~iusm_fxoao.js → 02y~6tp1j1wkh.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{09ose_165ra4d.js → 09qdljea8j.3~.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/0bi2r.m~yokoo.js +1 -0
  109. package/.next/standalone/.next/static/chunks/{11kt_9zaooda3.js → 0pt38lwlsaxvs.js} +1 -1
  110. package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +1 -0
  111. package/.next/standalone/.next/static/chunks/{0u-ys71jc4y68.js → 0vl201wjmz17m.js} +2 -2
  112. package/.next/standalone/.next/static/chunks/{0pr7k36o_.du1.js → 0vl~p17i-4qt2.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/0xkzmsj-sniqz.js +1 -0
  114. package/.next/standalone/.next/static/chunks/12po2vpc-4_c1.css +1 -0
  115. package/.next/standalone/.opencode/opencode.json +4 -0
  116. package/.next/standalone/.opencode/plugins/failproofai.mjs +131 -0
  117. package/.next/standalone/.pi/settings.json +5 -0
  118. package/.next/standalone/app/components/cli-badge.tsx +7 -11
  119. package/.next/standalone/app/components/project-list.tsx +32 -4
  120. package/.next/standalone/app/policies/hooks-client.tsx +31 -15
  121. package/.next/standalone/app/project/[name]/page.tsx +52 -16
  122. package/.next/standalone/app/project/[name]/session/[sessionId]/page.tsx +92 -15
  123. package/.next/standalone/assets/logos/copilot-dark.svg +1 -0
  124. package/.next/standalone/assets/logos/copilot-light.svg +1 -0
  125. package/.next/standalone/assets/logos/cursor-dark.svg +1 -0
  126. package/.next/standalone/assets/logos/cursor-light.svg +1 -0
  127. package/.next/standalone/assets/logos/gemini-dark.svg +13 -0
  128. package/.next/standalone/assets/logos/gemini-light.svg +13 -0
  129. package/.next/standalone/assets/logos/opencode-dark.svg +1 -0
  130. package/.next/standalone/assets/logos/opencode-light.svg +1 -0
  131. package/.next/standalone/assets/logos/pi-dark.svg +7 -0
  132. package/.next/standalone/assets/logos/pi-light.svg +7 -0
  133. package/.next/standalone/lib/cli-registry.ts +107 -0
  134. package/.next/standalone/lib/codex-projects.ts +3 -3
  135. package/.next/standalone/lib/copilot-projects.ts +224 -0
  136. package/.next/standalone/lib/copilot-sessions.ts +395 -0
  137. package/.next/standalone/lib/cursor-projects.ts +312 -0
  138. package/.next/standalone/lib/cursor-sessions.ts +467 -0
  139. package/.next/standalone/lib/gemini-projects.ts +203 -0
  140. package/.next/standalone/lib/gemini-sessions.ts +365 -0
  141. package/.next/standalone/lib/opencode-projects.ts +232 -0
  142. package/.next/standalone/lib/opencode-sessions.ts +237 -0
  143. package/.next/standalone/lib/pi-projects.ts +230 -0
  144. package/.next/standalone/lib/pi-sessions.ts +325 -0
  145. package/.next/standalone/lib/projects.ts +67 -31
  146. package/.next/standalone/next.config.ts +5 -4
  147. package/.next/standalone/package.json +2 -1
  148. package/.next/standalone/pi-extension/index.ts +373 -0
  149. package/.next/standalone/pi-extension/package.json +12 -0
  150. package/.next/standalone/server.js +1 -1
  151. package/README.md +37 -3
  152. package/bin/failproofai.mjs +61 -21
  153. package/dist/cli.mjs +2405 -253
  154. package/lib/cli-registry.ts +107 -0
  155. package/lib/codex-projects.ts +3 -3
  156. package/lib/copilot-projects.ts +224 -0
  157. package/lib/copilot-sessions.ts +395 -0
  158. package/lib/cursor-projects.ts +312 -0
  159. package/lib/cursor-sessions.ts +467 -0
  160. package/lib/gemini-projects.ts +203 -0
  161. package/lib/gemini-sessions.ts +365 -0
  162. package/lib/opencode-projects.ts +232 -0
  163. package/lib/opencode-sessions.ts +237 -0
  164. package/lib/pi-projects.ts +230 -0
  165. package/lib/pi-sessions.ts +325 -0
  166. package/lib/projects.ts +67 -31
  167. package/package.json +2 -1
  168. package/pi-extension/index.ts +373 -0
  169. package/pi-extension/package.json +12 -0
  170. package/scripts/install-diagnosis.mjs +190 -0
  171. package/scripts/launch.ts +32 -0
  172. package/scripts/postinstall.mjs +25 -0
  173. package/scripts/translate-docs/mdx-translator.ts +56 -2
  174. package/scripts/translate-docs/translator.ts +1 -1
  175. package/src/hooks/builtin-policies.ts +84 -14
  176. package/src/hooks/handler.ts +67 -5
  177. package/src/hooks/install-prompt.ts +33 -10
  178. package/src/hooks/integrations.ts +1007 -6
  179. package/src/hooks/policy-evaluator.ts +299 -3
  180. package/src/hooks/resolve-permission-mode.ts +23 -0
  181. package/src/hooks/types.ts +307 -3
  182. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +0 -3
  183. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0su~k6f._.js +0 -3
  184. package/.next/standalone/.next/server/chunks/lib_codex-projects_ts_07qqk1g._.js +0 -3
  185. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01743wx._.js +0 -3
  186. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +0 -4
  187. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +0 -4
  188. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gs6wz4._.js +0 -3
  189. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +0 -4
  190. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0it81ys._.js +0 -3
  191. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u4a9jq._.js +0 -4
  192. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +0 -4
  193. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12.h2mg._.js +0 -3
  194. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +0 -4
  195. package/.next/standalone/.next/server/chunks/ssr/_04w00cm._.js +0 -3
  196. package/.next/standalone/.next/static/chunks/0.rk1iwdt1d7c.css +0 -1
  197. package/.next/standalone/.next/static/chunks/06x4-d1~o-opr.js +0 -1
  198. package/.next/standalone/.next/static/chunks/095l4hc7-h.~~.js +0 -1
  199. package/.next/standalone/.next/static/chunks/0n~s0gafwnp2y.js +0 -1
  200. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → w0GG7S5UEj1-p5g9hfsh2}/_buildManifest.js +0 -0
  201. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → w0GG7S5UEj1-p5g9hfsh2}/_clientMiddlewareManifest.js +0 -0
  202. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → w0GG7S5UEj1-p5g9hfsh2}/_ssgManifest.js +0 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * failproofai policy bridge for Pi (pi-coding-agent).
3
+ *
4
+ * This extension is loaded by Pi at startup and registered via
5
+ * `pi install <abs-path-to-this-dir> [-l]` (or by hand-authoring an entry in
6
+ * `<scope>/.pi/settings.json`). It subscribes to Pi's `tool_call`, `user_bash`,
7
+ * `input`, and `session_start` events and forwards them to the failproofai
8
+ * binary as `failproofai --hook <Event> --cli pi`. failproofai prints a
9
+ * decision JSON to stdout; this shim parses it and translates into Pi's
10
+ * `{ block: true, reason }` return shape so policy `deny` decisions cancel
11
+ * tool execution.
12
+ *
13
+ * Marker comment for failproofai's installer detection (do not remove):
14
+ * __failproofai_hook__: true
15
+ *
16
+ * Binary resolution. failproofai ships two entrypoints:
17
+ * • dist/cli.mjs — bundled, node-compatible (production npm install)
18
+ * • bin/failproofai.mjs — source, requires `bun` (dev / monorepo)
19
+ *
20
+ * dist/cli.mjs is preferred because spawning `node bin/failproofai.mjs`
21
+ * fails with ERR_IMPORT_ATTRIBUTE_MISSING (the source `import package.json`
22
+ * needs `with { type: "json" }` under node, which bun handles transparently
23
+ * but the build:cli step transpiles away in dist/cli.mjs). When dist/cli.mjs
24
+ * isn't present, fall back to running bin/failproofai.mjs with `bun`. Pi
25
+ * spawns extensions with an undefined cwd contract, so paths are resolved
26
+ * relative to this file via `import.meta.url`, NOT process.cwd().
27
+ */
28
+ import { spawnSync } from "node:child_process";
29
+ import { resolve, dirname, join } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { existsSync, readdirSync, statSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+
34
+ const HERE = dirname(fileURLToPath(import.meta.url));
35
+ const DIST_BIN = resolve(HERE, "..", "dist", "cli.mjs");
36
+ const SRC_BIN = resolve(HERE, "..", "bin", "failproofai.mjs");
37
+ // Prefer the bundled dist/cli.mjs (node-compatible); fall back to source +
38
+ // bun for dev workflows where dist/ hasn't been built yet.
39
+ function resolveSpawn(): { cmd: string; args: string[] } {
40
+ if (process.env.FAILPROOFAI_BINARY_OVERRIDE) {
41
+ return { cmd: "node", args: [process.env.FAILPROOFAI_BINARY_OVERRIDE] };
42
+ }
43
+ if (existsSync(DIST_BIN)) {
44
+ return { cmd: "node", args: [DIST_BIN] };
45
+ }
46
+ return { cmd: "bun", args: [SRC_BIN] };
47
+ }
48
+
49
+ interface PolicyDecision {
50
+ permission?: "allow" | "deny";
51
+ reason?: string;
52
+ }
53
+
54
+ /**
55
+ * Spawn `failproofai --hook <eventName> --cli pi`, write the JSON payload to
56
+ * stdin, and parse the flat `{permission, reason}` JSON we expect failproofai
57
+ * to print on stdout. Fail-open on any subprocess / parse error.
58
+ */
59
+ /** Optional stderr trace for debugging the shim. Enabled with
60
+ * FAILPROOFAI_PI_DEBUG=1; silent otherwise. */
61
+ function debug(msg: string): void {
62
+ if (process.env.FAILPROOFAI_PI_DEBUG === "1") {
63
+ process.stderr.write(`[failproofai-pi-shim] ${msg}\n`);
64
+ }
65
+ }
66
+
67
+ function callPolicy(eventName: string, payload: unknown): { block: boolean; reason: string } {
68
+ const { cmd, args } = resolveSpawn();
69
+ debug(`callPolicy event=${eventName} cmd=${cmd}`);
70
+ try {
71
+ const result = spawnSync(
72
+ cmd,
73
+ [...args, "--hook", eventName, "--cli", "pi"],
74
+ {
75
+ input: JSON.stringify(payload),
76
+ encoding: "utf8",
77
+ timeout: 60_000,
78
+ },
79
+ );
80
+ if (result.status !== 0) return { block: false, reason: "" };
81
+ const stdout = (result.stdout || "").trim();
82
+ if (!stdout) return { block: false, reason: "" };
83
+ const parsed = JSON.parse(stdout) as PolicyDecision;
84
+ if (parsed.permission === "deny") {
85
+ debug(`DENY reason=${parsed.reason}`);
86
+ return { block: true, reason: parsed.reason ?? "Blocked by failproofai" };
87
+ }
88
+ } catch (err) {
89
+ debug(`EXCEPTION ${err instanceof Error ? err.message : String(err)}`);
90
+ // Fail-open: never block tool execution because of an infra failure.
91
+ }
92
+ return { block: false, reason: "" };
93
+ }
94
+
95
+ interface PiToolCallEvent {
96
+ type?: string;
97
+ toolName?: string;
98
+ toolCallId?: string;
99
+ input?: Record<string, unknown>;
100
+ cwd?: string;
101
+ sessionId?: string;
102
+ }
103
+
104
+ /**
105
+ * Pi emits tool names in lowercase (`bash`, `read`, `edit`, `write`).
106
+ * failproofai's builtin policies match on Claude-shaped capitalized names
107
+ * (`Bash`, `Read`, `Edit`, `Write`). Map between the two so existing
108
+ * tool-name match clauses fire on Pi sessions.
109
+ */
110
+ function canonicalizeToolName(piToolName: string | undefined): string | undefined {
111
+ if (!piToolName) return undefined;
112
+ return piToolName.charAt(0).toUpperCase() + piToolName.slice(1);
113
+ }
114
+
115
+ /** Resolve the cwd for the policy payload. Pi events don't include cwd, so
116
+ * fall back to the extension's process.cwd() — which is where Pi was
117
+ * launched and where `.failproofai/` config lives. */
118
+ function resolveCwd(eventCwd: string | undefined): string {
119
+ return eventCwd ?? process.cwd();
120
+ }
121
+
122
+ /**
123
+ * Pi (verified empirically against pi-coding-agent v0.71.1) does NOT
124
+ * populate `event.sessionId` on any of its events — `session_start`,
125
+ * `tool_call`, `user_bash`, `input`, `tool_result`, `agent_end`,
126
+ * `session_shutdown` all leave it undefined. Without help the shim can't
127
+ * tag activity records with a session id, so the dashboard renders
128
+ * `Session ID: —` for every Pi row.
129
+ *
130
+ * What Pi DOES do: at session start it creates a JSONL transcript at
131
+ * `~/.pi/agent/sessions/<encodedCwd>/<isoTimestamp>_<uuid>.jsonl` where
132
+ * the filename encodes the sessionId. We discover ours by scanning the
133
+ * encoded-cwd directory for the most-recently-modified matching file.
134
+ *
135
+ * Strategy: scan once and cache. Pi runs one session per process so the
136
+ * cache is per-process and lives for the session's lifetime. If Pi ever
137
+ * multiplexes, we'd need a keyed map.
138
+ */
139
+ const PI_FILE_RE = /^[\d-]+T[\d-]+Z_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
140
+
141
+ /** Encode a cwd into Pi's on-disk session-dir name. Pi strips the leading
142
+ * `/` before replacing remaining slashes with `-`, e.g.
143
+ * `/home/u/repo` → `--home-u-repo--`. */
144
+ function piEncodeCwd(cwd: string): string {
145
+ const inner = cwd.replace(/^\/+/, "").replace(/\//g, "-");
146
+ return `--${inner}--`;
147
+ }
148
+
149
+ /** Process start boundary — files older than this aren't from the current
150
+ * Pi session. Captured at module load so cold-start in a cwd with stale
151
+ * transcripts doesn't pin a previous session's UUID. We allow a small
152
+ * tolerance below `processStartMs` because mtime resolution and clock
153
+ * skew can put a "current" file's mtime a few hundred ms before module
154
+ * load on slow startup. */
155
+ const PROCESS_START_MS = Date.now();
156
+ const STALE_TOLERANCE_MS = 2_000;
157
+
158
+ /** Find the newest `<ts>_<uuid>.jsonl` file under `~/.pi/agent/sessions/<encodedCwd>/`
159
+ * whose mtime indicates it belongs to the CURRENT Pi process (≥ process
160
+ * start, with a small tolerance). Files older than that are stale
161
+ * transcripts from prior sessions in the same cwd — caching their UUID
162
+ * would cross-attribute every event of the new session.
163
+ * Returns undefined when the dir doesn't exist, has no matching file, or
164
+ * every matching file is stale. */
165
+ function discoverPiSessionId(cwd: string): string | undefined {
166
+ const root = process.env.PI_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
167
+ const dir = join(root, piEncodeCwd(cwd));
168
+ let entries: string[];
169
+ try { entries = readdirSync(dir); } catch { return undefined; }
170
+ const boundary = PROCESS_START_MS - STALE_TOLERANCE_MS;
171
+ let best: { sessionId: string; mtime: number } | undefined;
172
+ for (const name of entries) {
173
+ const m = PI_FILE_RE.exec(name);
174
+ if (!m) continue;
175
+ let mtime: number;
176
+ try { mtime = statSync(join(dir, name)).mtimeMs; } catch { continue; }
177
+ if (mtime < boundary) continue;
178
+ if (!best || mtime > best.mtime) best = { sessionId: m[1], mtime };
179
+ }
180
+ return best?.sessionId;
181
+ }
182
+
183
+ /** sessionId cache, keyed by cwd. Per-cwd so a multi-cwd Pi (extension running
184
+ * across multiple workspace roots) can't cross-attribute. Cleared on
185
+ * session_shutdown reasons `new`/`resume`/`fork` (Pi reuses the process). */
186
+ const cachedSessionIdByCwd = new Map<string, string>();
187
+ function resolveSessionId(eventSessionId: string | undefined, cwd: string): string | undefined {
188
+ if (eventSessionId) {
189
+ cachedSessionIdByCwd.set(cwd, eventSessionId);
190
+ return eventSessionId;
191
+ }
192
+ const cached = cachedSessionIdByCwd.get(cwd);
193
+ if (cached) return cached;
194
+ // Pi v0.71.1 never sets sessionId — discover from disk.
195
+ const discovered = discoverPiSessionId(cwd);
196
+ if (discovered) cachedSessionIdByCwd.set(cwd, discovered);
197
+ return discovered;
198
+ }
199
+ /** Clear the cached sessionId for a cwd. Called on session_shutdown reasons
200
+ * that indicate a new session is starting in the same process (`new`,
201
+ * `resume`, `fork`). Without this, the next session would inherit the prior
202
+ * sessionId until disk discovery refreshed it. */
203
+ function resetSessionIdCache(cwd: string): void {
204
+ cachedSessionIdByCwd.delete(cwd);
205
+ }
206
+
207
+ interface PiUserBashEvent {
208
+ type?: string;
209
+ command?: string;
210
+ cwd?: string;
211
+ sessionId?: string;
212
+ }
213
+
214
+ interface PiInputEvent {
215
+ type?: string;
216
+ text?: string;
217
+ source?: string;
218
+ cwd?: string;
219
+ sessionId?: string;
220
+ }
221
+
222
+ interface PiSessionStartEvent {
223
+ type?: string;
224
+ reason?: string;
225
+ cwd?: string;
226
+ sessionId?: string;
227
+ }
228
+
229
+ interface PiSessionShutdownEvent {
230
+ type?: string;
231
+ /** "quit" | "reload" | "new" | "resume" | "fork" per pi-coding-agent v0.72.1 */
232
+ reason?: string;
233
+ targetSessionFile?: string;
234
+ cwd?: string;
235
+ sessionId?: string;
236
+ }
237
+
238
+ interface PiToolResultEvent {
239
+ type?: string;
240
+ toolCallId?: string;
241
+ toolName?: string;
242
+ input?: Record<string, unknown>;
243
+ /** TextContent | ImageContent — opaque to us; forwarded as-is. */
244
+ content?: unknown[];
245
+ isError?: boolean;
246
+ cwd?: string;
247
+ sessionId?: string;
248
+ }
249
+
250
+ interface PiAgentEndEvent {
251
+ type?: string;
252
+ /** AgentMessage[] — opaque; not forwarded (Stop policies don't need it). */
253
+ messages?: unknown[];
254
+ cwd?: string;
255
+ sessionId?: string;
256
+ }
257
+
258
+ interface PiExtensionApi {
259
+ on(event: string, handler: (event: unknown) => unknown): void;
260
+ }
261
+
262
+ export default function failproofaiBridge(pi: PiExtensionApi) {
263
+ // tool_call → PreToolUse. Block tool execution when failproofai denies.
264
+ pi.on("tool_call", (event: unknown): unknown => {
265
+ const e = event as PiToolCallEvent;
266
+ const decision = callPolicy("tool_call", {
267
+ tool_name: canonicalizeToolName(e.toolName),
268
+ tool_input: e.input,
269
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
270
+ cwd: resolveCwd(e.cwd),
271
+ hook_event_name: "PreToolUse",
272
+ });
273
+ if (decision.block) return { block: true, reason: decision.reason };
274
+ return undefined;
275
+ });
276
+
277
+ // user_bash → PreToolUse with synthesized toolName=Bash.
278
+ pi.on("user_bash", (event: unknown): unknown => {
279
+ const e = event as PiUserBashEvent;
280
+ const decision = callPolicy("user_bash", {
281
+ tool_name: "Bash",
282
+ tool_input: { command: e.command },
283
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
284
+ cwd: resolveCwd(e.cwd),
285
+ hook_event_name: "PreToolUse",
286
+ });
287
+ if (decision.block) return { block: true, reason: decision.reason };
288
+ return undefined;
289
+ });
290
+
291
+ // input → UserPromptSubmit. Honor block decisions if Pi accepts them
292
+ // (Pi's docs describe block on input but it's not exhaustively tested).
293
+ pi.on("input", (event: unknown): unknown => {
294
+ const e = event as PiInputEvent;
295
+ const decision = callPolicy("input", {
296
+ prompt: e.text,
297
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
298
+ cwd: resolveCwd(e.cwd),
299
+ hook_event_name: "UserPromptSubmit",
300
+ });
301
+ if (decision.block) return { block: true, reason: decision.reason };
302
+ return undefined;
303
+ });
304
+
305
+ // session_start → SessionStart. Observe-only; we still forward so the
306
+ // activity feed records the session and any UserPromptSubmit policies that
307
+ // need session_id continuity see the metadata.
308
+ pi.on("session_start", (event: unknown): unknown => {
309
+ const e = event as PiSessionStartEvent;
310
+ callPolicy("session_start", {
311
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
312
+ cwd: resolveCwd(e.cwd),
313
+ reason: e.reason,
314
+ hook_event_name: "SessionStart",
315
+ });
316
+ return undefined;
317
+ });
318
+
319
+ // tool_result → PostToolUse. Observation-only on Pi: ToolResultEventResult
320
+ // exposes {content, details, isError} for mutation but no `block`. We
321
+ // forward to the failproofai binary so PostToolUse builtins (sanitize-jwt,
322
+ // sanitize-api-keys, sanitize-connection-strings, sanitize-private-key-
323
+ // content, sanitize-bearer-tokens) run and get their decisions logged to
324
+ // the activity store + stderr — but Pi keeps the original tool result.
325
+ pi.on("tool_result", (event: unknown): unknown => {
326
+ const e = event as PiToolResultEvent;
327
+ callPolicy("tool_result", {
328
+ tool_name: canonicalizeToolName(e.toolName),
329
+ tool_input: e.input ?? {},
330
+ tool_response: { content: e.content, isError: e.isError },
331
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
332
+ cwd: resolveCwd(e.cwd),
333
+ hook_event_name: "PostToolUse",
334
+ });
335
+ return undefined;
336
+ });
337
+
338
+ // agent_end → Stop. Observation-only on Pi: the agent loop has already
339
+ // exited when this fires, so a deny decision cannot keep Pi running the
340
+ // way Claude's exit-2-from-Stop can. We still forward so the 5
341
+ // require-*-before-stop builtins run and log their findings (visible in
342
+ // the dashboard's activity feed and stderr) — best-effort visibility.
343
+ pi.on("agent_end", (event: unknown): unknown => {
344
+ const e = event as PiAgentEndEvent;
345
+ callPolicy("agent_end", {
346
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
347
+ cwd: resolveCwd(e.cwd),
348
+ hook_event_name: "Stop",
349
+ });
350
+ return undefined;
351
+ });
352
+
353
+ // session_shutdown → SessionEnd. Observation-only; emits a SessionEnd
354
+ // record so per-session telemetry has a clean close. Reset the per-cwd
355
+ // sessionId cache for shutdown reasons that mean "Pi is starting a new
356
+ // session in the same process" — without the reset, the next session's
357
+ // events would inherit the prior session's id until disk discovery
358
+ // refreshed it.
359
+ pi.on("session_shutdown", (event: unknown): unknown => {
360
+ const e = event as PiSessionShutdownEvent;
361
+ const cwd = resolveCwd(e.cwd);
362
+ callPolicy("session_shutdown", {
363
+ session_id: resolveSessionId(e.sessionId, cwd),
364
+ cwd,
365
+ reason: e.reason,
366
+ hook_event_name: "SessionEnd",
367
+ });
368
+ if (e.reason === "new" || e.reason === "resume" || e.reason === "fork") {
369
+ resetSessionIdCache(cwd);
370
+ }
371
+ return undefined;
372
+ });
373
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@failproofai/pi-extension",
3
+ "version": "0.0.1",
4
+ "description": "failproofai policy bridge for Pi (pi-coding-agent)",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "private": true,
8
+ "keywords": [
9
+ "pi-extension",
10
+ "failproofai"
11
+ ]
12
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Detects when `failproofai` on the user's PATH is shadowed by a different,
3
+ * older install — typically a leftover `bun link` from a prior dev session, or
4
+ * a `bun install -g failproofai` whose prefix sorts ahead of npm's on PATH.
5
+ *
6
+ * Used by:
7
+ * - scripts/postinstall.mjs — warn at install time so the customer never sees
8
+ * the misleading "missing build output" runtime error.
9
+ * - scripts/launch.ts — when .next/standalone/server.js is missing,
10
+ * produce a shadow-shaped error if the cause is a shadow rather than a
11
+ * genuinely broken build.
12
+ *
13
+ * Pure Node.js built-ins, no external dependencies. Every probe is wrapped in
14
+ * try/catch — diagnoseShadow() is guaranteed not to throw.
15
+ */
16
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
17
+ import { dirname, resolve } from "node:path";
18
+ import { homedir, platform } from "node:os";
19
+ import { spawnSync } from "node:child_process";
20
+
21
+ const PKG_NAME = "failproofai";
22
+
23
+ /**
24
+ * Walk up from `start` looking for a package.json whose name === "failproofai".
25
+ * Returns its directory, or null when no such package.json is reachable.
26
+ */
27
+ function findPackageRoot(start) {
28
+ try {
29
+ let dir = realpathSync(start);
30
+ // If `start` was a file (e.g. /usr/local/bin/failproofai), step up to its dir.
31
+ if (existsSync(dir) && !existsSync(resolve(dir, "package.json"))) {
32
+ dir = dirname(dir);
33
+ }
34
+ while (true) {
35
+ const pkgPath = resolve(dir, "package.json");
36
+ if (existsSync(pkgPath)) {
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
39
+ if (pkg.name === PKG_NAME) return dir;
40
+ } catch {
41
+ // unreadable or non-JSON — fall through to parent
42
+ }
43
+ }
44
+ const parent = dirname(dir);
45
+ if (parent === dir) return null;
46
+ dir = parent;
47
+ }
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** Read `version` from a package.json; null on any error. */
54
+ function readPackageVersion(packageRoot) {
55
+ if (!packageRoot) return null;
56
+ try {
57
+ const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
58
+ return typeof pkg.version === "string" ? pkg.version : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** Find which `failproofai` PATH would resolve. POSIX: `command -v`; Win32: `where`. */
65
+ function resolvePathFirstBinary() {
66
+ try {
67
+ const isWin = platform() === "win32";
68
+ const res = isWin
69
+ ? spawnSync("where", [PKG_NAME], { encoding: "utf8" })
70
+ : spawnSync("sh", ["-c", `command -v ${PKG_NAME}`], { encoding: "utf8" });
71
+ if (res.status !== 0) return null;
72
+ const first = (res.stdout || "").split(/\r?\n/).find((l) => l.trim().length > 0);
73
+ return first ? first.trim() : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** Locate the npm global install of failproofai, if any. */
80
+ function locateNpmGlobal() {
81
+ try {
82
+ const res = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
83
+ if (res.status !== 0) return null;
84
+ const root = (res.stdout || "").trim();
85
+ if (!root) return null;
86
+ const candidate = resolve(root, PKG_NAME);
87
+ return existsSync(resolve(candidate, "package.json")) ? candidate : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /** Locate the bun global install of failproofai, if any. */
94
+ function locateBunGlobal() {
95
+ try {
96
+ const candidate = resolve(homedir(), ".bun", "install", "global", "node_modules", PKG_NAME);
97
+ return existsSync(resolve(candidate, "package.json")) ? candidate : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Build a copy-pasteable cleanup command for the offending install.
105
+ *
106
+ * The signal we trust is `pathFirstBin` — the un-resolved binary location PATH
107
+ * pointed to. For bun-link shadows the realpath'd package root is the dev tree
108
+ * (not under ~/.bun/), so checking the package root would mis-classify those
109
+ * shadows as npm and recommend the wrong cleanup.
110
+ */
111
+ function buildRecommendation(pathFirstBin) {
112
+ if (!pathFirstBin) return null;
113
+ const bunBinPrefix = resolve(homedir(), ".bun", "bin") + "/";
114
+ const bunGlobalPrefix = resolve(homedir(), ".bun", "install", "global") + "/";
115
+ const isBun = pathFirstBin.startsWith(bunBinPrefix) || pathFirstBin.startsWith(bunGlobalPrefix);
116
+ if (isBun) {
117
+ return `rm -f ~/.bun/bin/${PKG_NAME} && rm -rf ~/.bun/install/global/node_modules/${PKG_NAME}`;
118
+ }
119
+ return `npm rm -g ${PKG_NAME}`;
120
+ }
121
+
122
+ /**
123
+ * Diagnose whether the running binary is being shadowed on PATH by a different
124
+ * failproofai install.
125
+ *
126
+ * @param {{ selfPackageRoot: string, selfVersion: string | null }} self
127
+ * The package root and version of the binary calling diagnoseShadow().
128
+ * Callers (bin/failproofai.mjs, scripts/postinstall.mjs) already have these
129
+ * values; passing them in keeps the helper deterministic and free of
130
+ * import.meta.url assumptions.
131
+ */
132
+ export function diagnoseShadow(self) {
133
+ const selfPackageRoot = (() => {
134
+ try { return self?.selfPackageRoot ? realpathSync(self.selfPackageRoot) : null; }
135
+ catch { return self?.selfPackageRoot ?? null; }
136
+ })();
137
+ const selfVersion = self?.selfVersion ?? null;
138
+
139
+ const pathFirstBin = resolvePathFirstBinary();
140
+ const pathFirstPackageRoot = pathFirstBin ? findPackageRoot(pathFirstBin) : null;
141
+ const pathFirstVersion = readPackageVersion(pathFirstPackageRoot);
142
+
143
+ const npmGlobalPath = locateNpmGlobal();
144
+ const npmGlobalVersion = readPackageVersion(npmGlobalPath);
145
+
146
+ const bunGlobalPath = locateBunGlobal();
147
+ const bunGlobalVersion = readPackageVersion(bunGlobalPath);
148
+
149
+ // "Shadow" covers two scenarios:
150
+ // 1. Postinstall case — `selfPackageRoot` is the just-installed copy and
151
+ // PATH resolves elsewhere. Flag when the two roots differ.
152
+ // 2. Runtime case — the running binary IS the shadow (so selfPackageRoot
153
+ // === pathFirstPackageRoot), but a *different* failproofai install
154
+ // exists at the npm or bun global. Flag when one of those differs from
155
+ // pathFirstPackageRoot.
156
+ let shadowed = false;
157
+ if (selfPackageRoot && pathFirstPackageRoot && pathFirstPackageRoot !== selfPackageRoot) {
158
+ shadowed = true;
159
+ } else if (pathFirstPackageRoot) {
160
+ if (npmGlobalPath && npmGlobalPath !== pathFirstPackageRoot) shadowed = true;
161
+ else if (bunGlobalPath && bunGlobalPath !== pathFirstPackageRoot) shadowed = true;
162
+ }
163
+
164
+ const recommendation = shadowed ? buildRecommendation(pathFirstBin) : null;
165
+
166
+ // A short human-readable summary used by callers that want a one-liner.
167
+ let shadowDescription = null;
168
+ if (shadowed) {
169
+ shadowDescription =
170
+ `PATH resolves to ${pathFirstPackageRoot}` +
171
+ (pathFirstVersion ? ` (v${pathFirstVersion})` : "") +
172
+ `, but you just installed ${selfPackageRoot}` +
173
+ (selfVersion ? ` (v${selfVersion})` : "") + ".";
174
+ }
175
+
176
+ return {
177
+ selfPackageRoot,
178
+ selfVersion,
179
+ pathFirstBin,
180
+ pathFirstPath: pathFirstPackageRoot,
181
+ pathFirstVersion,
182
+ npmGlobalPath,
183
+ npmGlobalVersion,
184
+ bunGlobalPath,
185
+ bunGlobalVersion,
186
+ shadowed,
187
+ shadowDescription,
188
+ recommendation,
189
+ };
190
+ }
package/scripts/launch.ts CHANGED
@@ -7,6 +7,7 @@ import { realpathSync, existsSync } from "node:fs";
7
7
  import { resolve, dirname } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { parseScriptArgs } from "./parse-script-args";
10
+ import { diagnoseShadow } from "./install-diagnosis.mjs";
10
11
  import { version } from "../package.json";
11
12
 
12
13
  export function launch(mode: "dev" | "start"): void {
@@ -49,7 +50,38 @@ export function launch(mode: "dev" | "start"): void {
49
50
  ?? resolve(dirname(realpathSync(fileURLToPath(import.meta.url))), "..");
50
51
  const serverJsPath = resolve(packageRoot, ".next/standalone/server.js");
51
52
  if (!existsSync(serverJsPath)) {
53
+ // Most "missing server.js" reports come from a PATH shadow (an older
54
+ // `bun link` or a `bun install -g` whose prefix wins over npm), not from
55
+ // a genuinely broken build. Diagnose first so the error message names
56
+ // the actual cause when that's what's going on.
57
+ let shadowMessage: string | null = null;
58
+ try {
59
+ const diag = diagnoseShadow({ selfPackageRoot: packageRoot, selfVersion: version });
60
+ if (diag.shadowed) {
61
+ // Pick whichever alternate install exists at npm/bun globals AND
62
+ // differs from PATH-first. In the runtime stale-binary scenario the
63
+ // running install IS the PATH-first one, so we'd otherwise point the
64
+ // user back at themselves.
65
+ const alt =
66
+ (diag.npmGlobalPath && diag.npmGlobalPath !== diag.pathFirstPath
67
+ ? { path: diag.npmGlobalPath, version: diag.npmGlobalVersion }
68
+ : null)
69
+ ?? (diag.bunGlobalPath && diag.bunGlobalPath !== diag.pathFirstPath
70
+ ? { path: diag.bunGlobalPath, version: diag.bunGlobalVersion }
71
+ : null);
72
+ const newer = alt?.path ?? "(unknown)";
73
+ const newerVer = alt?.version ?? "?";
74
+ shadowMessage =
75
+ `\nError: failproofai on your PATH is a stale install that no longer has its build output.\n` +
76
+ ` Running: ${diag.pathFirstPath}` + (diag.pathFirstVersion ? ` (v${diag.pathFirstVersion})` : "") + `\n` +
77
+ ` Newer copy: ${newer} (v${newerVer})\n\n` +
78
+ `Remove the shadow with:\n ${diag.recommendation}\n`;
79
+ }
80
+ } catch {
81
+ // Diagnosis is best-effort; fall back to the original message.
82
+ }
52
83
  console.error(
84
+ shadowMessage ??
53
85
  `\nError: Cannot find server.js at:\n ${serverJsPath}\n\n` +
54
86
  `The package may be missing its build output.\n` +
55
87
  `Try reinstalling:\n npm install -g failproofai@latest\n`
@@ -12,6 +12,7 @@ import { resolve } from "node:path";
12
12
  import { platform, arch, release, homedir, hostname } from "node:os";
13
13
  import { createHmac } from "node:crypto";
14
14
  import { trackInstallEvent } from "./install-telemetry.mjs";
15
+ import { diagnoseShadow } from "./install-diagnosis.mjs";
15
16
 
16
17
  // Skip when running in development context (e.g. `bun install` in the source repo).
17
18
  // INIT_CWD is set by npm/bun to the directory where install was invoked; it differs
@@ -29,6 +30,30 @@ if (!existsSync(serverJsPath)) {
29
30
  process.exit(1);
30
31
  }
31
32
 
33
+ // Detect when an older `failproofai` is shadowing this fresh install on PATH —
34
+ // classic case is a leftover `bun link` from a prior dev session, or a
35
+ // `bun install -g` whose ~/.bun/bin sorts ahead of npm's prefix. Without this
36
+ // warning the user only finds out later via a confusing runtime error from
37
+ // scripts/launch.ts pointing at the *old* install's missing build output.
38
+ try {
39
+ let selfVersion = null;
40
+ try {
41
+ selfVersion = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf8")).version ?? null;
42
+ } catch {}
43
+ const diag = diagnoseShadow({ selfPackageRoot: process.cwd(), selfVersion });
44
+ if (diag.shadowed) {
45
+ console.warn(
46
+ `\n[failproofai] Warning: another failproofai install is earlier on your PATH.\n` +
47
+ ` Just installed: ${diag.selfPackageRoot}` + (diag.selfVersion ? ` (v${diag.selfVersion})` : "") + `\n` +
48
+ ` PATH resolves : ${diag.pathFirstPath}` + (diag.pathFirstVersion ? ` (v${diag.pathFirstVersion})` : "") + `\n\n` +
49
+ ` Your shell will run the older copy. Remove the shadow with:\n` +
50
+ ` ${diag.recommendation}\n`
51
+ );
52
+ }
53
+ } catch {
54
+ // Diagnosis is best-effort — never fail the install over a warning.
55
+ }
56
+
32
57
  const FAILPROOFAI_HOOK_MARKER = "__failproofai_hook__";
33
58
  const NAMESPACE = "failproofai-telemetry-v1";
34
59