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,493 @@
1
+ //! opencode `session` and `part` rows → AgentEye events.
2
+ //!
3
+ //! Every function here is pure in its arguments. That is load-bearing rather
4
+ //! than stylistic: this source re-reads mutated rows *by design* (see the
5
+ //! module docs on [`super`]), and the server collapses a re-read only when the
6
+ //! bytes are identical. Nothing here may consult the clock, a counter, or
7
+ //! anything outside its arguments.
8
+ //!
9
+ //! Row shapes verified against a live `~/.local/share/opencode/opencode.db`
10
+ //! (opencode 1.18.11):
11
+ //!
12
+ //! ```text
13
+ //! session columns id, directory (absolute cwd), path (NOT usable — see
14
+ //! `super`), slug, version, parent_id, time_created,
15
+ //! time_updated, tokens_input/output/reasoning/cache_*
16
+ //! message data {role:"user", model:{providerID, modelID}, time:{created}}
17
+ //! {role:"assistant", modelID, providerID, tokens:{…},
18
+ //! time:{created, completed}, finish:"stop"|"tool-calls"|…}
19
+ //! part data {type:"text", text, time?:{start, end}}
20
+ //! {type:"tool", tool, callID,
21
+ //! state:{status, input, output, metadata, time:{start,end}}}
22
+ //! {type:"step-start"|"step-finish", …} (emit nothing)
23
+ //! ```
24
+
25
+ use serde_json::{Map, Value, json};
26
+
27
+ /// Longest derived id component.
28
+ const MAX_ID_PART: usize = 48;
29
+ /// Longest goal string carried on `agent_start`.
30
+ const MAX_GOAL: usize = 1024;
31
+ /// Index offset for `agent_end`.
32
+ ///
33
+ /// The server sorts by `(timestamp, random id)`, so an `agent_end` sharing a
34
+ /// millisecond with the turn that produced it can come back *before* it. 999
35
+ /// puts it after every content event in that millisecond — the same trick and
36
+ /// the same constant the Claude source uses.
37
+ const END_INDEX: u32 = 999;
38
+
39
+ /// Make a derived id component safe and bounded.
40
+ ///
41
+ /// Byte-for-byte the Claude source's rule, deliberately: the two run on the
42
+ /// same machine over the same project directories, and a project that
43
+ /// sanitized differently under each would show up as two unrelated agents.
44
+ pub fn sanitize_id_part(s: &str) -> String {
45
+ let cleaned: String = s
46
+ .chars()
47
+ .map(|c| {
48
+ if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
49
+ c
50
+ } else {
51
+ '-'
52
+ }
53
+ })
54
+ .collect();
55
+ cleaned
56
+ .split('-')
57
+ .filter(|p| !p.is_empty())
58
+ .collect::<Vec<_>>()
59
+ .join("-")
60
+ .chars()
61
+ .take(MAX_ID_PART)
62
+ .collect()
63
+ }
64
+
65
+ /// `opencode-<project>`, from `session.directory`.
66
+ ///
67
+ /// `directory` is the absolute working directory and is the ONLY reliable
68
+ /// source of the project — see the module docs on [`super`] for why
69
+ /// `session.path` and `session.project_id` are not.
70
+ ///
71
+ /// Returns `None` for a directory with no usable final component (`/`, or an
72
+ /// empty string), so the caller can fall back to the configured agent id
73
+ /// rather than emitting `opencode-`.
74
+ pub fn agent_id_from_directory(directory: &str) -> Option<String> {
75
+ let project = sanitize_id_part(
76
+ directory
77
+ .trim_end_matches('/')
78
+ .rsplit('/')
79
+ .find(|p| !p.is_empty())?,
80
+ );
81
+ (!project.is_empty()).then(|| format!("opencode-{project}"))
82
+ }
83
+
84
+ /// Format epoch milliseconds as the RFC3339-with-microseconds string ingest
85
+ /// expects, offset by `index` microseconds.
86
+ ///
87
+ /// opencode stores `time_created`/`time_updated` and every JSON `time` field
88
+ /// as epoch MILLISECONDS (verified: 1785744553213). Microseconds are forced
89
+ /// rather than left to RFC3339's variable precision so every source emits one
90
+ /// shape and the streams sort against each other on a shared timeline.
91
+ pub fn to_rfc3339_micros(epoch_ms: i64, index: u32) -> Option<String> {
92
+ let nanos = (epoch_ms as i128) * 1_000_000 + (index as i128) * 1_000;
93
+ let dt = time::OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()?;
94
+ const FMT: &[time::format_description::BorrowedFormatItem<'_>] = time::macros::format_description!(
95
+ "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6]Z"
96
+ );
97
+ dt.format(FMT).ok()
98
+ }
99
+
100
+ /// What every emitted event is filed under.
101
+ #[derive(Debug, Clone)]
102
+ pub struct Ctx {
103
+ pub session_id: String,
104
+ pub agent_id: String,
105
+ pub environment: String,
106
+ }
107
+
108
+ impl Ctx {
109
+ fn new(session_id: &str, directory: &str, environment: &str, fallback_agent_id: &str) -> Self {
110
+ Self {
111
+ session_id: session_id.to_string(),
112
+ agent_id: agent_id_from_directory(directory)
113
+ .unwrap_or_else(|| fallback_agent_id.to_string()),
114
+ environment: environment.to_string(),
115
+ }
116
+ }
117
+ }
118
+
119
+ /// One `session` row, plus the two facts about it that need a subquery.
120
+ #[derive(Debug, Clone, Default)]
121
+ pub struct SessionRow {
122
+ pub id: String,
123
+ /// Absolute cwd. The agent id derives from this and nothing else.
124
+ pub directory: String,
125
+ pub version: Option<String>,
126
+ pub slug: Option<String>,
127
+ pub parent_id: Option<String>,
128
+ pub time_created: i64,
129
+ pub time_updated: i64,
130
+ pub tokens_input: i64,
131
+ pub tokens_output: i64,
132
+ pub tokens_reasoning: i64,
133
+ pub tokens_cache_read: i64,
134
+ pub tokens_cache_write: i64,
135
+ /// `part.data` of the session's earliest part. Its presence is the "this
136
+ /// session produced something" test, and its text is the session goal.
137
+ pub first_part: Option<Value>,
138
+ /// `message.data` of the session's latest message. Decides whether the
139
+ /// session is between turns or in the middle of one.
140
+ pub last_message: Option<Value>,
141
+ }
142
+
143
+ /// One `part` row, joined to its message (for the role and model) and its
144
+ /// session (for the directory the agent id comes from).
145
+ #[derive(Debug, Clone, Default)]
146
+ pub struct PartRow {
147
+ pub id: String,
148
+ pub session_id: String,
149
+ pub message_id: String,
150
+ pub directory: String,
151
+ pub time_created: i64,
152
+ pub time_updated: i64,
153
+ pub data: Value,
154
+ pub message: Value,
155
+ }
156
+
157
+ /// The envelope every emitted event carries.
158
+ ///
159
+ /// `opencode_part_id` is the dedup discriminator: two events that are
160
+ /// otherwise identical — the same tool invoked twice with the same input, say
161
+ /// — must hash differently or the server collapses them into one. opencode
162
+ /// prefixes ids per table (`prt_…`, `ses_…`), so this one field can carry the
163
+ /// originating row of a part-derived event and a session-derived event alike
164
+ /// with no chance of collision.
165
+ fn base(ctx: &Ctx, kind: &str, ts_ms: i64, index: u32, row_id: &str) -> Option<Map<String, Value>> {
166
+ let mut m = Map::new();
167
+ m.insert("timestamp".into(), json!(to_rfc3339_micros(ts_ms, index)?));
168
+ m.insert("session_id".into(), json!(ctx.session_id));
169
+ m.insert("agent_id".into(), json!(ctx.agent_id));
170
+ m.insert("type".into(), json!(kind));
171
+ m.insert("environment".into(), json!(ctx.environment));
172
+ m.insert("opencode_part_id".into(), json!(row_id));
173
+ if index > 0 {
174
+ m.insert("opencode_block_index".into(), json!(index));
175
+ }
176
+ Some(m)
177
+ }
178
+
179
+ /// A `session` row's `agent_start` and — when the session is between turns —
180
+ /// its `agent_end`.
181
+ ///
182
+ /// # Why a session row can produce these many times over
183
+ ///
184
+ /// The poller re-reads a row whenever its `time_updated` moves, and a session
185
+ /// row's moves on every turn. `agent_start` is therefore built ONLY from
186
+ /// columns opencode never rewrites (`id`, `directory`, `version`, `slug`,
187
+ /// `parent_id`, `time_created`) plus the first prompt, which is fixed once
188
+ /// written — so every re-read produces the same bytes and the server collapses
189
+ /// them into the single event it should have been. `session.title` is
190
+ /// pointedly absent: opencode backfills it with an LLM-generated summary a few
191
+ /// seconds into the session, which would make the second read differ from the
192
+ /// first and leave two `agent_start`s on the timeline.
193
+ ///
194
+ /// # `agent_end` without an end marker
195
+ ///
196
+ /// opencode never records that a session finished — there is no end row, no
197
+ /// terminal status, and `time_archived` stays null. An idle timeout is not
198
+ /// available either: the poll must be a pure function of the database and the
199
+ /// request, so it cannot ask what time it is now.
200
+ ///
201
+ /// So the end is inferred from the conversation instead: a session whose
202
+ /// latest message is an assistant turn that has *finished* and is not waiting
203
+ /// on tool calls is, right now, over. Every later turn moves the session row
204
+ /// again and emits another `agent_end` at the new `time_updated`; the newest
205
+ /// one is always the session's true last activity.
206
+ pub fn session_events(row: &SessionRow, environment: &str, fallback_agent_id: &str) -> Vec<Value> {
207
+ // A session row with no parts is one the user opened and never used —
208
+ // opencode creates it the moment a session is selected. Emitting an
209
+ // `agent_start` for it would put an empty session in the product, and
210
+ // would also risk a second `agent_start` later once the goal exists.
211
+ if row.first_part.is_none() {
212
+ return Vec::new();
213
+ }
214
+ let ctx = Ctx::new(&row.id, &row.directory, environment, fallback_agent_id);
215
+ let mut out = Vec::new();
216
+
217
+ if let Some(mut m) = base(&ctx, "agent_start", row.time_created, 0, &row.id) {
218
+ if let Some(goal) = first_prompt(row) {
219
+ m.insert("goal".into(), json!(goal));
220
+ }
221
+ m.insert("opencode_cwd".into(), json!(row.directory));
222
+ if let Some(v) = &row.version {
223
+ m.insert("opencode_version".into(), json!(v));
224
+ }
225
+ if let Some(s) = &row.slug {
226
+ m.insert("opencode_slug".into(), json!(s));
227
+ }
228
+ if let Some(p) = &row.parent_id {
229
+ m.insert("opencode_parent_session_id".into(), json!(p));
230
+ }
231
+ out.push(Value::Object(m));
232
+ }
233
+
234
+ if between_turns(row)
235
+ && let Some(mut m) = base(&ctx, "agent_end", row.time_updated, END_INDEX, &row.id)
236
+ {
237
+ // Namespaced, and deliberately NOT `input_tokens`/`output_tokens`:
238
+ // these are the session's running TOTALS, not this event's usage, and
239
+ // a consumer that sums the generic fields across events would count
240
+ // every turn again for every `agent_end` the session emits.
241
+ m.insert(
242
+ "opencode_tokens".into(),
243
+ json!({
244
+ "input": row.tokens_input,
245
+ "output": row.tokens_output,
246
+ "reasoning": row.tokens_reasoning,
247
+ "cache_read": row.tokens_cache_read,
248
+ "cache_write": row.tokens_cache_write,
249
+ }),
250
+ );
251
+ out.push(Value::Object(m));
252
+ }
253
+ out
254
+ }
255
+
256
+ /// The session goal: the text of its first part, which is the user's opening
257
+ /// prompt (opencode writes the prompt's text part before anything else).
258
+ fn first_prompt(row: &SessionRow) -> Option<String> {
259
+ let data = row.first_part.as_ref()?;
260
+ if data.get("type").and_then(|t| t.as_str()) != Some("text") {
261
+ return None;
262
+ }
263
+ let text = data.get("text").and_then(|t| t.as_str())?;
264
+ (!text.is_empty()).then(|| text.chars().take(MAX_GOAL).collect())
265
+ }
266
+
267
+ /// True when the session's latest message is a completed assistant turn.
268
+ ///
269
+ /// `finish:"tool-calls"` means the assistant stopped only to run tools and
270
+ /// another assistant message is coming, so it is explicitly NOT an end. A user
271
+ /// message as the latest one means the turn has not finished either.
272
+ fn between_turns(row: &SessionRow) -> bool {
273
+ let Some(message) = row.last_message.as_ref() else {
274
+ return false;
275
+ };
276
+ if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
277
+ return false;
278
+ }
279
+ matches!(
280
+ message.get("finish").and_then(|f| f.as_str()),
281
+ Some(finish) if finish != "tool-calls"
282
+ )
283
+ }
284
+
285
+ /// One `part` row to the events it yields.
286
+ ///
287
+ /// Returns an empty vector for a part opencode is still writing — see
288
+ /// `is_settled` and `tool_events` below. That is not a dropped event: the row's
289
+ /// `time_updated` moves again when the write completes, which brings it back
290
+ /// past the watermark and through here a second time.
291
+ pub fn part_events(row: &PartRow, environment: &str, fallback_agent_id: &str) -> Vec<Value> {
292
+ let ctx = Ctx::new(
293
+ &row.session_id,
294
+ &row.directory,
295
+ environment,
296
+ fallback_agent_id,
297
+ );
298
+ match row.data.get("type").and_then(|t| t.as_str()) {
299
+ Some("text") => text_events(row, &ctx),
300
+ Some("tool") => tool_events(row, &ctx),
301
+ // `step-start` / `step-finish` are turn structure, and `snapshot` /
302
+ // `patch` / `file` / `reasoning` need their own event types rather
303
+ // than a lossy squeeze into these. Ignoring an unknown type is also
304
+ // what keeps a new opencode release from breaking this source.
305
+ _ => Vec::new(),
306
+ }
307
+ }
308
+
309
+ /// A `text` part is a user prompt or an assistant answer, decided by the role
310
+ /// on its message — the part itself carries no role.
311
+ fn text_events(row: &PartRow, ctx: &Ctx) -> Vec<Value> {
312
+ // An assistant text part is written empty and grown token by token as the
313
+ // model streams. Shipping it before it settles would put one row in the
314
+ // product per poll that happened to land mid-stream, each a longer prefix
315
+ // of the same answer.
316
+ if !is_settled(&row.data) {
317
+ return Vec::new();
318
+ }
319
+ let Some(text) = row.data.get("text").and_then(|t| t.as_str()) else {
320
+ return Vec::new();
321
+ };
322
+ if text.is_empty() {
323
+ return Vec::new();
324
+ }
325
+ let role = row
326
+ .message
327
+ .get("role")
328
+ .and_then(|r| r.as_str())
329
+ .unwrap_or("assistant");
330
+
331
+ let kind = if role == "user" {
332
+ "model_request"
333
+ } else {
334
+ "model_response"
335
+ };
336
+ // `part.time_created` rather than `data.time.start`: it is the one
337
+ // timestamp on the row opencode never rewrites, so a re-read of a row that
338
+ // changed for some other reason still produces the same bytes.
339
+ let Some(mut m) = base(ctx, kind, row.time_created, 0, &row.id) else {
340
+ return Vec::new();
341
+ };
342
+ if let Some(model) = model_id(&row.message) {
343
+ m.insert("model".into(), json!(model));
344
+ }
345
+ if role == "user" {
346
+ m.insert(
347
+ "messages".into(),
348
+ json!([{ "role": "user", "content": text }]),
349
+ );
350
+ m.insert("opencode_cwd".into(), json!(row.directory));
351
+ } else {
352
+ m.insert("role".into(), json!("assistant"));
353
+ m.insert("content".into(), json!(text));
354
+ if let Some(p) = provider_id(&row.message) {
355
+ m.insert("opencode_provider".into(), json!(p));
356
+ }
357
+ }
358
+ m.insert("opencode_message_id".into(), json!(row.message_id));
359
+ vec![Value::Object(m)]
360
+ }
361
+
362
+ /// A `tool` part is BOTH the call and its result — opencode never writes a
363
+ /// second row for the result, it fills this one in.
364
+ ///
365
+ /// So a completed part yields two events. They are offset by index so they
366
+ /// sort in order even when a tool finishes inside the millisecond it started:
367
+ /// the server's tie-break is a random id, and a result that sorted before its
368
+ /// own call is visibly wrong on a timeline.
369
+ ///
370
+ /// A part that is still running yields the call alone. The result is not
371
+ /// merely absent from our output, it does not exist yet — `state.output` is
372
+ /// empty and `state.time.end` is unset until opencode writes them, ~12 seconds
373
+ /// later on the observed capture.
374
+ fn tool_events(row: &PartRow, ctx: &Ctx) -> Vec<Value> {
375
+ let state = row.data.get("state");
376
+ let status = state
377
+ .and_then(|s| s.get("status"))
378
+ .and_then(|s| s.as_str())
379
+ .unwrap_or("");
380
+ // `pending` is the call before its arguments have finished streaming:
381
+ // there is no input to report yet. Every other status — including one this
382
+ // code has never heard of — reports the call, so a new opencode state name
383
+ // degrades to an extra event rather than a silently missing one.
384
+ if status == "pending" {
385
+ return Vec::new();
386
+ }
387
+
388
+ let name = row
389
+ .data
390
+ .get("tool")
391
+ .and_then(|t| t.as_str())
392
+ .unwrap_or("tool");
393
+ // Falls back to the part id so two identical parallel calls cannot
394
+ // hash-collapse into one row.
395
+ let call_id = row
396
+ .data
397
+ .get("callID")
398
+ .and_then(|c| c.as_str())
399
+ .unwrap_or(&row.id);
400
+
401
+ let mut out = Vec::new();
402
+ if let Some(mut m) = base(ctx, "tool_use", row.time_created, 0, &row.id) {
403
+ m.insert("tool_name".into(), json!(name));
404
+ m.insert("tool_call_id".into(), json!(call_id));
405
+ if let Some(input) = state.and_then(|s| s.get("input")) {
406
+ m.insert("input".into(), input.clone());
407
+ }
408
+ if let Some(model) = model_id(&row.message) {
409
+ m.insert("model".into(), json!(model));
410
+ }
411
+ // Deliberately carries no status: a running part and the completed
412
+ // part it becomes then produce a byte-identical `tool_use`, which the
413
+ // server collapses. Putting the status here would leave one visible
414
+ // row per state the call passed through.
415
+ out.push(Value::Object(m));
416
+ }
417
+
418
+ if !matches!(status, "completed" | "error") {
419
+ return out;
420
+ }
421
+ let end = state
422
+ .and_then(|s| s.get("time"))
423
+ .and_then(|t| t.get("end"))
424
+ .and_then(serde_json::Value::as_i64)
425
+ // Only reachable on a shape without timing; the row's own last-write
426
+ // time is the closest true statement about when the result existed.
427
+ .unwrap_or(row.time_updated);
428
+ if let Some(mut m) = base(ctx, "tool_result", end, 1, &row.id) {
429
+ m.insert("tool_name".into(), json!(name));
430
+ m.insert("tool_call_id".into(), json!(call_id));
431
+ m.insert("opencode_tool_status".into(), json!(status));
432
+ let output = state.and_then(|s| s.get("output")).map(stringify);
433
+ let error = state.and_then(|s| s.get("error")).map(stringify);
434
+ if let Some(o) = output.clone().filter(|o| !o.is_empty()) {
435
+ m.insert("output".into(), json!(o));
436
+ } else if let Some(e) = error.clone().filter(|e| !e.is_empty()) {
437
+ // An errored tool has no output; the message is the whole result.
438
+ m.insert("output".into(), json!(e));
439
+ }
440
+ if status == "error" {
441
+ let detail = error.or(output).unwrap_or_default();
442
+ m.insert("error".into(), json!(detail));
443
+ m.insert("error_type".into(), json!("opencode_tool_error"));
444
+ }
445
+ out.push(Value::Object(m));
446
+ }
447
+ out
448
+ }
449
+
450
+ /// True when opencode has finished writing this part's content.
451
+ ///
452
+ /// A part that carries a `time` object is settled once that object has an
453
+ /// `end`; one with no `time` at all (a user prompt) was written in a single
454
+ /// shot and is settled on arrival.
455
+ fn is_settled(data: &Value) -> bool {
456
+ match data.get("time") {
457
+ Some(t) => t.get("end").is_some_and(|e| !e.is_null()),
458
+ None => true,
459
+ }
460
+ }
461
+
462
+ /// The model id, from either message shape: an assistant message carries a
463
+ /// flat `modelID`, a user message a nested `model:{providerID, modelID}`.
464
+ fn model_id(message: &Value) -> Option<&str> {
465
+ message.get("modelID").and_then(|m| m.as_str()).or_else(|| {
466
+ message
467
+ .get("model")
468
+ .and_then(|m| m.get("modelID"))
469
+ .and_then(|m| m.as_str())
470
+ })
471
+ }
472
+
473
+ fn provider_id(message: &Value) -> Option<&str> {
474
+ message
475
+ .get("providerID")
476
+ .and_then(|p| p.as_str())
477
+ .or_else(|| {
478
+ message
479
+ .get("model")
480
+ .and_then(|m| m.get("providerID"))
481
+ .and_then(|p| p.as_str())
482
+ })
483
+ }
484
+
485
+ /// Render a tool payload as text. opencode writes a plain string; anything
486
+ /// else is rendered rather than dropped.
487
+ fn stringify(v: &Value) -> String {
488
+ match v {
489
+ Value::String(s) => s.clone(),
490
+ Value::Null => String::new(),
491
+ other => other.to_string(),
492
+ }
493
+ }