failproofai 1.0.0-beta.11 → 1.0.0-beta.13

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 (177) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js +2 -3
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page.js +2 -3
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js +2 -2
  29. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js +2 -2
  31. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +3 -2
  34. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +2 -2
  36. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/logout/route.js +2 -2
  38. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +2 -2
  40. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/auth/status/route.js +2 -2
  42. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  45. package/.next/standalone/.next/server/app/audit/page.js +2 -3
  46. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/index.html +1 -1
  49. package/.next/standalone/.next/server/app/index.rsc +15 -15
  50. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  51. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  52. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  53. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  54. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  55. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  56. package/.next/standalone/.next/server/app/page.js +2 -3
  57. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  60. package/.next/standalone/.next/server/app/policies/page.js +2 -3
  61. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/page.js +3 -4
  65. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +3 -4
  70. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  73. package/.next/standalone/.next/server/app/projects/page.js +3 -4
  74. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  75. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  76. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  77. package/.next/standalone/.next/server/app/settings/page.js +2 -3
  78. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  79. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  80. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1a7i2q4._.js → [root-of-the-server]__00ni9za._.js} +1 -1
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08y94xv._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ed01_d._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1_ps0jw._.js → [root-of-the-server]__0effm9m._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0lk_935._.js → [root-of-the-server]__0ix8gm7._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0ky_n0r._.js → [root-of-the-server]__0ji11a-._.js} +1 -1
  88. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0v2fpxe._.js → [root-of-the-server]__0ojwjxl._.js} +1 -1
  89. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vb5fl2._.js +3 -0
  90. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ibjy5x._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1uyc-z4._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/[root-of-the-server]__20d06rh._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -3
  95. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -3
  96. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1rdruw4._.js → [root-of-the-server]__0_1tiya._.js} +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__20ie7me._.js → [root-of-the-server]__0eeipso._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +3 -3
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1tyf0tc._.js → [root-of-the-server]__17itmx-._.js} +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1gwr8l-._.js → [root-of-the-server]__1dx-a-c._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mmefsr._.js +5 -0
  103. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1yw368c._.js → [root-of-the-server]__1n2vcv4._.js} +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1a2hcb_._.js → [root-of-the-server]__1tlvv9a._.js} +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/{_1fahg9e._.js → _0vo2qyt._.js} +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/{_0uz7o9y._.js → _0xej-0e._.js} +2 -2
  109. package/.next/standalone/.next/server/chunks/ssr/{_04mekdy._.js → _10399ge._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/{_0se484m._.js → _149k9oy._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/ssr/{_0n4bc3i._.js → _166jvu8._.js} +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/{_0r-dsre._.js → _1_hwp06._.js} +2 -2
  113. package/.next/standalone/.next/server/chunks/ssr/{_1w8sola._.js → _1mnhi_r._.js} +2 -2
  114. package/.next/standalone/.next/server/chunks/ssr/{_1w_6p9g._.js → _1ror7k9._.js} +2 -2
  115. package/.next/standalone/.next/server/chunks/ssr/{_1gmzkwj._.js → _1sdgprl._.js} +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  117. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  119. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  120. package/.next/standalone/.next/server/pages/404.html +1 -1
  121. package/.next/standalone/.next/server/pages/500.html +1 -1
  122. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  123. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  124. package/.next/standalone/.next/static/chunks/{0-m1vctj2jny6.js → 01s3vpzky_qew.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/0km-jn9o5xyqs.css +2 -0
  126. package/.next/standalone/.next/static/chunks/{0on899o88y54u.js → 0zbcd_n8ku2ov.js} +1 -1
  127. package/.next/standalone/.next/static/chunks/{04y6igmx5olt6.js → 1erji0ratofla.js} +1 -1
  128. package/.next/standalone/.next/static/chunks/{06abmgw7cvecc.js → 23hnlzqx44vbe.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/{1a9qlmo5s678i.js → 2cwsiyhc7d2cw.js} +1 -1
  130. package/.next/standalone/.next/static/chunks/{37nu_nos28w1x.js → 2y42ebzynyxdv.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{0ug1y_6-uwu5t.js → 315lncj7dsp9c.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/31_px0dfgdg9j.js +6 -0
  133. package/.next/standalone/.next/static/chunks/{43ylh_2r15k-w.js → 3l4-fu3xt75j8.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/3poscoxn69x9g.js +1 -0
  135. package/.next/standalone/app/policies/hooks-client.tsx +4 -4
  136. package/.next/standalone/ino.ujh3/.failproofai/VERSION +4 -0
  137. package/.next/standalone/ino.ujh3/.failproofai/state/last-version +1 -0
  138. package/.next/standalone/ino.ujh3/.failproofai/state/telemetry-id +1 -0
  139. package/.next/standalone/package.json +10 -10
  140. package/.next/standalone/server.js +1 -1
  141. package/bin/failproofai-worker.mjs +1 -1
  142. package/bin/failproofai.mjs +186 -5
  143. package/dist/cli.mjs +1130 -1352
  144. package/dist/worker.mjs +171 -1010
  145. package/package.json +10 -10
  146. package/src/hooks/backfill-cli.ts +1 -1
  147. package/src/hooks/cloud-connection.ts +4 -4
  148. package/src/hooks/cloud-enrollment-cli.ts +1 -1
  149. package/src/hooks/cloud-enrollment.ts +3 -3
  150. package/src/hooks/cloud-managed-policies.ts +60 -22
  151. package/src/hooks/configure-wizard.ts +124 -53
  152. package/src/hooks/custom-hooks-loader.ts +57 -8
  153. package/src/hooks/daemon-service.ts +66 -11
  154. package/src/hooks/flush-cli.ts +180 -0
  155. package/src/hooks/fp-config.ts +242 -118
  156. package/src/hooks/fp-home.ts +138 -22
  157. package/src/hooks/fp-reset.ts +357 -43
  158. package/src/hooks/handler.ts +11 -11
  159. package/src/hooks/harness-cli.ts +309 -0
  160. package/src/hooks/hook-activity-store.ts +5 -5
  161. package/src/hooks/manager.ts +35 -0
  162. package/src/hooks/onboarding-attempt.ts +22 -6
  163. package/src/hooks/tui.ts +64 -7
  164. package/src/hooks/uninstall-cli.ts +1 -1
  165. package/src/hooks/worker-server.ts +69 -12
  166. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +0 -3
  167. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1iny24d._.js +0 -3
  168. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tyedjt._.js +0 -3
  169. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +0 -5
  170. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +0 -4
  171. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +0 -5
  172. package/.next/standalone/.next/static/chunks/0p_y4ndalc68e.js +0 -6
  173. package/.next/standalone/.next/static/chunks/13vrxdsdihqd_.css +0 -2
  174. package/.next/standalone/.next/static/chunks/19lrnqxn7ni7i.js +0 -1
  175. /package/.next/standalone/.next/static/{BZDo0aAHzDwh5ntxgIgpc → ziAsXfNE0cH_S7LHK-Py3}/_buildManifest.js +0 -0
  176. /package/.next/standalone/.next/static/{BZDo0aAHzDwh5ntxgIgpc → ziAsXfNE0cH_S7LHK-Py3}/_clientMiddlewareManifest.js +0 -0
  177. /package/.next/standalone/.next/static/{BZDo0aAHzDwh5ntxgIgpc → ziAsXfNE0cH_S7LHK-Py3}/_ssgManifest.js +0 -0
@@ -0,0 +1,309 @@
1
+ /**
2
+ * `failproofai harness` — manage extra capture paths per harness.
3
+ *
4
+ * Each supported agent CLI has one location its own installer put it in, and
5
+ * the collector watches that. This command adds others: a second profile, a
6
+ * bind-mounted team share, a container's home mapped in beside the host's, an
7
+ * agent an operator relocated. Those hold real sessions and nothing collected
8
+ * them.
9
+ *
10
+ * # This file edits config; the daemon decides what the config means
11
+ *
12
+ * Entries are stored as written. The grammar (`label=path`, folder-name
13
+ * fallback), `~` expansion, overlap rejection and the `<label>-<agentId>`
14
+ * namespacing all live in `crates/fpai-collect/src/extra_paths.rs`, and the
15
+ * daemon reports what it rejected at startup. Re-implementing any of that here
16
+ * would give one product two parsers, and the way that fails is the CLI
17
+ * accepting a path the daemon then silently drops — which is indistinguishable
18
+ * from the feature working.
19
+ *
20
+ * The one thing validated here is the HARNESS NAME, because that failure has no
21
+ * other detector: `[collector.sources.claud]` is valid TOML, parses cleanly,
22
+ * captures nothing, and is mentioned nowhere else a user would look.
23
+ *
24
+ * # No restart, and no sudo
25
+ *
26
+ * This writes one file in the user's own home and nothing else. The daemon's
27
+ * collector manager re-reads `config.toml` on an interval and cycles the
28
+ * collector whenever the resolved `CollectorConfig` changes — the same path
29
+ * that already picks up `--connect`, a stream being switched off, and a
30
+ * verbosity change. `extra_paths` rides it for free by living inside
31
+ * `Settings`, which is part of the value being compared. That is load-bearing
32
+ * rather than incidental, and `collector_config_change_cycles_the_collector`
33
+ * in `crates/fpai-collect/src/config.rs` pins it: move `sources` out of
34
+ * `CollectorConfig` and the CLI would keep reporting success for paths the
35
+ * running daemon never picks up.
36
+ */
37
+
38
+ import { readConfig, updateConfig } from "./fp-config";
39
+ import { configFile } from "./fp-home";
40
+
41
+ /**
42
+ * Harness keys `[collector.sources.<key>]` accepts.
43
+ *
44
+ * MUST stay in step with `HARNESS_KEYS` in `crates/failproofaid/src/main.rs`,
45
+ * which is the side that actually registers the tasks. A name here that the
46
+ * daemon does not know configures a table nothing reads; a name the daemon
47
+ * knows and this list omits is a path the user cannot add. `__tests__` asserts
48
+ * the two lists are identical by reading the Rust source, because nothing else
49
+ * connects them.
50
+ *
51
+ * Twelve keys, thirteen sources: `claude` covers both the main and the subagent
52
+ * transcript formats, which share a root — an extra path holding Claude
53
+ * transcripts holds their subagents too.
54
+ */
55
+ export const HARNESS_KEYS = [
56
+ "claude",
57
+ "codex",
58
+ "copilot",
59
+ "openclaw",
60
+ "pi",
61
+ "factory",
62
+ "antigravity",
63
+ "cursor",
64
+ "goose",
65
+ "opencode",
66
+ "devin",
67
+ "hermes",
68
+ ] as const;
69
+
70
+ export type HarnessKey = (typeof HARNESS_KEYS)[number];
71
+
72
+ export interface HarnessResult {
73
+ lines: string[];
74
+ exitCode: number;
75
+ }
76
+
77
+ const ok = (lines: string[]): HarnessResult => ({ lines, exitCode: 0 });
78
+ const fail = (lines: string[]): HarnessResult => ({ lines, exitCode: 1 });
79
+
80
+ function unknownHarness(name: string): HarnessResult {
81
+ return fail([
82
+ `Unknown harness: ${name}`,
83
+ "",
84
+ `Known harnesses: ${HARNESS_KEYS.join(", ")}`,
85
+ ]);
86
+ }
87
+
88
+ /** The path half of an entry, for comparing against what a user typed. */
89
+ function pathOf(entry: string): string {
90
+ const eq = entry.indexOf("=");
91
+ if (eq <= 0) return entry.trim();
92
+ const label = entry.slice(0, eq).trim();
93
+ return label === "" ? entry.trim() : entry.slice(eq + 1).trim();
94
+ }
95
+
96
+ /** The label half, or `null` when the entry did not name one. */
97
+ function labelOf(entry: string): string | null {
98
+ const eq = entry.indexOf("=");
99
+ if (eq <= 0) return null;
100
+ const label = entry.slice(0, eq).trim();
101
+ return label === "" ? null : label;
102
+ }
103
+
104
+ function currentPaths(harness: string): string[] {
105
+ return readConfig().collector.sources?.[harness]?.extraPaths ?? [];
106
+ }
107
+
108
+ function writePaths(harness: string, paths: string[]): void {
109
+ const cfg = readConfig();
110
+ const sources = { ...(cfg.collector.sources ?? {}) };
111
+ if (paths.length > 0) sources[harness] = { extraPaths: paths };
112
+ else delete sources[harness];
113
+ updateConfig({
114
+ collector: {
115
+ ...cfg.collector,
116
+ // `undefined` rather than `{}` when the last entry goes, so removing what
117
+ // you added leaves the file as it was rather than carrying an empty table
118
+ // forever.
119
+ sources: Object.keys(sources).length > 0 ? sources : undefined,
120
+ },
121
+ });
122
+ }
123
+
124
+ /**
125
+ * No restart, and deliberately no instruction to perform one.
126
+ *
127
+ * The daemon's collector manager re-reads `config.toml` on an interval and
128
+ * cycles the collector whenever the resolved `CollectorConfig` changes — the
129
+ * same path that already picks up `--connect`, a stream being switched off and
130
+ * a verbosity change. `extra_paths` rides that for free because it lives inside
131
+ * `Settings`, which lives inside the value being compared.
132
+ *
133
+ * Verified live: adding a path to a running daemon logged "collector
134
+ * configuration changed; cycling the collector", took the task count from 21 to
135
+ * 23, and the first transcript written under the new path reached the server.
136
+ *
137
+ * This said "run `sudo systemctl restart failproofaid@$USER`" first. That was
138
+ * wrong twice over: the restart is unnecessary, and it asks for root from a
139
+ * command that writes one file in the user's own home and needs none.
140
+ */
141
+ const TAKES_EFFECT_HINT = [
142
+ "",
143
+ "The daemon picks this up on its own within a few seconds — no restart, no sudo.",
144
+ ];
145
+
146
+ export function addPath(harness: string, entry: string): HarnessResult {
147
+ if (!(HARNESS_KEYS as readonly string[]).includes(harness)) return unknownHarness(harness);
148
+ const trimmed = entry.trim();
149
+ if (trimmed === "") return fail(["A path is required."]);
150
+ if (pathOf(trimmed) === "") {
151
+ return fail([`Could not read a path out of ${JSON.stringify(entry)}.`, "", "Use `<path>` or `<label>=<path>`."]);
152
+ }
153
+
154
+ const existing = currentPaths(harness);
155
+
156
+ // Exact duplicate: succeed rather than error. `add-path` run twice from a
157
+ // provisioning script must not fail the second time.
158
+ if (existing.includes(trimmed)) {
159
+ return ok([`${harness}: ${trimmed} is already configured.`]);
160
+ }
161
+
162
+ // Same path under a different label, or the same label on a different path.
163
+ // Both are rejected HERE rather than left to the daemon, because the daemon
164
+ // resolves them at startup and drops one — so the CLI would report success
165
+ // for a path that is never captured.
166
+ const samePath = existing.find((e) => pathOf(e) === pathOf(trimmed));
167
+ if (samePath) {
168
+ return fail([
169
+ `${harness} already captures ${pathOf(trimmed)} as ${JSON.stringify(samePath)}.`,
170
+ "",
171
+ "Remove it first if you meant to relabel it:",
172
+ ` failproofai harness remove-path ${harness} ${pathOf(trimmed)}`,
173
+ ]);
174
+ }
175
+ const wanted = labelOf(trimmed);
176
+ if (wanted) {
177
+ const sameLabel = existing.find((e) => labelOf(e) === wanted);
178
+ if (sameLabel) {
179
+ return fail([
180
+ `${harness} already uses the label ${JSON.stringify(wanted)} for ${pathOf(sameLabel)}.`,
181
+ "",
182
+ "Labels namespace agent ids, so two paths cannot share one.",
183
+ ]);
184
+ }
185
+ }
186
+
187
+ writePaths(harness, [...existing, trimmed]);
188
+ return ok([
189
+ `${harness}: now also capturing ${pathOf(trimmed)}`,
190
+ wanted
191
+ ? ` agent ids will be namespaced ${wanted}-*`
192
+ : " a label will be derived from the folder name; `failproofai harness list` shows it",
193
+ ` written to ${configFile()}`,
194
+ ...TAKES_EFFECT_HINT,
195
+ ]);
196
+ }
197
+
198
+ export function removePath(harness: string, target: string): HarnessResult {
199
+ if (!(HARNESS_KEYS as readonly string[]).includes(harness)) return unknownHarness(harness);
200
+ const existing = currentPaths(harness);
201
+ const t = target.trim();
202
+ // Match on the whole entry, on its path, or on its label — a user removing
203
+ // something is working from what `list` printed, which shows all three.
204
+ const kept = existing.filter((e) => e !== t && pathOf(e) !== t && labelOf(e) !== t);
205
+ if (kept.length === existing.length) {
206
+ return fail([
207
+ `${harness} has no extra path matching ${JSON.stringify(t)}.`,
208
+ "",
209
+ existing.length > 0
210
+ ? `Configured: ${existing.join(", ")}`
211
+ : `${harness} has no extra paths configured.`,
212
+ ]);
213
+ }
214
+ writePaths(harness, kept);
215
+ return ok([
216
+ `${harness}: no longer capturing ${t}`,
217
+ "",
218
+ "Already-collected sessions from that path are NOT removed — they are on the",
219
+ "server. This only stops new ones.",
220
+ ...TAKES_EFFECT_HINT,
221
+ ]);
222
+ }
223
+
224
+ export function listPaths(harness?: string): HarnessResult {
225
+ if (harness && !(HARNESS_KEYS as readonly string[]).includes(harness)) return unknownHarness(harness);
226
+ const sources = readConfig().collector.sources ?? {};
227
+ const names: string[] = harness
228
+ ? [harness]
229
+ : HARNESS_KEYS.filter((k) => sources[k]?.extraPaths?.length);
230
+
231
+ // Computed BEFORE the empty-state return, not after. A config whose only
232
+ // tables are typos has no known harness configured, so the early return fired
233
+ // and the warning below was unreachable in precisely the case it exists for —
234
+ // the user sees "No extra capture paths configured" while their file plainly
235
+ // contains one, which is the most confusing possible answer.
236
+ const unknown = Object.keys(sources).filter(
237
+ (k) => !(HARNESS_KEYS as readonly string[]).includes(k),
238
+ );
239
+
240
+ if (names.length === 0 && unknown.length === 0) {
241
+ return ok([
242
+ "No extra capture paths configured.",
243
+ "",
244
+ "Every harness is watching only its default location. Add one with:",
245
+ " failproofai harness add-path <harness> [<label>=]<path>",
246
+ "",
247
+ `Harnesses: ${HARNESS_KEYS.join(", ")}`,
248
+ ]);
249
+ }
250
+
251
+ const lines: string[] = [];
252
+ for (const name of names) {
253
+ const entries = sources[name]?.extraPaths ?? [];
254
+ if (entries.length === 0) {
255
+ if (harness) lines.push(`${name}: no extra paths configured (default location only)`);
256
+ continue;
257
+ }
258
+ lines.push(`${name}:`);
259
+ for (const e of entries) {
260
+ const label = labelOf(e);
261
+ lines.push(
262
+ label
263
+ ? ` ${pathOf(e)} → agent ids ${label}-*`
264
+ : ` ${pathOf(e)} → label derived from the folder name`,
265
+ );
266
+ }
267
+ }
268
+ // Unknown tables are surfaced here as well as by the daemon: a user who
269
+ // hand-edited config.toml runs `list` to check it, and that is the moment the
270
+ // typo is cheapest to find.
271
+ if (unknown.length > 0) {
272
+ if (lines.length === 0) lines.push("No extra capture paths configured for any known harness.");
273
+ lines.push(
274
+ "",
275
+ `⚠ config.json configures ${unknown.length} unknown harness(es): ${unknown.join(", ")}`,
276
+ " Nothing is captured from them.",
277
+ ` Known: ${HARNESS_KEYS.join(", ")}`,
278
+ );
279
+ }
280
+ return ok(lines);
281
+ }
282
+
283
+ /** Dispatch for `failproofai harness <sub> ...`. */
284
+ export function runHarnessCommand(argv: string[]): HarnessResult {
285
+ const [sub, ...rest] = argv;
286
+ switch (sub) {
287
+ case "add-path":
288
+ if (rest.length < 2) {
289
+ return fail(["Usage: failproofai harness add-path <harness> [<label>=]<path>"]);
290
+ }
291
+ return addPath(rest[0], rest.slice(1).join(" "));
292
+ case "remove-path":
293
+ if (rest.length < 2) {
294
+ return fail(["Usage: failproofai harness remove-path <harness> <path|label>"]);
295
+ }
296
+ return removePath(rest[0], rest.slice(1).join(" "));
297
+ case "list":
298
+ return listPaths(rest[0]);
299
+ default:
300
+ return fail([
301
+ sub ? `Unknown subcommand: ${sub}` : "A subcommand is required.",
302
+ "",
303
+ "Usage:",
304
+ " failproofai harness list [<harness>]",
305
+ " failproofai harness add-path <harness> [<label>=]<path>",
306
+ " failproofai harness remove-path <harness> <path|label>",
307
+ ]);
308
+ }
309
+ }
@@ -99,16 +99,16 @@ export interface HookActivityEntry {
99
99
  policySource?: "builtin" | "custom" | "convention" | "cloud";
100
100
  /** Cloud policy id of the decider. Present only when `policySource` is "cloud". */
101
101
  cloudPolicyId?: string;
102
- /** Immutable revision of that policy — the half of attribution that identifies WHICH version ran. */
103
- cloudRevision?: number;
102
+ /** Immutable version of that policy — the half of attribution that identifies WHICH version ran. */
103
+ cloudVersion?: number;
104
104
  /**
105
- * The cloud generation active when this event was evaluated, recorded on
105
+ * The cloud deployment active when this event was evaluated, recorded on
106
106
  * every row of a managed machine regardless of what decided. "What was
107
107
  * deployed here" is a different question from "what decided", and only this
108
108
  * distinguishes a rollout that changed no outcomes from one that never
109
109
  * arrived.
110
110
  */
111
- cloudGeneration?: number;
111
+ cloudDeployment?: number;
112
112
  /**
113
113
  * What observe-mode policies WOULD have done, had they been enforcing. This
114
114
  * record is the entire point of observe mode: without it the row is
@@ -117,7 +117,7 @@ export interface HookActivityEntry {
117
117
  */
118
118
  observed?: Array<{
119
119
  policyId: string;
120
- revision: number;
120
+ version: number;
121
121
  decision: "deny" | "instruct";
122
122
  reason: string | null;
123
123
  }>;
@@ -25,6 +25,7 @@ import { getInstanceId, hashToId } from "../../lib/telemetry-id";
25
25
  import { CliError } from "../cli-error";
26
26
  import { hookLogWarn } from "./hook-logger";
27
27
  import { customPoliciesDir, globalPolicyConfigFile } from "./fp-home";
28
+ import { readActiveCloudManagedPolicies } from "./cloud-managed-policies";
28
29
 
29
30
  const VALID_POLICY_NAMES = new Set(BUILTIN_POLICIES.map((p) => p.name));
30
31
 
@@ -839,6 +840,40 @@ export async function listHooks(cwd?: string): Promise<void> {
839
840
  console.log();
840
841
  }
841
842
 
843
+ // Cloud-managed policies. These enforce on this machine exactly like the two
844
+ // sections above, but nothing here listed them — so `failproofai policies`
845
+ // answered "what is enforcing?" with a subset, and the policies an operator
846
+ // pushed to a fleet were the ones invisible to the person running the
847
+ // command on it.
848
+ //
849
+ // Read-only on purpose: these are owned by the deployment, not by local
850
+ // config. `--uninstall <name>` cannot switch one off, and printing them
851
+ // beside toggleable rows without saying so would imply it can.
852
+ try {
853
+ const cloud = readActiveCloudManagedPolicies();
854
+ if (cloud.length > 0) {
855
+ const gen = cloud[0].deployment;
856
+ console.log(
857
+ `\n \u2500\u2500 Cloud-managed \u2014 deployment ${gen} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`,
858
+ );
859
+ const colWidth = Math.max(nameColWidth, ...cloud.map((c) => c.id.length + 2));
860
+ for (const artifact of cloud) {
861
+ // `observe` is evaluated and then has its verdict discarded, so a row
862
+ // that read "ON" would claim enforcement this policy deliberately is
863
+ // not doing.
864
+ const status =
865
+ artifact.effect === "observe" ? "\x1B[33m\u25D0 OBS\x1B[0m" : "\x1B[32m\u2713 ON\x1B[0m";
866
+ console.log(` ${status} ${artifact.id.padEnd(colWidth)}v${artifact.version}`);
867
+ }
868
+ console.log("\n Managed from the dashboard \u2014 not switchable with `failproofai policies`.");
869
+ console.log();
870
+ }
871
+ } catch {
872
+ // A machine with no deployment, or an unreadable manifest, simply has no
873
+ // section. The hook path reports its own failures; a listing must not be
874
+ // the thing that turns a bad manifest into a broken command.
875
+ }
876
+
842
877
  // Mirror what was just listed into the USER config. Safe here because
843
878
  // `failproofai policies` is a one-shot command — never do this on the hook
844
879
  // path (see the HooksConfig.conventionPolicies doc comment).
@@ -29,11 +29,13 @@
29
29
  * elevate. So each reason carries a cheap local check for whether its blocker
30
30
  * is gone, and the wizard offers itself again the moment one is:
31
31
  *
32
- * needs_root -> elevation is now possible without a prompt
33
- * daemon_failed -> the service manager now reports something different
34
- * cancelled -> the CLI version changed; an upgrade is a fair moment to
35
- * ask again, and nothing else about a deliberate cancel
36
- * should re-nag
32
+ * needs_root -> elevation is now possible without a prompt
33
+ * daemon_failed -> the service manager now reports something different
34
+ * cancelled -> the CLI version changed; an upgrade is a fair moment to
35
+ * ask again, and nothing else about a deliberate cancel
36
+ * should re-nag
37
+ * unsupported_platform -> the CLI version changed (a future release might
38
+ * support this platform)
37
39
  *
38
40
  * Every probe is local and takes milliseconds. None of them runs on the hook
39
41
  * path — `--hook` never reaches the first-run gate at all.
@@ -150,6 +152,11 @@ export function blockerCleared(attempt: OnboardingAttempt, probe: RetryProbe): b
150
152
  // this module removes — but an upgrade is a new thing to say, so it is
151
153
  // allowed to ask once more.
152
154
  return probe.cliVersion !== attempt.cliVersion;
155
+ case "unsupported_platform":
156
+ // A permanent property of the machine, not the invocation — nagging every
157
+ // command would just repeat the hard-fail. Only a new release is a reason
158
+ // to ask again (it might add support for this platform).
159
+ return probe.cliVersion !== attempt.cliVersion;
153
160
  case "not_a_tty":
154
161
  case "running_as_sudo":
155
162
  // Both are properties of the invocation, not of the machine, so the next
@@ -166,14 +173,23 @@ export function attemptHintLines(attempt: OnboardingAttempt): string[] {
166
173
  needs_root: "it needs root to install the failproofaid service",
167
174
  daemon_failed: "the failproofaid service could not be started",
168
175
  cancelled: "it was cancelled",
176
+ unsupported_platform: "failproofaid does not run on this platform",
169
177
  not_a_tty: "there was no terminal to ask in",
170
178
  running_as_sudo: "it was run under sudo",
171
179
  };
172
180
  const detail = why[attempt.reason] ?? "it did not finish";
181
+ // Every other reason is fixable by the user right now, so the generic
182
+ // retry is correct. unsupported_platform is not — blockerCleared only
183
+ // re-offers it on a CLI version change, and `failproofai config` would
184
+ // hit the exact same hard-fail in the meantime.
185
+ const action =
186
+ attempt.reason === "unsupported_platform"
187
+ ? "Check for a failproofai update — this platform may be supported by a newer release."
188
+ : "Run `failproofai config` when you are ready.";
173
189
  return [
174
190
  ``,
175
191
  `[failproofai] Setup is not finished — ${detail}.`,
176
- ` Run \`failproofai config\` when you are ready.`,
192
+ ` ${action}`,
177
193
  ``,
178
194
  ];
179
195
  }
package/src/hooks/tui.ts CHANGED
@@ -34,6 +34,8 @@ export interface SelectChoice<T> {
34
34
  }
35
35
 
36
36
  export interface SelectOneOptions<T> {
37
+ /** Offer ← to go back a step. The caller must handle `BACK`. */
38
+ allowBack?: boolean;
37
39
  message: string;
38
40
  choices: SelectChoice<T>[];
39
41
  /** Static info lines rendered under the question (e.g. a review summary). */
@@ -64,6 +66,8 @@ export interface MultiChoice<T> {
64
66
  }
65
67
 
66
68
  export interface MultiSelectOptions<T> {
69
+ /** Offer ← to go back a step. The caller must handle `BACK`. */
70
+ allowBack?: boolean;
67
71
  message: string;
68
72
  choices: MultiChoice<T>[];
69
73
  minSelected?: number;
@@ -423,6 +427,8 @@ interface PromptSpec<R> {
423
427
  renderRow: (index: number, active: boolean, budget: number) => string;
424
428
  /** Extra line(s) above the footer (e.g. a min-selected warning). */
425
429
  warnLine?: () => string | null;
430
+ /** When set, ← resolves `BACK` so the caller can step backwards. */
431
+ allowBack?: boolean;
426
432
  footer: string;
427
433
  /** Handle non-navigation keys. `{done}` finishes, `"redraw"` repaints. */
428
434
  onKey: (key: readline.Key, cursor: number) => { done: R } | "redraw" | undefined;
@@ -469,10 +475,21 @@ function runPrompt<R>(p: PromptSpec<R>): Promise<R | null> {
469
475
  };
470
476
 
471
477
  const collapse = (result: R | null): void => {
478
+ // BACK is handled HERE, not in each `summaryFor`, because it is not a value
479
+ // of `R` at all — it is a sentinel the shared key handler injects, so every
480
+ // prompt would otherwise have to know about a symbol it never declared.
481
+ //
482
+ // Both existing callers got it wrong in different ways, and one of them
483
+ // hung: `multiSelect`'s summary calls `values.includes(...)`, which throws
484
+ // `TypeError` on a symbol — and it throws INSIDE `finish`, before
485
+ // `resolve(result)`, so pressing ← never settled the promise and the wizard
486
+ // stopped responding entirely. `selectOne` fell through to `String(value)`
487
+ // and rendered the literal text `Symbol(failproofai.back)`.
488
+ const summary = (result as unknown) === BACK ? "back" : p.summaryFor(result);
472
489
  repaint(stdout, region, [
473
490
  c.dim(BAR),
474
491
  `${c.dim(STEP_DONE)} ${p.message}`,
475
- `${c.dim(BAR)} ${c.dim(p.summaryFor(result))}`,
492
+ `${c.dim(BAR)} ${c.dim(summary)}`,
476
493
  ]);
477
494
  };
478
495
 
@@ -500,6 +517,10 @@ function runPrompt<R>(p: PromptSpec<R>): Promise<R | null> {
500
517
  if (!key) return;
501
518
  if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") {
502
519
  finish(null);
520
+ } else if (key.name === "left" && p.allowBack) {
521
+ // Only when the caller opted in. A prompt with nowhere to go back TO
522
+ // must not appear to offer it.
523
+ finish(BACK as unknown as never);
503
524
  } else if (key.name === "up") {
504
525
  cursor = cursor > 0 ? cursor - 1 : choices.length - 1;
505
526
  repaint(stdout, region, build());
@@ -519,7 +540,24 @@ function runPrompt<R>(p: PromptSpec<R>): Promise<R | null> {
519
540
 
520
541
  // ── selectOne (radio) ─────────────────────────────────────────────────────────
521
542
 
522
- export function selectOne<T>(opts: SelectOneOptions<T>): Promise<T | null> {
543
+ /**
544
+ * Returned by a prompt when the user asked to go BACK a step, as distinct from
545
+ * cancelling. Both used to be `null`, which made "I picked the wrong scope" and
546
+ * "I want out" the same keystroke — so the only way to change an earlier answer
547
+ * was to abandon setup and start over.
548
+ *
549
+ * A symbol rather than a sentinel string because a caller's value type is its
550
+ * own: `selectOne<string>` could legitimately have "back" as a real choice.
551
+ */
552
+ export const BACK: unique symbol = Symbol("failproofai.back");
553
+ export type Back = typeof BACK;
554
+
555
+ // Overloaded so `BACK` appears in the return type ONLY where it was asked for.
556
+ // Widening every caller to `T | Back | null` would make dozens of call sites
557
+ // handle a value they can never receive.
558
+ export function selectOne<T>(opts: SelectOneOptions<T> & { allowBack: true }): Promise<T | Back | null>;
559
+ export function selectOne<T>(opts: SelectOneOptions<T>): Promise<T | null>;
560
+ export function selectOne<T>(opts: SelectOneOptions<T>): Promise<T | Back | null> {
523
561
  const stdin: TTYIn = opts.stdin ?? process.stdin;
524
562
  const stdout: TTYOut = opts.stdout ?? process.stdout;
525
563
  const choices = opts.choices;
@@ -550,7 +588,10 @@ export function selectOne<T>(opts: SelectOneOptions<T>): Promise<T | null> {
550
588
  const hint = choice.hint ? ` ${c.dim(ellipsize(choice.hint, budget))}` : "";
551
589
  return `${dot} ${label}${hint}`;
552
590
  },
553
- footer: "↑/↓ navigate · enter to select · esc to cancel",
591
+ allowBack: opts.allowBack,
592
+ footer: opts.allowBack
593
+ ? "↑/↓ navigate · enter to select · ← back · esc to cancel"
594
+ : "↑/↓ navigate · enter to select · esc to cancel",
554
595
  onKey: (key, cursor) =>
555
596
  key.name === "return" ? { done: choices[cursor].value } : undefined,
556
597
  summaryFor: (value) =>
@@ -562,7 +603,9 @@ export function selectOne<T>(opts: SelectOneOptions<T>): Promise<T | null> {
562
603
 
563
604
  // ── multiSelect (checklist) ────────────────────────────────────────────────────
564
605
 
565
- export function multiSelect<T>(opts: MultiSelectOptions<T>): Promise<T[] | null> {
606
+ export function multiSelect<T>(opts: MultiSelectOptions<T> & { allowBack: true }): Promise<T[] | null | Back>;
607
+ export function multiSelect<T>(opts: MultiSelectOptions<T>): Promise<T[] | null>;
608
+ export function multiSelect<T>(opts: MultiSelectOptions<T>): Promise<T[] | null | Back> {
566
609
  const stdin: TTYIn = opts.stdin ?? process.stdin;
567
610
  const stdout: TTYOut = opts.stdout ?? process.stdout;
568
611
  const choices = opts.choices;
@@ -604,7 +647,12 @@ export function multiSelect<T>(opts: MultiSelectOptions<T>): Promise<T[] | null>
604
647
  return `${caret} ${box} ${label}${hint}`;
605
648
  },
606
649
  warnLine: () => (warn ? c.warn(`Select at least ${minSelected}.`) : null),
607
- footer: opts.hint ?? "↑/↓ move · space select · ctrl+a all · enter confirm",
650
+ allowBack: opts.allowBack,
651
+ footer:
652
+ opts.hint ??
653
+ (opts.allowBack
654
+ ? "↑/↓ move · space select · ctrl+a all · ← back · enter confirm"
655
+ : "↑/↓ move · space select · ctrl+a all · enter confirm"),
608
656
  onKey: (key, cursor) => {
609
657
  if (key.name === "space") {
610
658
  if (choices[cursor]?.locked) return "redraw"; // always on — not a choice
@@ -705,10 +753,19 @@ export function promptText(opts: PromptTextOptions): Promise<string | null> {
705
753
  return new Promise((resolve) => {
706
754
  let value = "";
707
755
  const draw = (error?: string) => {
756
+ const cols = stdout.columns || 80;
708
757
  const shown = opts.mask ? "•".repeat(value.length) : value;
709
758
  const hint = opts.hint ? ` ${c.dim(opts.hint)}` : "";
710
- const err = error ? `\n ${c.warn(error)}` : "";
711
- stdout.write(`\r\x1b[2K${c.bold(opts.message)} ${shown}${hint}${err}`);
759
+ // Truncate to ONE physical row. `\r\x1b[2K` erases the row the cursor is
760
+ // on and nothing above it — so a line wider than the terminal wraps, the
761
+ // erase reaches only its last row, and every keystroke leaves the earlier
762
+ // rows behind. That is why pasting a 40-character API key printed 40
763
+ // stacked copies of the prompt: `API key for <host>` plus the masked
764
+ // value plus the `needs events:add · policies:pull …` hint is past 80
765
+ // columns before the key is even half typed.
766
+ const line = truncate(`${c.bold(opts.message)} ${shown}${hint}`, cols - 1);
767
+ const err = error ? `\n ${truncate(c.warn(error), cols - 3)}` : "";
768
+ stdout.write(`\r\x1b[2K${line}${err}`);
712
769
  if (err) stdout.write("\x1b[1A");
713
770
  };
714
771
  draw();
@@ -229,7 +229,7 @@ export async function runUninstallCommand(opts: UninstallOptions = {}): Promise<
229
229
  purged: false,
230
230
  lines: [
231
231
  ...lines,
232
- `✗ could not update ${failproofaiHome()}/config.toml: ${err instanceof Error ? err.message : String(err)}`,
232
+ `✗ could not update ${failproofaiHome()}/config.json: ${err instanceof Error ? err.message : String(err)}`,
233
233
  ``,
234
234
  `Stopped before touching the service. Removing it while this machine still`,
235
235
  `requires it would deny every tool call. Fix the file's permissions and re-run.`,