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,619 @@
1
+ //! Claude Code transcript line → AgentEye events.
2
+ //!
3
+ //! Every function here is pure in `(line, ctx, offset, state)`, which is what
4
+ //! makes a live tail and a full re-read byte-identical and therefore
5
+ //! dedup-collapsible. Nothing may consult the clock, a counter, or anything
6
+ //! outside its arguments.
7
+ //!
8
+ //! Record shapes verified against real transcripts on disk (Claude Code 2.1.x):
9
+ //!
10
+ //! ```text
11
+ //! user {type:"user", timestamp, uuid, message:{role, content}}
12
+ //! content is a STRING for a prompt, or an ARRAY containing
13
+ //! {type:"tool_result", tool_use_id, content, is_error}
14
+ //! assistant {type:"assistant", timestamp, message:{model, id, content:[
15
+ //! {type:"text"|"tool_use"|"thinking", ...}], usage}}
16
+ //! a failed turn is flagged by isApiErrorMessage / isAbortedMidStream
17
+ //! / message.model == "<synthetic>"
18
+ //! system {type:"system", subtype, timestamp, ...}
19
+ //! only subtype "compact_boundary" is modelled
20
+ //! ```
21
+ //!
22
+ //! # `thinking` blocks emit nothing, and that is a decision
23
+ //!
24
+ //! Measured over every transcript on this machine: **7,687 of 7,687 thinking
25
+ //! blocks carry `"thinking": ""`**, with the entire payload in an opaque
26
+ //! `signature` attestation. Emitting one event per block would add 7,687
27
+ //! contentless rows against 27,521 assistant lines. The explicit arm in
28
+ //! [`assistant_events`] emits only a block that actually carries text, so a
29
+ //! future Claude that starts populating the field shows up as data arriving
30
+ //! rather than as a silent gap — and costs nothing until then.
31
+
32
+ use serde_json::{Map, Value, json};
33
+
34
+ use crate::cursor::TailState;
35
+ use crate::filetail::Ctx;
36
+
37
+ /// Longest derived id component.
38
+ const MAX_ID_PART: usize = 48;
39
+
40
+ /// The placeholder `message.model` on a turn Claude Code fabricated rather than
41
+ /// served. Never a real model, so it must not reach `model` columns or
42
+ /// [`TailState::last_model`].
43
+ const SYNTHETIC_MODEL: &str = "<synthetic>";
44
+
45
+ /// Make a derived id component safe and bounded.
46
+ pub fn sanitize_id_part(s: &str) -> String {
47
+ let cleaned: String = s
48
+ .chars()
49
+ .map(|c| {
50
+ if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
51
+ c
52
+ } else {
53
+ '-'
54
+ }
55
+ })
56
+ .collect();
57
+ cleaned
58
+ .split('-')
59
+ .filter(|p| !p.is_empty())
60
+ .collect::<Vec<_>>()
61
+ .join("-")
62
+ .chars()
63
+ .take(MAX_ID_PART)
64
+ .collect()
65
+ }
66
+
67
+ /// Normalize a transcript timestamp to the microsecond form ingest expects,
68
+ /// offset by `index` microseconds.
69
+ ///
70
+ /// The offset is what keeps several events derived from ONE line in order:
71
+ /// the server sorts by `(ts, random id)`, so an assistant turn's text and its
72
+ /// tool calls would otherwise come back shuffled. Index 0 is untouched, so the
73
+ /// primary event of a line keeps the line's exact time.
74
+ ///
75
+ /// Saturates within the second rather than carrying, so an offset can never
76
+ /// reorder an event past a genuinely later line.
77
+ pub fn with_index(ts: &str, index: usize) -> Option<String> {
78
+ let body = ts.strip_suffix('Z')?;
79
+ let (main, frac) = match body.rsplit_once('.') {
80
+ Some((m, f)) => (m, f),
81
+ None => (body, ""),
82
+ };
83
+ // Claude writes milliseconds; pad to microseconds so every event has one
84
+ // shape and sorts against the hook source's events correctly.
85
+ let mut micros: u32 = if frac.is_empty() {
86
+ 0
87
+ } else {
88
+ let padded = format!("{frac:0<6}");
89
+ padded.get(..6)?.parse().ok()?
90
+ };
91
+ micros = (micros + index.min(999) as u32).min(999_999);
92
+ Some(format!("{main}.{micros:06}Z"))
93
+ }
94
+
95
+ fn ts_of(v: &Value) -> Option<&str> {
96
+ v.get("timestamp")?.as_str()
97
+ }
98
+
99
+ /// The envelope every emitted event carries.
100
+ ///
101
+ /// `claude_line_offset` is the dedup discriminator: two identical events from
102
+ /// different lines must hash differently, or the server collapses them.
103
+ fn base(ctx: &Ctx, kind: &str, ts: &str, index: usize, offset: u64) -> Option<Map<String, Value>> {
104
+ let mut m = Map::new();
105
+ m.insert("timestamp".into(), json!(with_index(ts, index)?));
106
+ m.insert("session_id".into(), json!(ctx.session_id));
107
+ m.insert("agent_id".into(), json!(ctx.agent_id));
108
+ m.insert("type".into(), json!(kind));
109
+ m.insert("environment".into(), json!(ctx.environment));
110
+ m.insert("claude_line_offset".into(), json!(offset));
111
+ if index > 0 {
112
+ m.insert("claude_block_index".into(), json!(index));
113
+ }
114
+ Some(m)
115
+ }
116
+
117
+ /// Build the session's `agent_start` from its header lines.
118
+ ///
119
+ /// Scans for the first line carrying a usable timestamp rather than trusting
120
+ /// line 1: a transcript can open with metadata records that have none, and a
121
+ /// session with no start event is absent from the product entirely.
122
+ pub fn agent_start(header: &[String], ctx: &Ctx, offset: u64) -> Option<(Value, Option<String>)> {
123
+ let mut cwd: Option<String> = None;
124
+ let mut git_branch: Option<String> = None;
125
+ let mut version: Option<String> = None;
126
+ let mut goal: Option<String> = None;
127
+ let mut first_ts: Option<String> = None;
128
+
129
+ for line in header {
130
+ let Ok(v) = serde_json::from_str::<Value>(line) else {
131
+ continue;
132
+ };
133
+ if first_ts.is_none()
134
+ && let Some(t) = ts_of(&v)
135
+ {
136
+ first_ts = Some(t.to_string());
137
+ }
138
+ if cwd.is_none()
139
+ && let Some(c) = v.get("cwd").and_then(|c| c.as_str())
140
+ {
141
+ cwd = Some(c.to_string());
142
+ }
143
+ if git_branch.is_none()
144
+ && let Some(b) = v.get("gitBranch").and_then(|b| b.as_str())
145
+ {
146
+ git_branch = Some(b.to_string());
147
+ }
148
+ if version.is_none()
149
+ && let Some(x) = v.get("version").and_then(|x| x.as_str())
150
+ {
151
+ version = Some(x.to_string());
152
+ }
153
+ // The session's goal is its first real human prompt.
154
+ if goal.is_none()
155
+ && v.get("type").and_then(|t| t.as_str()) == Some("user")
156
+ && let Some(text) = v
157
+ .get("message")
158
+ .and_then(|m| m.get("content"))
159
+ .and_then(|c| c.as_str())
160
+ && !text.is_empty()
161
+ {
162
+ goal = Some(text.chars().take(1024).collect());
163
+ }
164
+ }
165
+
166
+ let ts = first_ts?;
167
+ let mut m = base(ctx, "agent_start", &ts, 0, offset)?;
168
+ if let Some(g) = goal {
169
+ m.insert("goal".into(), json!(g));
170
+ }
171
+ if let Some(c) = cwd {
172
+ m.insert("claude_cwd".into(), json!(c));
173
+ }
174
+ if let Some(b) = git_branch {
175
+ m.insert("claude_git_branch".into(), json!(b));
176
+ }
177
+ if let Some(v) = version {
178
+ m.insert("claude_version".into(), json!(v));
179
+ }
180
+ Some((Value::Object(m), Some(ts)))
181
+ }
182
+
183
+ /// [`agent_start`] plus the parent link a subagent transcript needs.
184
+ ///
185
+ /// ⚠️ The fields are `claude_parent_session_id` and `claude_agent_id`, and the
186
+ /// names are load-bearing. `parent_id` is the one name that must NOT be used:
187
+ /// the dashboard matches it against an **`agent_id`**, not a session id, so a
188
+ /// session UUID there resolves to nothing and every subagent link is silently
189
+ /// dropped.
190
+ ///
191
+ /// Both are derived from `ctx.session_id`, which the format built from the path
192
+ /// — so this stays pure and agrees with the id the cursor is keyed on. Split
193
+ /// from the right because an agent id never contains `:` while a hypothetical
194
+ /// parent directory might.
195
+ ///
196
+ /// Nested subagents (spawn depth 2, 9 of 122 here) name the ROOT session, not
197
+ /// their spawning sibling: the immediate parent is only in the sidecar, which
198
+ /// this function cannot see, and the root is the useful grouping key anyway.
199
+ pub fn subagent_start(
200
+ header: &[String],
201
+ ctx: &Ctx,
202
+ offset: u64,
203
+ ) -> Option<(Value, Option<String>)> {
204
+ let (mut event, ts) = agent_start(header, ctx, offset)?;
205
+ if let Some(m) = event.as_object_mut()
206
+ && let Some((parent, agent)) = ctx.session_id.rsplit_once(':')
207
+ {
208
+ m.insert("claude_parent_session_id".into(), json!(parent));
209
+ m.insert("claude_agent_id".into(), json!(agent));
210
+ }
211
+ Some((event, ts))
212
+ }
213
+
214
+ /// The single `agent_end`, derived deterministically from the last timestamp
215
+ /// and the file size (used as its offset, so it is unique per session).
216
+ ///
217
+ /// Index 999 puts it after every content event sharing that timestamp — the
218
+ /// server's tie-break is a random id, so without this a session can appear to
219
+ /// end before its last turn.
220
+ pub fn agent_end(ctx: &Ctx, last_ts: &str, size: u64) -> Value {
221
+ match base(ctx, "agent_end", last_ts, 999, size) {
222
+ Some(m) => Value::Object(m),
223
+ // `base` only fails on an unparseable timestamp, which cannot happen
224
+ // here: `last_ts` came from a line this transform already accepted.
225
+ None => json!({
226
+ "timestamp": last_ts,
227
+ "session_id": ctx.session_id,
228
+ "agent_id": ctx.agent_id,
229
+ "type": "agent_end",
230
+ "environment": ctx.environment,
231
+ }),
232
+ }
233
+ }
234
+
235
+ /// One content line to its timestamp and the events it yields.
236
+ pub fn transform_line(
237
+ line: &str,
238
+ ctx: &Ctx,
239
+ offset: u64,
240
+ state: &mut TailState,
241
+ ) -> (Option<String>, Vec<Value>) {
242
+ let Ok(v) = serde_json::from_str::<Value>(line) else {
243
+ return (None, Vec::new());
244
+ };
245
+ // No timestamp means a metadata record (mode, file-history-snapshot,
246
+ // ai-title, …). Skipped for free rather than needing a type allowlist that
247
+ // would have to grow with every Claude release.
248
+ let Some(ts) = ts_of(&v).map(str::to_string) else {
249
+ return (None, Vec::new());
250
+ };
251
+
252
+ let events = match v.get("type").and_then(|t| t.as_str()) {
253
+ Some("user") => user_events(&v, ctx, &ts, offset, state),
254
+ Some("assistant") => assistant_events(&v, ctx, &ts, offset, state),
255
+ Some("system") => system_events(&v, ctx, &ts, offset, state),
256
+ _ => Vec::new(),
257
+ };
258
+ (Some(ts), events)
259
+ }
260
+
261
+ /// A `system` line. Seven subtypes occur on this machine (`turn_duration`,
262
+ /// `stop_hook_summary`, `local_command`, `away_summary`, `compact_boundary`,
263
+ /// `informational`, `bridge_status`); only the one that changes what the model
264
+ /// can see is modelled.
265
+ fn system_events(v: &Value, ctx: &Ctx, ts: &str, offset: u64, state: &TailState) -> Vec<Value> {
266
+ match v.get("subtype").and_then(|s| s.as_str()) {
267
+ Some("compact_boundary") => compact_boundary(v, ctx, ts, offset, state),
268
+ _ => Vec::new(),
269
+ }
270
+ }
271
+
272
+ /// `system`/`compact_boundary` — the only on-disk record that the context was
273
+ /// reset.
274
+ ///
275
+ /// Modelled as a `model_request` because that is what a compaction is: the next
276
+ /// request is built from a summary instead of the transcript. `model` is
277
+ /// stamped from carried state for the same reason a user prompt is — the
278
+ /// server builds this row's summary from the model alone.
279
+ ///
280
+ /// This is orthogonal to the file SHRINKING, which `/compact` also does and
281
+ /// which the engine already handles by re-reading from zero. Nothing here
282
+ /// fights that: the re-read produces this same event at this same offset, so
283
+ /// the server dedups it.
284
+ ///
285
+ /// Only the scalar `compactMetadata` fields are promoted. `preservedMessages`
286
+ /// is a uuid index that grows with the preserved window and answers no question
287
+ /// an operator would ask, so shipping it would be volume without signal.
288
+ fn compact_boundary(v: &Value, ctx: &Ctx, ts: &str, offset: u64, state: &TailState) -> Vec<Value> {
289
+ let Some(mut m) = base(ctx, "model_request", ts, 0, offset) else {
290
+ return Vec::new();
291
+ };
292
+ m.insert("claude_kind".into(), json!("compact_boundary"));
293
+ if let Some(model) = &state.last_model {
294
+ m.insert("model".into(), json!(model));
295
+ }
296
+ if let Some(meta) = v.get("compactMetadata") {
297
+ for (src, dst) in [
298
+ ("trigger", "claude_compact_trigger"),
299
+ ("preTokens", "claude_compact_pre_tokens"),
300
+ ("postTokens", "claude_compact_post_tokens"),
301
+ ("cumulativeDroppedTokens", "claude_compact_dropped_tokens"),
302
+ ] {
303
+ if let Some(x) = meta.get(src).filter(|x| !x.is_null()) {
304
+ m.insert(dst.into(), x.clone());
305
+ }
306
+ }
307
+ if let Some(ms) = meta.get("durationMs").and_then(|x| x.as_u64()) {
308
+ m.insert("duration_ms".into(), json!(ms));
309
+ }
310
+ }
311
+ if let Some(c) = v.get("cwd").and_then(|c| c.as_str()) {
312
+ m.insert("claude_cwd".into(), json!(c));
313
+ }
314
+ vec![Value::Object(m)]
315
+ }
316
+
317
+ /// A `user` line is either a human prompt or the results of tool calls.
318
+ fn user_events(v: &Value, ctx: &Ctx, ts: &str, offset: u64, state: &mut TailState) -> Vec<Value> {
319
+ let Some(content) = v.get("message").and_then(|m| m.get("content")) else {
320
+ return Vec::new();
321
+ };
322
+
323
+ // A string is a real prompt.
324
+ if let Some(text) = content.as_str() {
325
+ if text.is_empty() {
326
+ return Vec::new();
327
+ }
328
+ let Some(mut m) = base(ctx, "model_request", ts, 0, offset) else {
329
+ return Vec::new();
330
+ };
331
+ // Inherited from the last assistant turn: a user line names no model,
332
+ // and the server builds this row's summary from the model alone.
333
+ if let Some(model) = &state.last_model {
334
+ m.insert("model".into(), json!(model));
335
+ }
336
+ m.insert(
337
+ "messages".into(),
338
+ json!([{ "role": "user", "content": text }]),
339
+ );
340
+ if let Some(c) = v.get("cwd").and_then(|c| c.as_str()) {
341
+ m.insert("claude_cwd".into(), json!(c));
342
+ }
343
+ return vec![Value::Object(m)];
344
+ }
345
+
346
+ // An array carries tool results.
347
+ let Some(blocks) = content.as_array() else {
348
+ return Vec::new();
349
+ };
350
+ let mut out = Vec::new();
351
+ for (i, block) in blocks.iter().enumerate() {
352
+ if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
353
+ continue;
354
+ }
355
+ let call_id = block.get("tool_use_id").and_then(|x| x.as_str());
356
+ let Some(mut m) = base(ctx, "tool_result", ts, i, offset) else {
357
+ continue;
358
+ };
359
+ if let Some(id) = call_id {
360
+ m.insert("tool_call_id".into(), json!(id));
361
+ // The tool's name appears on NO result line. Without carrying it
362
+ // from the call, every result is a blank row in the product.
363
+ if let Some(name) = state.tool_name(id) {
364
+ m.insert("tool_name".into(), json!(name));
365
+ }
366
+ }
367
+ if let Some(output) = block.get("content") {
368
+ m.insert("output".into(), json!(stringify(output)));
369
+ }
370
+ if block.get("is_error").and_then(|e| e.as_bool()) == Some(true) {
371
+ m.insert(
372
+ "error".into(),
373
+ json!(stringify(block.get("content").unwrap_or(&Value::Null))),
374
+ );
375
+ m.insert("error_type".into(), json!("claude_tool_error"));
376
+ }
377
+ out.push(Value::Object(m));
378
+ }
379
+ out
380
+ }
381
+
382
+ /// An `assistant` line is text and/or tool calls, plus token usage.
383
+ fn assistant_events(
384
+ v: &Value,
385
+ ctx: &Ctx,
386
+ ts: &str,
387
+ offset: u64,
388
+ state: &mut TailState,
389
+ ) -> Vec<Value> {
390
+ let Some(message) = v.get("message") else {
391
+ return Vec::new();
392
+ };
393
+ let model = message.get("model").and_then(|m| m.as_str());
394
+ if let Some(m) = model.filter(|m| *m != SYNTHETIC_MODEL) {
395
+ // `<synthetic>` is excluded deliberately: it is the placeholder on a
396
+ // fabricated turn, and letting it in would stamp "<synthetic>" as the
397
+ // model on every later user prompt that inherits from here.
398
+ state.last_model = Some(m.to_string());
399
+ }
400
+ let message_id = message.get("id").and_then(|i| i.as_str());
401
+
402
+ // A turn Claude Code recorded as a failure replaces the whole line: one
403
+ // `error`, no content rows, and no usage.
404
+ if let Some(ev) = error_turn(v, message, ctx, ts, offset) {
405
+ return vec![ev];
406
+ }
407
+
408
+ // One API response is written across several lines that each repeat the
409
+ // SAME usage object. Attributing it per line multiplies token totals, so it
410
+ // is counted once per message id. The claim is staked at the bottom of this
411
+ // function, by the line that actually emits — see there.
412
+ let usage_is_new = match (message_id, state.last_usage_message_id.as_deref()) {
413
+ (Some(id), Some(seen)) => id != seen,
414
+ (Some(_), None) => true,
415
+ _ => false,
416
+ };
417
+
418
+ let Some(blocks) = message.get("content").and_then(|c| c.as_array()) else {
419
+ return Vec::new();
420
+ };
421
+
422
+ let mut out: Vec<Value> = Vec::new();
423
+ for (i, block) in blocks.iter().enumerate() {
424
+ match block.get("type").and_then(|t| t.as_str()) {
425
+ Some("text") => {
426
+ let Some(text) = block.get("text").and_then(|t| t.as_str()) else {
427
+ continue;
428
+ };
429
+ if text.is_empty() {
430
+ continue;
431
+ }
432
+ let Some(mut m) = base(ctx, "model_response", ts, i, offset) else {
433
+ continue;
434
+ };
435
+ m.insert("role".into(), json!("assistant"));
436
+ m.insert("content".into(), json!(text));
437
+ if let Some(model) = model {
438
+ m.insert("model".into(), json!(model));
439
+ }
440
+ if let Some(id) = message_id {
441
+ m.insert("claude_message_id".into(), json!(id));
442
+ }
443
+ if let Some(sr) = message.get("stop_reason").and_then(|s| s.as_str()) {
444
+ m.insert("claude_stop_reason".into(), json!(sr));
445
+ }
446
+ out.push(Value::Object(m));
447
+ }
448
+ Some("tool_use") => {
449
+ let Some(mut m) = base(ctx, "tool_use", ts, i, offset) else {
450
+ continue;
451
+ };
452
+ let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("tool");
453
+ // Fall back to an offset-derived id so parallel identical calls
454
+ // cannot hash-collapse into one row.
455
+ let id = block
456
+ .get("id")
457
+ .and_then(|x| x.as_str())
458
+ .map(str::to_string)
459
+ .unwrap_or_else(|| format!("claude-{offset}-{i}"));
460
+ state.remember_tool(id.clone(), name.to_string());
461
+ m.insert("tool_name".into(), json!(name));
462
+ m.insert("tool_call_id".into(), json!(id));
463
+ if let Some(input) = block.get("input") {
464
+ m.insert("input".into(), input.clone());
465
+ }
466
+ if let Some(model) = model {
467
+ m.insert("model".into(), json!(model));
468
+ }
469
+ out.push(Value::Object(m));
470
+ }
471
+ // Emitted only when it carries text — see the module docs. Kept as
472
+ // an explicit arm so the 7,687-of-7,687-empty measurement has a
473
+ // place to live and a populated block cannot slip past unnoticed.
474
+ Some("thinking") => {
475
+ let text = block
476
+ .get("thinking")
477
+ .and_then(|t| t.as_str())
478
+ .unwrap_or_default();
479
+ if text.trim().is_empty() {
480
+ continue;
481
+ }
482
+ let Some(mut m) = base(ctx, "model_response", ts, i, offset) else {
483
+ continue;
484
+ };
485
+ m.insert("role".into(), json!("assistant"));
486
+ m.insert("content".into(), json!(text));
487
+ m.insert("claude_kind".into(), json!("thinking"));
488
+ if let Some(model) = model {
489
+ m.insert("model".into(), json!(model));
490
+ }
491
+ if let Some(id) = message_id {
492
+ m.insert("claude_message_id".into(), json!(id));
493
+ }
494
+ out.push(Value::Object(m));
495
+ }
496
+ _ => {}
497
+ }
498
+ }
499
+
500
+ // Attach usage to the FIRST event of the line, so a line yielding several
501
+ // events reports its tokens once.
502
+ //
503
+ // The group is claimed HERE, by a line that actually emitted — not up top
504
+ // on sight of the id. Claiming it earlier loses the whole group whenever
505
+ // its first line emits nothing, which is the common case, not an edge one:
506
+ // Claude writes the thinking block as its own line at the head of a group,
507
+ // and 7,699 of 11,213 groups on this machine begin with a line that yields
508
+ // no event. Under the earlier gate those groups reported zero tokens —
509
+ // 8.4M of 10.3M output tokens corpus-wide, silently.
510
+ //
511
+ // Deferring is also strictly more accurate: usage accumulates across a
512
+ // group's lines, and the later line this now bills carries the larger
513
+ // figure in 7,703 of the 8,599 multi-line groups (identical in the rest).
514
+ // Still pure — `last_usage_message_id` lives in the cursor, so a resumed
515
+ // read holds the same value at the same offset as a full re-read.
516
+ if usage_is_new
517
+ && let Some(usage) = message.get("usage")
518
+ && let Some(first) = out.first_mut()
519
+ && let Some(obj) = first.as_object_mut()
520
+ {
521
+ if let Some(n) = usage.get("input_tokens").and_then(|x| x.as_u64()) {
522
+ obj.insert("input_tokens".into(), json!(n));
523
+ }
524
+ if let Some(n) = usage.get("output_tokens").and_then(|x| x.as_u64()) {
525
+ obj.insert("output_tokens".into(), json!(n));
526
+ }
527
+ obj.insert("claude_usage".into(), usage.clone());
528
+ if let Some(id) = message_id {
529
+ state.last_usage_message_id = Some(id.to_string());
530
+ }
531
+ }
532
+
533
+ out
534
+ }
535
+
536
+ /// A turn Claude Code recorded as a failure, or `None` if this is a normal one.
537
+ ///
538
+ /// Three independent markers mean the same thing and any one alone is enough:
539
+ /// `isApiErrorMessage`, `isAbortedMidStream`, and the fabricated
540
+ /// `message.model == "<synthetic>"`. On this machine: 2 aborted, 5 synthetic,
541
+ /// 0 api-error (the field is present 5 times, always `false`) — so the
542
+ /// api-error arm is written from the marker's meaning rather than from a live
543
+ /// sample, and is the one to re-check first if this ever misfires.
544
+ ///
545
+ /// There is deliberately no "only if it has content" guard: a failed turn is
546
+ /// precisely the one with nothing to check.
547
+ ///
548
+ /// The line yields this event and NOTHING else — no `model_response` for its
549
+ /// partial text, no usage. Usage in particular: a synthetic turn's usage object
550
+ /// is all zeros and it is interleaved *inside* a real `message.id` group, so
551
+ /// letting it claim the group would zero out the real response's tokens.
552
+ /// Suppressing the content rows is safe here because none of the 7 flagged
553
+ /// lines carries a `tool_use` block — all 7 are text-only — so no tool call can
554
+ /// be lost this way.
555
+ fn error_turn(v: &Value, message: &Value, ctx: &Ctx, ts: &str, offset: u64) -> Option<Value> {
556
+ let api_error = v.get("isApiErrorMessage").and_then(|x| x.as_bool()) == Some(true);
557
+ let aborted = v.get("isAbortedMidStream").and_then(|x| x.as_bool()) == Some(true);
558
+ let synthetic = message.get("model").and_then(|m| m.as_str()) == Some(SYNTHETIC_MODEL);
559
+ if !(api_error || aborted || synthetic) {
560
+ return None;
561
+ }
562
+
563
+ let mut m = base(ctx, "error", ts, 0, offset)?;
564
+ // Claude Code's own label (`"server_error"`) when it wrote one — that is
565
+ // the string an operator can group on — else whichever marker fired.
566
+ let error_type = v
567
+ .get("error")
568
+ .and_then(|e| e.as_str())
569
+ .filter(|s| !s.is_empty())
570
+ .unwrap_or(if aborted {
571
+ "claude_aborted"
572
+ } else if api_error {
573
+ "claude_api_error"
574
+ } else {
575
+ "claude_synthetic"
576
+ });
577
+ m.insert("error_type".into(), json!(error_type));
578
+
579
+ // The server's `is_error` is a truthiness check on the message, so this
580
+ // must never be empty — a failed turn rendering as a success is worse than
581
+ // a generic sentence.
582
+ let text = message.get("content").map(stringify).unwrap_or_default();
583
+ let detail = if text.trim().is_empty() {
584
+ if aborted {
585
+ "assistant turn aborted mid-stream"
586
+ } else if api_error {
587
+ "assistant turn failed with an API error"
588
+ } else {
589
+ "synthetic assistant turn (no model response)"
590
+ }
591
+ } else {
592
+ text.trim()
593
+ };
594
+ m.insert("message".into(), json!(detail));
595
+ if let Some(model) = message.get("model").and_then(|x| x.as_str()) {
596
+ m.insert("claude_model".into(), json!(model));
597
+ }
598
+ if let Some(id) = message.get("id").and_then(|x| x.as_str()) {
599
+ m.insert("claude_message_id".into(), json!(id));
600
+ }
601
+ if let Some(sr) = message.get("stop_reason").and_then(|s| s.as_str()) {
602
+ m.insert("claude_stop_reason".into(), json!(sr));
603
+ }
604
+ Some(Value::Object(m))
605
+ }
606
+
607
+ /// Render a tool result payload as text. Claude uses a bare string, or an array
608
+ /// of typed blocks.
609
+ fn stringify(v: &Value) -> String {
610
+ match v {
611
+ Value::String(s) => s.clone(),
612
+ Value::Array(items) => items
613
+ .iter()
614
+ .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
615
+ .collect::<Vec<_>>()
616
+ .join("\n"),
617
+ other => other.to_string(),
618
+ }
619
+ }