failproofai 1.0.0-beta.2 → 1.0.0-beta.3

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 (196) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  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/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  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/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  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.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  46. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  47. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  48. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  54. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  58. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  67. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  75. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  93. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  94. package/.next/standalone/.next/server/pages/404.html +1 -1
  95. package/.next/standalone/.next/server/pages/500.html +1 -1
  96. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  97. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  98. package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  104. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  109. package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
  110. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  111. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  112. package/.next/standalone/Cargo.lock +1992 -198
  113. package/.next/standalone/Cargo.toml +1 -1
  114. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  115. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  116. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  117. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  118. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  119. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  120. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  121. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  122. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  123. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  124. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  125. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  126. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  127. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  128. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  129. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  130. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  131. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  132. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  133. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  134. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  135. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  136. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  137. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  138. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  139. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  140. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  141. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  142. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  143. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  144. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  145. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  146. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  147. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  148. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  149. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  150. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  151. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  152. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  153. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  154. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  155. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  156. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  157. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  158. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  159. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  160. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  161. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  162. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  163. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  164. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  165. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  166. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  167. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  168. package/.next/standalone/package.json +2 -2
  169. package/.next/standalone/server.js +1 -1
  170. package/bin/failproofai.mjs +129 -0
  171. package/dist/cli.mjs +2195 -770
  172. package/dist/worker.mjs +365 -52
  173. package/package.json +2 -2
  174. package/src/hooks/builtin-policies.ts +139 -0
  175. package/src/hooks/cloud-connection.ts +205 -0
  176. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  177. package/src/hooks/cloud-enrollment.ts +173 -0
  178. package/src/hooks/cloud-managed-policies.ts +138 -0
  179. package/src/hooks/collector-config.ts +226 -0
  180. package/src/hooks/configure-wizard.ts +221 -5
  181. package/src/hooks/custom-hooks-loader.ts +69 -25
  182. package/src/hooks/daemon-client.ts +18 -0
  183. package/src/hooks/handler.ts +144 -6
  184. package/src/hooks/hook-activity-store.ts +50 -0
  185. package/src/hooks/loader-utils.ts +6 -4
  186. package/src/hooks/policy-types.ts +35 -0
  187. package/src/hooks/session-pause-cli.ts +157 -0
  188. package/src/hooks/session-pause.ts +236 -0
  189. package/src/hooks/tui.ts +115 -0
  190. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  191. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  192. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  193. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  194. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
  195. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
@@ -0,0 +1,393 @@
1
+ //! Claude subagent transcripts: file identity, the parent link, and the
2
+ //! disjointness the two `Format`s rest on.
3
+ //!
4
+ //! Path shapes and record shapes are verbatim from the 124 files under
5
+ //! `~/.claude/projects/**/subagents/` (Claude Code 2.1.220).
6
+
7
+ use std::fs;
8
+ use std::path::{Path, PathBuf};
9
+ use std::sync::Arc;
10
+ use std::sync::atomic::AtomicBool;
11
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
+
13
+ use fpai_collect::filetail::{self, Ctx, Params, RereadPolicy, Spec};
14
+ use fpai_collect::sources::claude;
15
+ use fpai_collect::supervisor::Shutdown;
16
+ use serde_json::{Value, json};
17
+
18
+ /// A real parent session id, and a real 17-hex agent id.
19
+ const PARENT: &str = "3ee9c788-8772-4f92-be0b-a80ede7ac48e";
20
+ const AGENT: &str = "a5a59824a02ebc510";
21
+
22
+ fn tmpdir(name: &str) -> PathBuf {
23
+ let d = std::env::temp_dir().join(format!(
24
+ "fpai-clsub-{}-{}-{}",
25
+ name,
26
+ std::process::id(),
27
+ SystemTime::now()
28
+ .duration_since(UNIX_EPOCH)
29
+ .unwrap()
30
+ .as_nanos()
31
+ ));
32
+ fs::create_dir_all(&d).unwrap();
33
+ d
34
+ }
35
+
36
+ /// `projects/<slug>/<parent>/subagents/`.
37
+ fn flat(root: &Path) -> PathBuf {
38
+ root.join("-home-u-repo").join(PARENT).join("subagents")
39
+ }
40
+
41
+ /// `projects/<slug>/<parent>/subagents/workflows/wf_<id>/` — the same tree with
42
+ /// two extra levels inserted.
43
+ fn workflow(root: &Path) -> PathBuf {
44
+ flat(root).join("workflows").join("wf_ca1e341f-c30")
45
+ }
46
+
47
+ fn ctx() -> Ctx {
48
+ Ctx {
49
+ session_id: format!("{PARENT}:{AGENT}"),
50
+ agent_id: "claude-Explore".into(),
51
+ environment: "local".into(),
52
+ }
53
+ }
54
+
55
+ /// The first line of a real subagent transcript: a genuine timestamped `user`
56
+ /// turn carrying the PARENT's `sessionId` and its own `agentId`.
57
+ fn sub_prompt(ts: &str, text: &str) -> String {
58
+ json!({"parentUuid":null,"isSidechain":true,"agentId":AGENT,"type":"user",
59
+ "uuid":"669bdae5-3111-4d87-bb17-f18874e96232","timestamp":ts,
60
+ "cwd":"/home/u/repo","sessionId":PARENT,"version":"2.1.220",
61
+ "gitBranch":"feat/collector",
62
+ "message":{"role":"user","content":text}})
63
+ .to_string()
64
+ }
65
+
66
+ fn sub_tool_use(ts: &str, call_id: &str, name: &str) -> String {
67
+ json!({"isSidechain":true,"agentId":AGENT,"type":"assistant","timestamp":ts,
68
+ "attributionAgent":"Explore","sessionId":PARENT,
69
+ "message":{"model":"claude-opus-4-8","id":"msg_01","role":"assistant",
70
+ "content":[{"type":"tool_use","id":call_id,"name":name,"input":{"command":"ls"}}],
71
+ "usage":{"input_tokens":2,"output_tokens":91}}})
72
+ .to_string()
73
+ }
74
+
75
+ // ── file identity ────────────────────────────────────────────────────────
76
+
77
+ #[test]
78
+ fn a_subagent_transcript_is_claimed_in_both_the_flat_and_workflow_layouts() {
79
+ // The workflow layout inserts `workflows/wf_<id>/` between `subagents/` and
80
+ // the file. Anchoring on a depth count instead of the literal component
81
+ // would be confidently wrong on exactly one of the two shapes.
82
+ let root = Path::new("/p");
83
+ let is = claude::SUBAGENT_FORMAT.is_source_file;
84
+ assert!(is(&flat(root).join(format!("agent-{AGENT}.jsonl"))));
85
+ assert!(is(&workflow(root).join(format!("agent-{AGENT}.jsonl"))));
86
+ }
87
+
88
+ #[test]
89
+ fn the_subagent_format_rejects_its_siblings() {
90
+ let root = Path::new("/p");
91
+ let is = claude::SUBAGENT_FORMAT.is_source_file;
92
+ // A different schema entirely (`{type, key, agentId}`, no timestamps) that
93
+ // lives in both layouts. Tailing it would invent a session out of nothing.
94
+ assert!(!is(&flat(root).join("journal.jsonl")));
95
+ assert!(!is(&workflow(root).join("journal.jsonl")));
96
+ // The sidecar this format READS for the agent type; it is rewritten and is
97
+ // not a transcript.
98
+ assert!(!is(&flat(root).join(format!("agent-{AGENT}.meta.json"))));
99
+ // An empty agent id would key a session on nothing.
100
+ assert!(!is(&flat(root).join("agent-.jsonl")));
101
+ // `agent-*.jsonl` outside the tree is not ours.
102
+ assert!(!is(&root.join("-home-u-repo").join("agent-x.jsonl")));
103
+ }
104
+
105
+ #[test]
106
+ fn the_two_claude_formats_are_disjoint_over_every_path_in_the_tree() {
107
+ // If both claimed one file it would be tailed twice and every line would
108
+ // ship under two different session ids — silently doubling a session's
109
+ // token totals and its tool-call counts.
110
+ let root = Path::new("/p");
111
+ let proj = root.join("-home-u-repo");
112
+ let paths = [
113
+ proj.join(format!("{PARENT}.jsonl")),
114
+ proj.join(format!("{PARENT}.jsonl.tool-calls.json")),
115
+ proj.join("journal.jsonl"),
116
+ flat(root).join(format!("agent-{AGENT}.jsonl")),
117
+ flat(root).join(format!("agent-{AGENT}.meta.json")),
118
+ flat(root).join("journal.jsonl"),
119
+ workflow(root).join(format!("agent-{AGENT}.jsonl")),
120
+ workflow(root).join("journal.jsonl"),
121
+ proj.join("notes.txt"),
122
+ ];
123
+ for p in paths {
124
+ let main = (claude::FORMAT.is_source_file)(&p);
125
+ let sub = (claude::SUBAGENT_FORMAT.is_source_file)(&p);
126
+ assert!(!(main && sub), "both formats claim {}", p.display());
127
+ }
128
+ }
129
+
130
+ #[test]
131
+ fn a_subagent_becomes_a_child_session_keyed_on_its_parent_and_its_agent_id() {
132
+ let root = Path::new("/p");
133
+ let expected = format!("{PARENT}:{AGENT}");
134
+ let id = claude::SUBAGENT_FORMAT.session_id_from_path;
135
+ assert_eq!(
136
+ id(&flat(root).join(format!("agent-{AGENT}.jsonl"))).as_deref(),
137
+ Some(expected.as_str())
138
+ );
139
+ // The workflow layout carries the SAME parent id, two levels deeper. The
140
+ // parent is the directory containing `subagents/`, never a fixed depth.
141
+ assert_eq!(
142
+ id(&workflow(root).join(format!("agent-{AGENT}.jsonl"))).as_deref(),
143
+ Some(expected.as_str())
144
+ );
145
+ }
146
+
147
+ #[test]
148
+ fn a_layout_with_no_subagents_component_yields_none_rather_than_a_guess() {
149
+ // `None` routes to the engine's synthetic dev+inode id and a warning, which
150
+ // is recoverable. A guessed parent would file a subagent's turns under some
151
+ // other session, which is not.
152
+ let id = claude::SUBAGENT_FORMAT.session_id_from_path;
153
+ assert_eq!(id(Path::new("/p/proj/agent-abc.jsonl")), None);
154
+ assert_eq!(id(Path::new("/p/proj/subagents/journal.jsonl")), None);
155
+ }
156
+
157
+ // ── agent type ───────────────────────────────────────────────────────────
158
+
159
+ #[test]
160
+ fn the_agent_type_comes_from_the_sidecar_because_it_is_on_no_transcript_line() {
161
+ // `agentType` appears zero times in the transcripts themselves; the
162
+ // `.meta.json` sidecar is the only place it is written as such.
163
+ let dir = tmpdir("sidecar");
164
+ let subs = flat(&dir);
165
+ fs::create_dir_all(&subs).unwrap();
166
+ let transcript = subs.join(format!("agent-{AGENT}.jsonl"));
167
+ fs::write(&transcript, b"").unwrap();
168
+ fs::write(
169
+ subs.join(format!("agent-{AGENT}.meta.json")),
170
+ br#"{"agentType":"Explore","description":"Map the daemon","spawnDepth":1}"#,
171
+ )
172
+ .unwrap();
173
+
174
+ let derived = (claude::SUBAGENT_FORMAT.agent_id_from_path)(&transcript, &[]);
175
+ assert_eq!(derived.as_deref(), Some("claude-Explore"));
176
+ fs::remove_dir_all(&dir).ok();
177
+ }
178
+
179
+ #[test]
180
+ fn the_sidecar_wins_over_the_in_file_field_because_it_has_no_startup_window() {
181
+ // Ordering is load-bearing: `agent_id` is frozen onto the cursor at
182
+ // discovery and a subagent's `agent_start` resolves on byte 0, while
183
+ // `attributionAgent` is not written until line 3-4. A file discovered one
184
+ // line in would otherwise freeze the fallback onto every event.
185
+ let dir = tmpdir("sidecar-wins");
186
+ let subs = flat(&dir);
187
+ fs::create_dir_all(&subs).unwrap();
188
+ let transcript = subs.join(format!("agent-{AGENT}.jsonl"));
189
+ fs::write(&transcript, b"").unwrap();
190
+ fs::write(
191
+ subs.join(format!("agent-{AGENT}.meta.json")),
192
+ br#"{"agentType":"Explore"}"#,
193
+ )
194
+ .unwrap();
195
+ let header = vec![r#"{"type":"assistant","attributionAgent":"general-purpose"}"#.to_string()];
196
+ assert_eq!(
197
+ (claude::SUBAGENT_FORMAT.agent_id_from_path)(&transcript, &header).as_deref(),
198
+ Some("claude-Explore")
199
+ );
200
+
201
+ // A malformed sidecar degrades to the in-file field rather than failing
202
+ // capture — the two agreed in 122 of 122 measured, so it is a real answer.
203
+ fs::write(subs.join(format!("agent-{AGENT}.meta.json")), b"{not json").unwrap();
204
+ assert_eq!(
205
+ (claude::SUBAGENT_FORMAT.agent_id_from_path)(&transcript, &header).as_deref(),
206
+ Some("claude-general-purpose")
207
+ );
208
+
209
+ // Neither available: `None` hands the engine its configured default, so an
210
+ // unnameable subagent is still captured.
211
+ assert_eq!(
212
+ (claude::SUBAGENT_FORMAT.agent_id_from_path)(&transcript, &[]),
213
+ None
214
+ );
215
+ fs::remove_dir_all(&dir).ok();
216
+ }
217
+
218
+ // ── the parent link ──────────────────────────────────────────────────────
219
+
220
+ #[test]
221
+ fn the_start_event_names_the_parent_without_ever_saying_parent_id() {
222
+ // ⚠️ The dashboard matches `parent_id` against an AGENT id, not a session
223
+ // id — so putting a session UUID there resolves to nothing and silently
224
+ // mis-links every subagent. These two names are the contract.
225
+ let header = vec![sub_prompt("2026-07-18T09:42:04.058Z", "map the daemon")];
226
+ let (ev, ts) = (claude::SUBAGENT_FORMAT.agent_start)(&header, &ctx(), 0).unwrap();
227
+ assert_eq!(ev["type"], "agent_start");
228
+ assert_eq!(ts.as_deref(), Some("2026-07-18T09:42:04.058Z"));
229
+ assert_eq!(ev["claude_parent_session_id"], PARENT);
230
+ assert_eq!(ev["claude_agent_id"], AGENT);
231
+ assert!(
232
+ ev.get("parent_id").is_none(),
233
+ "parent_id would be matched against an agent_id and resolve to nothing"
234
+ );
235
+ // A subagent's first line IS its task prompt, so the goal resolves at once.
236
+ assert_eq!(ev["goal"], "map the daemon");
237
+ assert_eq!(ev["session_id"], format!("{PARENT}:{AGENT}"));
238
+ }
239
+
240
+ #[test]
241
+ fn a_main_session_start_carries_no_parent_link() {
242
+ // The two formats share `transform::agent_start`; only the subagent wrapper
243
+ // may add the link, or a top-level session would claim a parent.
244
+ let header = vec![
245
+ r#"{"type":"user","timestamp":"2026-07-18T09:42:04.058Z","cwd":"/home/u/repo",
246
+ "message":{"role":"user","content":"hi"}}"#
247
+ .to_string(),
248
+ ];
249
+ let main_ctx = Ctx {
250
+ session_id: PARENT.into(),
251
+ agent_id: "claude-repo".into(),
252
+ environment: "local".into(),
253
+ };
254
+ let (ev, _) = (claude::FORMAT.agent_start)(&header, &main_ctx, 0).unwrap();
255
+ assert!(ev.get("claude_parent_session_id").is_none());
256
+ assert!(ev.get("claude_agent_id").is_none());
257
+ }
258
+
259
+ // ── engine ───────────────────────────────────────────────────────────────
260
+
261
+ fn spec(root: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
262
+ Spec {
263
+ format: claude::SUBAGENT_FORMAT,
264
+ roots: vec![root],
265
+ spool_dir: spool,
266
+ state_dir: state,
267
+ poll_interval: Duration::from_millis(200),
268
+ params: Params {
269
+ agent_id: claude::SUBAGENT_DEFAULT_AGENT_ID.into(),
270
+ environment: "local".into(),
271
+ end_idle_mins: 0,
272
+ max_read_bytes: 8 * 1024 * 1024,
273
+ max_batch_bytes: 8 * 1024 * 1024,
274
+ since_days: None,
275
+ },
276
+ }
277
+ }
278
+
279
+ fn spooled(dir: &Path) -> Vec<Value> {
280
+ let mut out = Vec::new();
281
+ let Ok(rd) = fs::read_dir(dir) else {
282
+ return out;
283
+ };
284
+ for e in rd.filter_map(|e| e.ok()) {
285
+ let p = e.path();
286
+ if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
287
+ continue;
288
+ }
289
+ for l in fs::read_to_string(&p).unwrap().lines() {
290
+ if !l.trim().is_empty() {
291
+ out.push(serde_json::from_str(l).unwrap());
292
+ }
293
+ }
294
+ }
295
+ out
296
+ }
297
+
298
+ async fn run_briefly(s: Spec, ms: u64) {
299
+ let sd = Shutdown::for_test(Arc::new(AtomicBool::new(false)));
300
+ let _ = tokio::time::timeout(Duration::from_millis(ms), filetail::run(s, sd)).await;
301
+ }
302
+
303
+ #[tokio::test(flavor = "multi_thread")]
304
+ async fn a_workflow_subagent_lands_as_a_child_session_named_after_its_type() {
305
+ let root = tmpdir("wf-root");
306
+ let spool = tmpdir("wf-spool");
307
+ let state = tmpdir("wf-state");
308
+
309
+ // The deep layout, plus both decoys that share the directory.
310
+ let dir = workflow(&root);
311
+ fs::create_dir_all(&dir).unwrap();
312
+ fs::write(
313
+ dir.join(format!("agent-{AGENT}.jsonl")),
314
+ [
315
+ sub_prompt("2026-07-18T09:42:04.058Z", "map the daemon"),
316
+ sub_tool_use("2026-07-18T09:42:05.000Z", "toolu_01", "Grep"),
317
+ ]
318
+ .join("\n")
319
+ + "\n",
320
+ )
321
+ .unwrap();
322
+ fs::write(
323
+ dir.join(format!("agent-{AGENT}.meta.json")),
324
+ br#"{"agentType":"general-purpose","spawnDepth":1}"#,
325
+ )
326
+ .unwrap();
327
+ fs::write(
328
+ dir.join("journal.jsonl"),
329
+ b"{\"type\":\"started\",\"key\":\"v2:abc\",\"agentId\":\"aother\"}\n",
330
+ )
331
+ .unwrap();
332
+
333
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
334
+
335
+ let ev = spooled(&spool);
336
+ let types: Vec<&str> = ev.iter().filter_map(|e| e["type"].as_str()).collect();
337
+ assert!(types.contains(&"agent_start"), "got {types:?}");
338
+ assert!(types.contains(&"tool_use"), "got {types:?}");
339
+ assert!(types.contains(&"agent_end"), "got {types:?}");
340
+
341
+ let want = format!("{PARENT}:{AGENT}");
342
+ for e in &ev {
343
+ assert_eq!(e["session_id"], want, "one child session, not the parent's");
344
+ assert_eq!(e["agent_id"], "claude-general-purpose");
345
+ }
346
+ // journal.jsonl must contribute nothing at all.
347
+ assert!(
348
+ ev.iter().all(|e| e["session_id"] == want),
349
+ "journal.jsonl was tailed"
350
+ );
351
+
352
+ fs::remove_dir_all(&root).ok();
353
+ fs::remove_dir_all(&spool).ok();
354
+ fs::remove_dir_all(&state).ok();
355
+ }
356
+
357
+ #[tokio::test(flavor = "multi_thread")]
358
+ async fn the_main_format_walking_the_same_root_ignores_subagent_files() {
359
+ // Both sources are pointed at ONE root by the daemon, so this is the live
360
+ // arrangement: if the main format claimed these files, every subagent line
361
+ // would ship a second time under the parent's session id.
362
+ let root = tmpdir("shared-root");
363
+ let spool = tmpdir("shared-spool");
364
+ let state = tmpdir("shared-state");
365
+
366
+ let dir = flat(&root);
367
+ fs::create_dir_all(&dir).unwrap();
368
+ fs::write(
369
+ dir.join(format!("agent-{AGENT}.jsonl")),
370
+ sub_prompt("2026-07-18T09:42:04.058Z", "sub work") + "\n",
371
+ )
372
+ .unwrap();
373
+
374
+ let mut s = spec(root.clone(), spool.clone(), state.clone());
375
+ s.format = claude::FORMAT;
376
+ s.params.agent_id = claude::DEFAULT_AGENT_ID.into();
377
+ run_briefly(s, 1000).await;
378
+
379
+ assert!(
380
+ spooled(&spool).is_empty(),
381
+ "the main format must not claim subagent transcripts"
382
+ );
383
+
384
+ fs::remove_dir_all(&root).ok();
385
+ fs::remove_dir_all(&spool).ok();
386
+ fs::remove_dir_all(&state).ok();
387
+ }
388
+
389
+ #[test]
390
+ fn subagent_transcripts_are_byte_tailable_like_the_main_ones() {
391
+ assert_eq!(claude::SUBAGENT_FORMAT.reread, RereadPolicy::ByteCursor);
392
+ assert_eq!(claude::SUBAGENT_FORMAT.kind, "claude-subagent");
393
+ }