@agentdeck/bridge 0.2.1 → 0.2.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 (383) hide show
  1. package/assets/fonts/IBMPlexSans-Bold.ttf +0 -0
  2. package/assets/fonts/IBMPlexSans-Regular.ttf +0 -0
  3. package/assets/fonts/JetBrainsMono-Bold.ttf +0 -0
  4. package/assets/fonts/JetBrainsMono-Regular.ttf +0 -0
  5. package/assets/fonts/LICENSES.md +22 -0
  6. package/dist/adapters/claude-code.d.ts +6 -0
  7. package/dist/adapters/claude-code.d.ts.map +1 -1
  8. package/dist/adapters/claude-code.js +8 -0
  9. package/dist/adapters/claude-code.js.map +1 -1
  10. package/dist/adapters/openclaw.d.ts +24 -0
  11. package/dist/adapters/openclaw.d.ts.map +1 -1
  12. package/dist/adapters/openclaw.js +291 -35
  13. package/dist/adapters/openclaw.js.map +1 -1
  14. package/dist/adapters/opencode-adapter.d.ts +59 -0
  15. package/dist/adapters/opencode-adapter.d.ts.map +1 -1
  16. package/dist/adapters/opencode-adapter.js +297 -35
  17. package/dist/adapters/opencode-adapter.js.map +1 -1
  18. package/dist/adb-reverse.d.ts +1 -1
  19. package/dist/adb-reverse.js +1 -1
  20. package/dist/antigravity-local.d.ts +1 -0
  21. package/dist/antigravity-local.d.ts.map +1 -1
  22. package/dist/antigravity-local.js +33 -0
  23. package/dist/antigravity-local.js.map +1 -1
  24. package/dist/apme/adapters/claude-hook.d.ts.map +1 -1
  25. package/dist/apme/adapters/claude-hook.js +5 -0
  26. package/dist/apme/adapters/claude-hook.js.map +1 -1
  27. package/dist/apme/adapters/codex-hook.d.ts +17 -0
  28. package/dist/apme/adapters/codex-hook.d.ts.map +1 -0
  29. package/dist/apme/adapters/codex-hook.js +95 -0
  30. package/dist/apme/adapters/codex-hook.js.map +1 -0
  31. package/dist/apme/adapters/codex-turn-manager.d.ts +86 -0
  32. package/dist/apme/adapters/codex-turn-manager.d.ts.map +1 -0
  33. package/dist/apme/adapters/codex-turn-manager.js +496 -0
  34. package/dist/apme/adapters/codex-turn-manager.js.map +1 -0
  35. package/dist/apme/adapters/openclaw-hook.d.ts +99 -0
  36. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -0
  37. package/dist/apme/adapters/openclaw-hook.js +234 -0
  38. package/dist/apme/adapters/openclaw-hook.js.map +1 -0
  39. package/dist/apme/adapters/opencode-hook.d.ts +56 -0
  40. package/dist/apme/adapters/opencode-hook.d.ts.map +1 -0
  41. package/dist/apme/adapters/opencode-hook.js +182 -0
  42. package/dist/apme/adapters/opencode-hook.js.map +1 -0
  43. package/dist/apme/adapters/timeline.d.ts.map +1 -1
  44. package/dist/apme/adapters/timeline.js +6 -1
  45. package/dist/apme/adapters/timeline.js.map +1 -1
  46. package/dist/apme/antigravity-transcript.d.ts +18 -0
  47. package/dist/apme/antigravity-transcript.d.ts.map +1 -0
  48. package/dist/apme/antigravity-transcript.js +94 -0
  49. package/dist/apme/antigravity-transcript.js.map +1 -0
  50. package/dist/apme/classify-turn.d.ts +13 -0
  51. package/dist/apme/classify-turn.d.ts.map +1 -0
  52. package/dist/apme/classify-turn.js +49 -0
  53. package/dist/apme/classify-turn.js.map +1 -0
  54. package/dist/apme/claude-transcript-reader.d.ts +10 -0
  55. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  56. package/dist/apme/claude-transcript-reader.js +35 -0
  57. package/dist/apme/claude-transcript-reader.js.map +1 -1
  58. package/dist/apme/collector.d.ts +129 -6
  59. package/dist/apme/collector.d.ts.map +1 -1
  60. package/dist/apme/collector.js +470 -23
  61. package/dist/apme/collector.js.map +1 -1
  62. package/dist/apme/dashboard-html.d.ts.map +1 -1
  63. package/dist/apme/dashboard-html.js +87 -11
  64. package/dist/apme/dashboard-html.js.map +1 -1
  65. package/dist/apme/http.d.ts.map +1 -1
  66. package/dist/apme/http.js +43 -6
  67. package/dist/apme/http.js.map +1 -1
  68. package/dist/apme/index.d.ts +26 -5
  69. package/dist/apme/index.d.ts.map +1 -1
  70. package/dist/apme/index.js +135 -9
  71. package/dist/apme/index.js.map +1 -1
  72. package/dist/apme/pareto.d.ts +39 -0
  73. package/dist/apme/pareto.d.ts.map +1 -0
  74. package/dist/apme/pareto.js +59 -0
  75. package/dist/apme/pareto.js.map +1 -0
  76. package/dist/apme/recommend.d.ts +3 -0
  77. package/dist/apme/recommend.d.ts.map +1 -1
  78. package/dist/apme/recommend.js +43 -1
  79. package/dist/apme/recommend.js.map +1 -1
  80. package/dist/apme/runner.d.ts +92 -4
  81. package/dist/apme/runner.d.ts.map +1 -1
  82. package/dist/apme/runner.js +586 -70
  83. package/dist/apme/runner.js.map +1 -1
  84. package/dist/apme/sample-to-timeline.d.ts +26 -0
  85. package/dist/apme/sample-to-timeline.d.ts.map +1 -0
  86. package/dist/apme/sample-to-timeline.js +95 -0
  87. package/dist/apme/sample-to-timeline.js.map +1 -0
  88. package/dist/apme/scorers/index.d.ts +47 -0
  89. package/dist/apme/scorers/index.d.ts.map +1 -0
  90. package/dist/apme/scorers/index.js +96 -0
  91. package/dist/apme/scorers/index.js.map +1 -0
  92. package/dist/apme/settings.d.ts +14 -10
  93. package/dist/apme/settings.d.ts.map +1 -1
  94. package/dist/apme/settings.js +34 -8
  95. package/dist/apme/settings.js.map +1 -1
  96. package/dist/apme/store.d.ts +33 -1
  97. package/dist/apme/store.d.ts.map +1 -1
  98. package/dist/apme/store.js +334 -25
  99. package/dist/apme/store.js.map +1 -1
  100. package/dist/awaiting-overlay.d.ts +99 -0
  101. package/dist/awaiting-overlay.d.ts.map +1 -0
  102. package/dist/awaiting-overlay.js +148 -0
  103. package/dist/awaiting-overlay.js.map +1 -0
  104. package/dist/ble-sync-spawn.d.ts +63 -0
  105. package/dist/ble-sync-spawn.d.ts.map +1 -0
  106. package/dist/ble-sync-spawn.js +196 -0
  107. package/dist/ble-sync-spawn.js.map +1 -0
  108. package/dist/bridge-core.d.ts +24 -1
  109. package/dist/bridge-core.d.ts.map +1 -1
  110. package/dist/bridge-core.js +157 -24
  111. package/dist/bridge-core.js.map +1 -1
  112. package/dist/broadcast.d.ts +3 -0
  113. package/dist/broadcast.d.ts.map +1 -0
  114. package/dist/broadcast.js +122 -0
  115. package/dist/broadcast.js.map +1 -0
  116. package/dist/check-deps.d.ts.map +1 -1
  117. package/dist/check-deps.js +25 -9
  118. package/dist/check-deps.js.map +1 -1
  119. package/dist/cli.d.ts +15 -1
  120. package/dist/cli.d.ts.map +1 -1
  121. package/dist/cli.js +1025 -94
  122. package/dist/cli.js.map +1 -1
  123. package/dist/codex-auth.d.ts +21 -0
  124. package/dist/codex-auth.d.ts.map +1 -1
  125. package/dist/codex-auth.js +52 -1
  126. package/dist/codex-auth.js.map +1 -1
  127. package/dist/codex-output-parser.d.ts +8 -0
  128. package/dist/codex-output-parser.d.ts.map +1 -1
  129. package/dist/codex-output-parser.js +110 -25
  130. package/dist/codex-output-parser.js.map +1 -1
  131. package/dist/codex-rate-limits.d.ts +16 -0
  132. package/dist/codex-rate-limits.d.ts.map +1 -0
  133. package/dist/codex-rate-limits.js +197 -0
  134. package/dist/codex-rate-limits.js.map +1 -0
  135. package/dist/codex-rollout-response.d.ts +31 -0
  136. package/dist/codex-rollout-response.d.ts.map +1 -0
  137. package/dist/codex-rollout-response.js +135 -0
  138. package/dist/codex-rollout-response.js.map +1 -0
  139. package/dist/daemon-server.d.ts +85 -0
  140. package/dist/daemon-server.d.ts.map +1 -1
  141. package/dist/daemon-server.js +1627 -159
  142. package/dist/daemon-server.js.map +1 -1
  143. package/dist/daemon.js +15 -6
  144. package/dist/daemon.js.map +1 -1
  145. package/dist/display-dim.d.ts +9 -0
  146. package/dist/display-dim.d.ts.map +1 -0
  147. package/dist/display-dim.js +53 -0
  148. package/dist/display-dim.js.map +1 -0
  149. package/dist/display-monitor.d.ts +15 -2
  150. package/dist/display-monitor.d.ts.map +1 -1
  151. package/dist/display-monitor.js +104 -26
  152. package/dist/display-monitor.js.map +1 -1
  153. package/dist/esp32-serial.d.ts +160 -1
  154. package/dist/esp32-serial.d.ts.map +1 -1
  155. package/dist/esp32-serial.js +950 -153
  156. package/dist/esp32-serial.js.map +1 -1
  157. package/dist/fallback-task-timeline.d.ts +42 -0
  158. package/dist/fallback-task-timeline.d.ts.map +1 -0
  159. package/dist/fallback-task-timeline.js +112 -0
  160. package/dist/fallback-task-timeline.js.map +1 -0
  161. package/dist/foundation-models-helper.d.ts +11 -0
  162. package/dist/foundation-models-helper.d.ts.map +1 -0
  163. package/dist/foundation-models-helper.js +241 -0
  164. package/dist/foundation-models-helper.js.map +1 -0
  165. package/dist/hook-migration.js +1 -1
  166. package/dist/hook-migration.js.map +1 -1
  167. package/dist/hook-server.d.ts +2 -3
  168. package/dist/hook-server.d.ts.map +1 -1
  169. package/dist/hook-server.js +22 -10
  170. package/dist/hook-server.js.map +1 -1
  171. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts +33 -0
  172. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -0
  173. package/dist/idotmatrix/idotmatrix-daemon-sync.js +164 -0
  174. package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -0
  175. package/dist/idotmatrix/idotmatrix-discover.d.ts +18 -0
  176. package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -0
  177. package/dist/idotmatrix/idotmatrix-discover.js +85 -0
  178. package/dist/idotmatrix/idotmatrix-discover.js.map +1 -0
  179. package/dist/idotmatrix/idotmatrix-settings.d.ts +20 -0
  180. package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -0
  181. package/dist/idotmatrix/idotmatrix-settings.js +57 -0
  182. package/dist/idotmatrix/idotmatrix-settings.js.map +1 -0
  183. package/dist/index.d.ts.map +1 -1
  184. package/dist/index.js +215 -179
  185. package/dist/index.js.map +1 -1
  186. package/dist/log-stream.d.ts +15 -16
  187. package/dist/log-stream.d.ts.map +1 -1
  188. package/dist/log-stream.js +18 -96
  189. package/dist/log-stream.js.map +1 -1
  190. package/dist/logger.d.ts.map +1 -1
  191. package/dist/logger.js +12 -3
  192. package/dist/logger.js.map +1 -1
  193. package/dist/mdns.d.ts +17 -0
  194. package/dist/mdns.d.ts.map +1 -1
  195. package/dist/mdns.js +52 -18
  196. package/dist/mdns.js.map +1 -1
  197. package/dist/modules/adb-module.d.ts +1 -1
  198. package/dist/modules/adb-module.d.ts.map +1 -1
  199. package/dist/modules/adb-module.js +4 -3
  200. package/dist/modules/adb-module.js.map +1 -1
  201. package/dist/modules/broadcast-module.d.ts +17 -0
  202. package/dist/modules/broadcast-module.d.ts.map +1 -0
  203. package/dist/modules/broadcast-module.js +26 -0
  204. package/dist/modules/broadcast-module.js.map +1 -0
  205. package/dist/modules/idotmatrix-module.d.ts +9 -0
  206. package/dist/modules/idotmatrix-module.d.ts.map +1 -0
  207. package/dist/modules/idotmatrix-module.js +46 -0
  208. package/dist/modules/idotmatrix-module.js.map +1 -0
  209. package/dist/modules/index.d.ts +3 -1
  210. package/dist/modules/index.d.ts.map +1 -1
  211. package/dist/modules/index.js +9 -3
  212. package/dist/modules/index.js.map +1 -1
  213. package/dist/modules/pixoo-module.d.ts.map +1 -1
  214. package/dist/modules/pixoo-module.js +14 -4
  215. package/dist/modules/pixoo-module.js.map +1 -1
  216. package/dist/modules/serial-module.d.ts +4 -0
  217. package/dist/modules/serial-module.d.ts.map +1 -1
  218. package/dist/modules/serial-module.js +14 -12
  219. package/dist/modules/serial-module.js.map +1 -1
  220. package/dist/modules/timebox-module.d.ts +9 -0
  221. package/dist/modules/timebox-module.d.ts.map +1 -0
  222. package/dist/modules/timebox-module.js +46 -0
  223. package/dist/modules/timebox-module.js.map +1 -0
  224. package/dist/modules/types.d.ts +4 -2
  225. package/dist/modules/types.d.ts.map +1 -1
  226. package/dist/openclaw-session.d.ts +18 -0
  227. package/dist/openclaw-session.d.ts.map +1 -0
  228. package/dist/openclaw-session.js +34 -0
  229. package/dist/openclaw-session.js.map +1 -0
  230. package/dist/output-parser.d.ts +30 -0
  231. package/dist/output-parser.d.ts.map +1 -1
  232. package/dist/output-parser.js +125 -9
  233. package/dist/output-parser.js.map +1 -1
  234. package/dist/passive-observer.d.ts +26 -0
  235. package/dist/passive-observer.d.ts.map +1 -1
  236. package/dist/passive-observer.js +258 -49
  237. package/dist/passive-observer.js.map +1 -1
  238. package/dist/permission-resolver.d.ts +47 -0
  239. package/dist/permission-resolver.d.ts.map +1 -0
  240. package/dist/permission-resolver.js +103 -0
  241. package/dist/permission-resolver.js.map +1 -0
  242. package/dist/pixoo/micro-glyphs.d.ts +49 -0
  243. package/dist/pixoo/micro-glyphs.d.ts.map +1 -0
  244. package/dist/pixoo/micro-glyphs.js +276 -0
  245. package/dist/pixoo/micro-glyphs.js.map +1 -0
  246. package/dist/pixoo/pixoo-bridge.d.ts +2 -2
  247. package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
  248. package/dist/pixoo/pixoo-bridge.js +93 -25
  249. package/dist/pixoo/pixoo-bridge.js.map +1 -1
  250. package/dist/pixoo/pixoo-camera.d.ts +19 -3
  251. package/dist/pixoo/pixoo-camera.d.ts.map +1 -1
  252. package/dist/pixoo/pixoo-camera.js +62 -27
  253. package/dist/pixoo/pixoo-camera.js.map +1 -1
  254. package/dist/pixoo/pixoo-client.d.ts +13 -3
  255. package/dist/pixoo/pixoo-client.d.ts.map +1 -1
  256. package/dist/pixoo/pixoo-client.js +51 -22
  257. package/dist/pixoo/pixoo-client.js.map +1 -1
  258. package/dist/pixoo/pixoo-discover.d.ts +37 -0
  259. package/dist/pixoo/pixoo-discover.d.ts.map +1 -0
  260. package/dist/pixoo/pixoo-discover.js +105 -0
  261. package/dist/pixoo/pixoo-discover.js.map +1 -0
  262. package/dist/pixoo/pixoo-renderer.d.ts +8 -3
  263. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  264. package/dist/pixoo/pixoo-renderer.js +189 -60
  265. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  266. package/dist/pixoo/pixoo-settings.d.ts +6 -0
  267. package/dist/pixoo/pixoo-settings.d.ts.map +1 -1
  268. package/dist/pixoo/pixoo-settings.js +9 -0
  269. package/dist/pixoo/pixoo-settings.js.map +1 -1
  270. package/dist/pixoo/pixoo-sprites.d.ts +60 -11
  271. package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
  272. package/dist/pixoo/pixoo-sprites.js +512 -209
  273. package/dist/pixoo/pixoo-sprites.js.map +1 -1
  274. package/dist/pty-manager.d.ts.map +1 -1
  275. package/dist/pty-manager.js +8 -2
  276. package/dist/pty-manager.js.map +1 -1
  277. package/dist/session-activity.d.ts +14 -0
  278. package/dist/session-activity.d.ts.map +1 -0
  279. package/dist/session-activity.js +160 -0
  280. package/dist/session-activity.js.map +1 -0
  281. package/dist/session-aggregator.d.ts +17 -0
  282. package/dist/session-aggregator.d.ts.map +1 -1
  283. package/dist/session-aggregator.js +48 -9
  284. package/dist/session-aggregator.js.map +1 -1
  285. package/dist/session-registry.d.ts +71 -3
  286. package/dist/session-registry.d.ts.map +1 -1
  287. package/dist/session-registry.js +137 -8
  288. package/dist/session-registry.js.map +1 -1
  289. package/dist/session-timeline-relay.d.ts.map +1 -1
  290. package/dist/session-timeline-relay.js +13 -4
  291. package/dist/session-timeline-relay.js.map +1 -1
  292. package/dist/session-transcript-timeline.d.ts +49 -0
  293. package/dist/session-transcript-timeline.d.ts.map +1 -0
  294. package/dist/session-transcript-timeline.js +276 -0
  295. package/dist/session-transcript-timeline.js.map +1 -0
  296. package/dist/state-machine.d.ts +6 -1
  297. package/dist/state-machine.d.ts.map +1 -1
  298. package/dist/state-machine.js +86 -28
  299. package/dist/state-machine.js.map +1 -1
  300. package/dist/timebox/timebox-daemon-sync.d.ts +19 -0
  301. package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -0
  302. package/dist/timebox/timebox-daemon-sync.js +135 -0
  303. package/dist/timebox/timebox-daemon-sync.js.map +1 -0
  304. package/dist/timebox/timebox-discover.d.ts +19 -0
  305. package/dist/timebox/timebox-discover.d.ts.map +1 -0
  306. package/dist/timebox/timebox-discover.js +87 -0
  307. package/dist/timebox/timebox-discover.js.map +1 -0
  308. package/dist/timebox/timebox-settings.d.ts +32 -0
  309. package/dist/timebox/timebox-settings.d.ts.map +1 -0
  310. package/dist/timebox/timebox-settings.js +80 -0
  311. package/dist/timebox/timebox-settings.js.map +1 -0
  312. package/dist/timeline-store.d.ts +63 -3
  313. package/dist/timeline-store.d.ts.map +1 -1
  314. package/dist/timeline-store.js +292 -14
  315. package/dist/timeline-store.js.map +1 -1
  316. package/dist/timeline-summarizer.d.ts.map +1 -1
  317. package/dist/timeline-summarizer.js +29 -5
  318. package/dist/timeline-summarizer.js.map +1 -1
  319. package/dist/tui/ansi.d.ts +0 -4
  320. package/dist/tui/ansi.d.ts.map +1 -1
  321. package/dist/tui/ansi.js +14 -7
  322. package/dist/tui/ansi.js.map +1 -1
  323. package/dist/tui/dashboard.d.ts +1 -0
  324. package/dist/tui/dashboard.d.ts.map +1 -1
  325. package/dist/tui/dashboard.js +24 -7
  326. package/dist/tui/dashboard.js.map +1 -1
  327. package/dist/tui/renderer.d.ts +36 -0
  328. package/dist/tui/renderer.d.ts.map +1 -1
  329. package/dist/tui/renderer.js +303 -119
  330. package/dist/tui/renderer.js.map +1 -1
  331. package/dist/tui/terrarium.d.ts.map +1 -1
  332. package/dist/tui/terrarium.js +37 -49
  333. package/dist/tui/terrarium.js.map +1 -1
  334. package/dist/types.d.ts +2 -2
  335. package/dist/types.d.ts.map +1 -1
  336. package/dist/types.js +2 -2
  337. package/dist/types.js.map +1 -1
  338. package/dist/usage-api.d.ts.map +1 -1
  339. package/dist/usage-api.js +7 -1
  340. package/dist/usage-api.js.map +1 -1
  341. package/dist/usage-event.d.ts +10 -3
  342. package/dist/usage-event.d.ts.map +1 -1
  343. package/dist/usage-event.js +74 -12
  344. package/dist/usage-event.js.map +1 -1
  345. package/dist/utility-proxy.d.ts.map +1 -1
  346. package/dist/utility-proxy.js +8 -0
  347. package/dist/utility-proxy.js.map +1 -1
  348. package/dist/utils/project-name.d.ts +3 -0
  349. package/dist/utils/project-name.d.ts.map +1 -1
  350. package/dist/utils/project-name.js +44 -2
  351. package/dist/utils/project-name.js.map +1 -1
  352. package/dist/windows-service.d.ts +26 -0
  353. package/dist/windows-service.d.ts.map +1 -0
  354. package/dist/windows-service.js +145 -0
  355. package/dist/windows-service.js.map +1 -0
  356. package/dist/ws-server.d.ts +22 -1
  357. package/dist/ws-server.d.ts.map +1 -1
  358. package/dist/ws-server.js +89 -4
  359. package/dist/ws-server.js.map +1 -1
  360. package/fm-helper/AgentDeckFMHelper.swift +112 -0
  361. package/package.json +9 -3
  362. package/scripts/build-fm-helper.mjs +26 -0
  363. package/LICENSE +0 -21
  364. package/dist/apme/tuner.d.ts +0 -64
  365. package/dist/apme/tuner.d.ts.map +0 -1
  366. package/dist/apme/tuner.js +0 -297
  367. package/dist/apme/tuner.js.map +0 -1
  368. package/dist/d200h/hid-protocol.d.ts +0 -62
  369. package/dist/d200h/hid-protocol.d.ts.map +0 -1
  370. package/dist/d200h/hid-protocol.js +0 -124
  371. package/dist/d200h/hid-protocol.js.map +0 -1
  372. package/dist/d200h/image-renderer.d.ts +0 -36
  373. package/dist/d200h/image-renderer.d.ts.map +0 -1
  374. package/dist/d200h/image-renderer.js +0 -487
  375. package/dist/d200h/image-renderer.js.map +0 -1
  376. package/dist/modules/d200h-module.d.ts +0 -43
  377. package/dist/modules/d200h-module.d.ts.map +0 -1
  378. package/dist/modules/d200h-module.js +0 -360
  379. package/dist/modules/d200h-module.js.map +0 -1
  380. package/dist/modules/d200h-renderer.d.ts +0 -7
  381. package/dist/modules/d200h-renderer.d.ts.map +0 -1
  382. package/dist/modules/d200h-renderer.js +0 -321
  383. package/dist/modules/d200h-renderer.js.map +0 -1
@@ -18,18 +18,38 @@ import { mkdirSync, writeFileSync } from 'fs';
18
18
  import { homedir } from 'os';
19
19
  import { debug } from '../logger.js';
20
20
  import { resolveProjectName } from '../utils/project-name.js';
21
- import { classifyRunSmart } from './classifier.js';
21
+ import { priceUsd, providerFor } from '@agentdeck/shared';
22
+ import { classifyRunSmart, computeSignals, classify } from './classifier.js';
23
+ /** A turn_start with the same prompt landing on a fresh, still-empty turn
24
+ * within this window is treated as a transport echo (e.g. OpenClaw
25
+ * `chat.send` span + the gateway's `session.message` role=user re-delivery
26
+ * of the same text), not a new turn. */
27
+ const DUPLICATE_TURN_OPEN_WINDOW_MS = 15_000;
22
28
  export class ApmeCollector {
23
29
  store;
24
30
  hwSampler;
25
31
  sessionToRun = new Map(); // sessionId → runId
32
+ sessionToAgentType = new Map(); // sessionId → agentType (survives closeRun for late-attributed timeline rows)
26
33
  sessionToTurn = new Map(); // sessionId → current turn
27
34
  sessionToLastTurnId = new Map(); // survives closeTurn()
28
35
  sessionToTask = new Map(); // sessionId → current task
29
36
  runTaskCount = new Map(); // runId → next task_index
37
+ sessionToUsage = new Map(); // last cumulative usage
30
38
  /** Optional listener fired after `closeTask` persists the row. The runner
31
39
  * wires this to enqueue a task-level judge call. */
32
40
  onTaskClosed = null;
41
+ /** Optional listener fired after `openTaskIfNone` inserts a fresh task row.
42
+ * The dashboard timeline emitter wires this to push a `task_start` entry. */
43
+ onTaskOpened = null;
44
+ /** Optional listener fired after a typed trajectory event is persisted.
45
+ * The timeline projection (Phase 3) consumes this. */
46
+ onSampleEvent = null;
47
+ /** Optional listener fired on the TodoWrite-all-completed soft hint.
48
+ * The timeline emitter wires this to push a `task_milestone` row. */
49
+ onTaskMilestone = null;
50
+ /** Last milestone key (`taskId:turnIndex`) per session — a turn can carry
51
+ * several all-completed TodoWrite calls; surface only the first. */
52
+ sessionToLastMilestone = new Map();
33
53
  constructor(store, hwSampler) {
34
54
  this.store = store;
35
55
  this.hwSampler = hwSampler;
@@ -54,6 +74,7 @@ export class ApmeCollector {
54
74
  try {
55
75
  this.store.insertRun(row);
56
76
  this.sessionToRun.set(input.sessionId, runId);
77
+ this.sessionToAgentType.set(input.sessionId, input.agentType);
57
78
  debug('APME', `openRun ${runId} session=${input.sessionId} agent=${input.agentType} model=${input.modelId ?? '-'}`);
58
79
  }
59
80
  catch (err) {
@@ -61,13 +82,23 @@ export class ApmeCollector {
61
82
  }
62
83
  return runId;
63
84
  }
64
- /** Record a hook event as a step + manage turn lifecycle. */
65
- ingestHook(sessionId, event, data) {
85
+ /** Record a hook event as a step + manage turn lifecycle.
86
+ *
87
+ * `event` is normalized up-front so callers may pass EITHER the PascalCase
88
+ * hook name (`UserPromptSubmit`, managed session bridge + span ingest) OR
89
+ * the snake_case name the daemon's `/hooks/` handler maps to
90
+ * (`user_prompt_submit`, `tool_start`, `tool_end`). Before this, snake_case
91
+ * silently fell through every branch — the daemon's direct-`claude` hooks
92
+ * never opened a turn, so those sessions produced no tasks and the device
93
+ * timeline showed every prompt as a bare top-level chat. Making the matcher
94
+ * case-tolerant means no caller can quietly no-op the segmentation. */
95
+ ingestHook(sessionId, rawEvent, data) {
66
96
  if (!this.store.enabled)
67
97
  return;
68
98
  const runId = this.sessionToRun.get(sessionId);
69
99
  if (!runId)
70
100
  return;
101
+ const event = normalizeHookEventName(rawEvent);
71
102
  const toolName = typeof data.tool_name === 'string' ? data.tool_name : null;
72
103
  // ── Turn management ──
73
104
  if (event === 'UserPromptSubmit') {
@@ -77,8 +108,31 @@ export class ApmeCollector {
77
108
  ? data.message?.content
78
109
  : undefined);
79
110
  const prompt = typeof rawPrompt === 'string' ? rawPrompt.slice(0, 8_000) : null;
80
- // Close previous turn if open read index BEFORE delete
81
- const prevIndex = this.sessionToTurn.get(sessionId)?.index ?? -1;
111
+ // Duplicate-open guard: one user prompt can reach the collector more
112
+ // than once within moments. Closing and reopening here would strand an
113
+ // empty phantom turn and shift every later turn_index, so an identical
114
+ // prompt landing on a fresh, still-empty turn is a no-op.
115
+ const openTurn = this.sessionToTurn.get(sessionId);
116
+ if (openTurn && prompt !== null && openTurn.prompt === prompt &&
117
+ openTurn.toolCalls === 0 && !openTurn.hasResponse &&
118
+ Date.now() - openTurn.startedAt < DUPLICATE_TURN_OPEN_WINDOW_MS) {
119
+ debug('APME', `duplicate turn_start ignored (echo) turn=${openTurn.id.slice(0, 8)}`);
120
+ return;
121
+ }
122
+ // Close previous turn if open. Resolve prevIndex carefully: the active
123
+ // turn may already have been closed by an explicit closeTurnForSession
124
+ // (Codex `codex_stop` hook), in which case sessionToTurn is empty. Fall
125
+ // back to the last closed turn's row in the store so subsequent turns
126
+ // keep monotonically increasing turn_index instead of resetting to 0.
127
+ let prevIndex = this.sessionToTurn.get(sessionId)?.index ?? -1;
128
+ if (prevIndex === -1) {
129
+ const lastTurnId = this.sessionToLastTurnId.get(sessionId);
130
+ if (lastTurnId) {
131
+ const lastIdx = this.store.getTurn(lastTurnId)?.turn_index;
132
+ if (typeof lastIdx === 'number')
133
+ prevIndex = lastIdx;
134
+ }
135
+ }
82
136
  this.closeTurn(sessionId);
83
137
  // Open new turn
84
138
  const turnIndex = prevIndex + 1;
@@ -90,6 +144,7 @@ export class ApmeCollector {
90
144
  startedAt: Date.now(), toolCalls: 0,
91
145
  filesModified: 0, filesCreated: 0,
92
146
  gitBefore: readGitHead(projectPath),
147
+ prompt, hasResponse: false,
93
148
  };
94
149
  this.sessionToTurn.set(sessionId, turn);
95
150
  // Ensure an active task exists so this turn can attach to it. Tasks group
@@ -97,9 +152,16 @@ export class ApmeCollector {
97
152
  // /clear, session_end). First turn in a run opens task 0.
98
153
  const task = this.openTaskIfNone(sessionId, runId);
99
154
  if (task) {
100
- if (task.firstTurnIndex === null)
155
+ const wasFirstTurn = task.firstTurnIndex === null;
156
+ if (wasFirstTurn)
101
157
  task.firstTurnIndex = turnIndex;
102
158
  task.lastTurnIndex = turnIndex;
159
+ // Second (or later) turn on the same task proves it's a real,
160
+ // multi-turn work unit → promote the deferred TASK header so the
161
+ // follow-up prompts render grouped under one row instead of as
162
+ // separate top-level chats. Single-turn Q&A never reaches here.
163
+ if (!wasFirstTurn)
164
+ this.emitDeferredTaskStartIfNeeded(sessionId);
103
165
  }
104
166
  try {
105
167
  this.store.insertTurn({
@@ -111,6 +173,10 @@ export class ApmeCollector {
111
173
  catch (err) {
112
174
  debug('APME', `insertTurn failed: ${String(err)}`);
113
175
  }
176
+ // Sample trajectory: the user message opens the turn's typed event log.
177
+ if (task) {
178
+ this.appendSampleEvent({ taskId: task.id, runId, turnIndex }, { kind: 'user_message', ts: turn.startedAt, dedupCore: hashCore(prompt ?? `turn${turnIndex}`), payloadObj: { text: prompt ?? '' } });
179
+ }
114
180
  // Also set run's task_prompt from first prompt
115
181
  try {
116
182
  if (run && !run.taskPrompt && prompt) {
@@ -127,16 +193,70 @@ export class ApmeCollector {
127
193
  activeTurn.filesModified++;
128
194
  if (toolName === 'Write')
129
195
  activeTurn.filesCreated++;
196
+ // Sample trajectory: a tool call starts as a pending ToolEvent; its
197
+ // PostToolUse result resolves the SAME row (one row, not two).
198
+ const task = this.sessionToTask.get(sessionId);
199
+ if (task && toolName) {
200
+ this.appendSampleEvent({ taskId: task.id, runId, turnIndex: activeTurn.index }, {
201
+ kind: 'tool', toolName, toolStatus: 'pending',
202
+ dedupCore: `${toolName}:${activeTurn.toolCalls}`,
203
+ payloadObj: { input: extractToolInput(data) },
204
+ });
205
+ }
130
206
  }
131
- // ── Task boundary: TodoWrite all-completed ──
132
- // Claude Code's TodoWrite PostToolUse payload contains tool_input.todos.
133
- // When every todo status is "completed", treat it as the agent declaring
134
- // the task finished. Close the current task; the next UserPromptSubmit
135
- // opens a fresh one.
207
+ // Sample trajectory: resolve the pending ToolEvent on PostToolUse / tool_result.
208
+ if ((event === 'PostToolUse' || event === 'tool_result') && toolName) {
209
+ const task = this.sessionToTask.get(sessionId);
210
+ const turnIndex = this.sessionToTurn.get(sessionId)?.index;
211
+ if (task && turnIndex !== undefined) {
212
+ const isError = Boolean(data.is_error || data.error);
213
+ const output = extractToolOutput(data);
214
+ const pending = this.store.findPendingToolEvent(task.id, turnIndex, toolName);
215
+ if (pending?.id != null) {
216
+ let payload = pending.payload;
217
+ try {
218
+ const obj = pending.payload ? JSON.parse(pending.payload) : {};
219
+ if (output !== undefined)
220
+ obj.output = output;
221
+ payload = safeStringify(obj);
222
+ }
223
+ catch { /* keep existing payload */ }
224
+ this.store.updateSampleEvent(pending.id, {
225
+ toolStatus: isError ? 'error' : 'success',
226
+ toolError: isError ? String(data.error ?? 'error').slice(0, 500) : null,
227
+ payload,
228
+ });
229
+ }
230
+ else {
231
+ // No pending row (PostToolUse without a matching PreToolUse) — record
232
+ // a resolved tool event directly.
233
+ this.appendSampleEvent({ taskId: task.id, runId, turnIndex }, {
234
+ kind: 'tool', toolName,
235
+ toolStatus: isError ? 'error' : 'success',
236
+ toolError: isError ? String(data.error ?? 'error').slice(0, 500) : null,
237
+ dedupCore: `${toolName}:resolved:${turnIndex}:${this.store.nextSampleSeq(task.id)}`,
238
+ payloadObj: { output },
239
+ });
240
+ }
241
+ }
242
+ }
243
+ // ── Task boundary HINT: TodoWrite all-completed ──
244
+ // Demoted from a hard boundary to a non-segmenting hint (2026-06).
245
+ // TodoWrite-all-complete fires unreliably (~18% on Claude Code v2.1) and,
246
+ // when it did fire, fragmented a single logical task into several units.
247
+ // Tasks now segment only on EXPLICIT boundaries (`/task close`, `/clear`)
248
+ // or `session_end` — a stable, user-controlled unit. We still record the
249
+ // milestone in the trajectory so the task rollup can see that the agent
250
+ // declared its todos done, without splitting the task.
136
251
  if (event === 'PostToolUse' && toolName === 'TodoWrite') {
137
252
  const todos = extractTodos(data);
138
253
  if (todos && todos.length > 0 && todos.every((t) => t.status === 'completed')) {
139
- this.closeTask(sessionId, 'todo_complete');
254
+ const task = this.sessionToTask.get(sessionId);
255
+ const turnIndex = this.sessionToTurn.get(sessionId)?.index;
256
+ if (task && turnIndex !== undefined) {
257
+ this.appendSampleEvent({ taskId: task.id, runId, turnIndex }, { kind: 'state', dedupCore: `todos_complete:${turnIndex}:${todos.length}`, payloadObj: { state: 'todos_completed', count: todos.length } });
258
+ this.fireTaskMilestone(sessionId, task.id, runId, turnIndex, todos.length);
259
+ }
140
260
  }
141
261
  }
142
262
  // Record step
@@ -150,6 +270,14 @@ export class ApmeCollector {
150
270
  debug('APME', `ingestHook failed: ${String(err)}`);
151
271
  }
152
272
  }
273
+ /** Public wrapper for the private `closeTurn`. Used by adapters that
274
+ * see an explicit turn-end signal (Codex `codex_stop` hook) and want
275
+ * to finalize the turn row immediately rather than wait for the next
276
+ * UserPromptSubmit / closeRun to flush endedAt + buffered counters.
277
+ * Idempotent — no-op when no active turn for the session. */
278
+ closeTurnForSession(sessionId) {
279
+ this.closeTurn(sessionId);
280
+ }
153
281
  /** Close the current turn for a session (called on new prompt or session end). */
154
282
  closeTurn(sessionId) {
155
283
  const turn = this.sessionToTurn.get(sessionId);
@@ -182,9 +310,48 @@ export class ApmeCollector {
182
310
  getActiveTaskId(sessionId) {
183
311
  return this.sessionToTask.get(sessionId)?.id ?? null;
184
312
  }
313
+ /** Fire the `onTaskMilestone` listener for a TodoWrite-all-completed hint,
314
+ * at most once per (task, turn) — the agent may rewrite an all-completed
315
+ * todo list several times inside one turn. */
316
+ fireTaskMilestone(sessionId, taskId, runId, turnIndex, todoCount) {
317
+ if (!this.onTaskMilestone)
318
+ return;
319
+ const key = `${taskId}:${turnIndex}`;
320
+ if (this.sessionToLastMilestone.get(sessionId) === key)
321
+ return;
322
+ this.sessionToLastMilestone.set(sessionId, key);
323
+ // A TodoWrite plan implies a task worth showing — promote the deferred
324
+ // task_start first so the milestone row never renders orphaned above a
325
+ // task with no header.
326
+ this.emitDeferredTaskStartIfNeeded(sessionId);
327
+ const run = this.store.getRun(runId);
328
+ try {
329
+ this.onTaskMilestone({
330
+ taskId,
331
+ runId,
332
+ sessionId,
333
+ agentType: (run?.agentType ?? null),
334
+ projectName: run?.projectName ?? null,
335
+ turnIndex,
336
+ todoCount,
337
+ at: Date.now(),
338
+ });
339
+ }
340
+ catch (err) {
341
+ debug('APME', `onTaskMilestone listener threw: ${String(err)}`);
342
+ }
343
+ }
185
344
  /** Open a new task if none is active for this session. Returns the active
186
345
  * task (new or existing), or null if no run is open. Idempotent: repeat
187
- * calls while a task is already active are no-ops. */
346
+ * calls while a task is already active are no-ops.
347
+ *
348
+ * The `task_start` timeline row is NOT emitted here — it is deferred to
349
+ * `emitDeferredTaskStartIfNeeded`, fired when a "real task" signal lands
350
+ * (a second turn on the same task, or a TodoWrite plan). Short single-turn
351
+ * conversations therefore never produce a TASK header on the dashboard,
352
+ * keeping the timeline focused on the turn rows worth evaluating. The DB
353
+ * `tasks` row is inserted immediately so eval/analytics see every unit.
354
+ * Mirrors ApmeCollector.swift `openTaskIfNone` + `emitDeferredTaskStartIfNeeded`. */
188
355
  openTaskIfNone(sessionId, runId) {
189
356
  const existing = this.sessionToTask.get(sessionId);
190
357
  if (existing)
@@ -198,6 +365,7 @@ export class ApmeCollector {
198
365
  startedAt: Date.now(),
199
366
  firstTurnIndex: null,
200
367
  lastTurnIndex: null,
368
+ timelineEmitted: false,
201
369
  };
202
370
  this.sessionToTask.set(sessionId, task);
203
371
  try {
@@ -214,6 +382,57 @@ export class ApmeCollector {
214
382
  }
215
383
  return task;
216
384
  }
385
+ /** Emit the deferred `task_start` timeline row for the session's active task,
386
+ * once. Idempotent — repeat calls after the first are no-ops. Uses the
387
+ * task's original `startedAt` as the row ts so the TASK header anchors above
388
+ * the first turn it groups instead of jumping in mid-conversation. Called
389
+ * when a task proves itself "real" (second turn, or TodoWrite plan).
390
+ * Mirrors ApmeCollector.swift `emitDeferredTaskStartIfNeeded`. */
391
+ emitDeferredTaskStartIfNeeded(sessionId) {
392
+ const task = this.sessionToTask.get(sessionId);
393
+ if (!task || task.timelineEmitted)
394
+ return;
395
+ task.timelineEmitted = true;
396
+ if (!this.onTaskOpened)
397
+ return;
398
+ const run = this.store.getRun(task.runId);
399
+ try {
400
+ this.onTaskOpened({
401
+ taskId: task.id,
402
+ runId: task.runId,
403
+ sessionId,
404
+ agentType: (run?.agentType ?? null),
405
+ projectName: run?.projectName ?? null,
406
+ taskIndex: task.index,
407
+ startedAt: task.startedAt,
408
+ });
409
+ }
410
+ catch (err) {
411
+ debug('APME', `onTaskOpened listener threw: ${String(err)}`);
412
+ }
413
+ }
414
+ /** Public wrapper for closeTask. Used by the manual-boundary CLI /
415
+ * HTTP route + the macOS detail-pane button — lets the user declare
416
+ * "this task is done" without `/clear` (which would also split the
417
+ * run). No-op when no task is active. Passing `outcome` overrides the
418
+ * judge's coarse score-derived class — handy for `task cancel` where
419
+ * the user wants the row tagged "abandoned" regardless of partial
420
+ * progress. */
421
+ closeTaskExternal(sessionId, boundarySignal = 'manual', outcome) {
422
+ const task = this.sessionToTask.get(sessionId);
423
+ if (!task)
424
+ return false;
425
+ this.closeTask(sessionId, boundarySignal);
426
+ if (outcome) {
427
+ try {
428
+ this.store.updateTask(task.id, { outcome });
429
+ }
430
+ catch (err) {
431
+ debug('APME', `manual outcome write failed: ${String(err)}`);
432
+ }
433
+ }
434
+ return true;
435
+ }
217
436
  /** Close the current task for a session, persisting boundary metadata.
218
437
  * No-op if no task is active. Fires `onTaskClosed` so the runner can
219
438
  * enqueue a task-level judge call. Tasks that never saw a turn
@@ -223,6 +442,7 @@ export class ApmeCollector {
223
442
  if (!task)
224
443
  return;
225
444
  this.sessionToTask.delete(sessionId);
445
+ this.sessionToLastMilestone.delete(sessionId);
226
446
  // Empty task: no turns ever attached. Drop the row so the dashboard
227
447
  // doesn't show phantom entries from back-to-back boundary signals.
228
448
  if (task.firstTurnIndex === null) {
@@ -237,10 +457,31 @@ export class ApmeCollector {
237
457
  debug('APME', `closeTask ${task.id.slice(0, 8)} — empty, dropped`);
238
458
  return;
239
459
  }
240
- // Derive task category from the run (best-effort). Turn-level categories
241
- // may diverge, but for now tasks inherit from the run.
460
+ // Category, present-at-close. Prefer the run's already-resolved category;
461
+ // otherwise classify synchronously from the run's signals so the task row
462
+ // (and its rollup judge rubric) always carries a stable category. The
463
+ // async run-level classifier (classifyRunSmart at closeRun) frequently
464
+ // resolves AFTER the task has already closed — leaving taskCategory null
465
+ // and the judge falling back to the wrong generic rubric.
242
466
  const run = this.store.getRun(task.runId);
243
- const taskCategory = run?.taskCategory ?? null;
467
+ let taskCategory = run?.taskCategory ?? null;
468
+ if (!taskCategory || taskCategory === 'unknown') {
469
+ try {
470
+ const signals = computeSignals(this.store, task.runId);
471
+ // run.endedAt is still null at session_end close (updateRun runs after
472
+ // closeTask), so sessionDurationSec would be 0 and skew the duration
473
+ // rules — derive it from the task span instead.
474
+ if (signals.sessionDurationSec === 0) {
475
+ signals.sessionDurationSec = Math.max(0, Math.round((Date.now() - task.startedAt) / 1000));
476
+ }
477
+ const category = classify(signals);
478
+ if (category && category !== 'unknown')
479
+ taskCategory = category;
480
+ }
481
+ catch (err) {
482
+ debug('APME', `closeTask classify failed: ${String(err)}`);
483
+ }
484
+ }
244
485
  try {
245
486
  this.store.updateTask(task.id, {
246
487
  endedAt: Date.now(),
@@ -253,14 +494,20 @@ export class ApmeCollector {
253
494
  debug('APME', `updateTask failed: ${String(err)}`);
254
495
  }
255
496
  debug('APME', `closeTask ${task.id.slice(0, 8)} signal=${boundarySignal} turns=${task.firstTurnIndex}..${task.lastTurnIndex}`);
256
- // Notify listeners (runner enqueueTask is wired via apme/index.ts).
497
+ // Notify listeners (runner enqueueTask + timeline emitter wired via apme/index.ts).
257
498
  if (this.onTaskClosed) {
258
499
  try {
259
500
  this.onTaskClosed({
260
501
  taskId: task.id,
261
502
  runId: task.runId,
503
+ sessionId,
504
+ agentType: (run?.agentType ?? null),
505
+ projectName: run?.projectName ?? null,
506
+ startedAt: task.startedAt,
507
+ endedAt: Date.now(),
262
508
  boundarySignal,
263
509
  taskCategory,
510
+ timelineEmitted: task.timelineEmitted,
264
511
  });
265
512
  }
266
513
  catch (err) {
@@ -272,6 +519,92 @@ export class ApmeCollector {
272
519
  getRunId(sessionId) {
273
520
  return this.sessionToRun.get(sessionId) ?? null;
274
521
  }
522
+ /** Agent type established for a session at `openRun` (survives closeRun so a
523
+ * late-attributed timeline row still resolves its brand). This is the single
524
+ * authoritative agentType source the timeline attributor backfills from, so
525
+ * every timeline entry ships with an agentType regardless of which emitter
526
+ * produced it (observed hook `chat_start`, relayed managed `tool_exec`, …). */
527
+ getRunAgentType(sessionId) {
528
+ return this.sessionToAgentType.get(sessionId) ?? null;
529
+ }
530
+ // ─── SessionSample trajectory (the normalizer's typed event log) ────────────
531
+ /** Resolve the (taskId, runId, turnIndex) a sample event should attach to.
532
+ * Prefers the active task/turn; falls back to a turn row in the DB when the
533
+ * task already closed (e.g. PTY response captured after session_end). */
534
+ sampleCtxForTurn(sessionId, turnId) {
535
+ const active = this.sessionToTask.get(sessionId);
536
+ if (turnId) {
537
+ const row = this.store.getTurn(turnId);
538
+ const taskId = row?.task_id ?? active?.id;
539
+ const runId = row?.run_id ?? active?.runId;
540
+ const turnIndex = row?.turn_index
541
+ ?? this.sessionToTurn.get(sessionId)?.index ?? active?.lastTurnIndex ?? 0;
542
+ if (taskId && runId)
543
+ return { taskId, runId, turnIndex };
544
+ }
545
+ if (active) {
546
+ const turnIndex = this.sessionToTurn.get(sessionId)?.index ?? active.lastTurnIndex ?? 0;
547
+ return { taskId: active.id, runId: active.runId, turnIndex };
548
+ }
549
+ return null;
550
+ }
551
+ /** Append one typed trajectory event to the active sample. Storage-time dedup
552
+ * is handled by the UNIQUE (task_id, dedup_key) index. Fires `onSampleEvent`
553
+ * only when a row was actually inserted (not a dup), so the timeline
554
+ * projection never double-emits. */
555
+ appendSampleEvent(ctx, ev) {
556
+ if (!this.store.enabled)
557
+ return;
558
+ const dedupKey = createHash('sha1').update(`${ev.kind}|${ctx.turnIndex}|${ev.dedupCore}`).digest('hex').slice(0, 24);
559
+ const row = {
560
+ taskId: ctx.taskId,
561
+ runId: ctx.runId,
562
+ turnIndex: ctx.turnIndex,
563
+ seq: this.store.nextSampleSeq(ctx.taskId),
564
+ ts: ev.ts ?? Date.now(),
565
+ kind: ev.kind,
566
+ model: ev.model ?? null,
567
+ inputTokens: ev.inputTokens ?? null,
568
+ outputTokens: ev.outputTokens ?? null,
569
+ costUsd: ev.costUsd ?? null,
570
+ latencyMs: ev.latencyMs ?? null,
571
+ toolName: ev.toolName ?? null,
572
+ toolStatus: ev.toolStatus ?? null,
573
+ toolError: ev.toolError ?? null,
574
+ payload: ev.payloadObj ? safeStringify(ev.payloadObj) : null,
575
+ dedupKey,
576
+ };
577
+ let inserted = false;
578
+ try {
579
+ inserted = this.store.insertSampleEvent(row);
580
+ }
581
+ catch (err) {
582
+ debug('APME', `appendSampleEvent failed: ${String(err)}`);
583
+ return;
584
+ }
585
+ if (inserted && this.onSampleEvent) {
586
+ const run = this.store.getRun(ctx.runId);
587
+ try {
588
+ this.onSampleEvent({
589
+ taskId: ctx.taskId, runId: ctx.runId,
590
+ sessionId: this.runToSessionId(ctx.runId),
591
+ agentType: (run?.agentType ?? null),
592
+ projectName: run?.projectName ?? null,
593
+ event: row,
594
+ });
595
+ }
596
+ catch (err) {
597
+ debug('APME', `onSampleEvent listener threw: ${String(err)}`);
598
+ }
599
+ }
600
+ }
601
+ /** Reverse-lookup sessionId for a runId (best-effort; only the live map). */
602
+ runToSessionId(runId) {
603
+ for (const [sid, rid] of this.sessionToRun)
604
+ if (rid === runId)
605
+ return sid;
606
+ return '';
607
+ }
275
608
  /** Store Claude's response text on the current turn.
276
609
  * Falls back to the last closed turn if closeTurn() already ran (race with session exit).
277
610
  * Tags turns.efficiency_json.response_kind so the runner can skip tool-only / empty
@@ -298,10 +631,21 @@ export class ApmeCollector {
298
631
  response: response.slice(0, 10_000),
299
632
  efficiencyJson,
300
633
  });
634
+ if (turn)
635
+ turn.hasResponse = true;
301
636
  }
302
637
  catch (err) {
303
638
  debug('APME', `setTurnResponse failed: ${String(err)}`);
304
639
  }
640
+ // Sample trajectory: the assistant response closes the turn's event arc.
641
+ const ctx = this.sampleCtxForTurn(sessionId, turnId);
642
+ if (ctx) {
643
+ this.appendSampleEvent(ctx, {
644
+ kind: 'assistant_message',
645
+ dedupCore: hashCore(response.slice(0, 400)),
646
+ payloadObj: { text: response.slice(0, 10_000), responseKind: kind },
647
+ });
648
+ }
305
649
  }
306
650
  /** Apply response to the last closed turn if it has no response yet.
307
651
  * Used as fallback when Stop hook doesn't fire (PTY output capture). */
@@ -327,6 +671,14 @@ export class ApmeCollector {
327
671
  });
328
672
  }
329
673
  catch { /* ignore */ }
674
+ const ctx = this.sampleCtxForTurn(sessionId, turnId);
675
+ if (ctx) {
676
+ this.appendSampleEvent(ctx, {
677
+ kind: 'assistant_message',
678
+ dedupCore: hashCore(response.slice(0, 400)),
679
+ payloadObj: { text: response.slice(0, 10_000), responseKind: kind },
680
+ });
681
+ }
330
682
  }
331
683
  /** Single-entrypoint ingestion using the shared TelemetrySpan envelope.
332
684
  *
@@ -381,11 +733,27 @@ export class ApmeCollector {
381
733
  this.splitRun(sessionId, a['agentdeck.cwd']);
382
734
  return;
383
735
  }
384
- // Other boundary signals (todo_complete, session_end) are handled
385
- // automatically: tool_result detects todo_complete, closeRun closes
386
- // the current task with session_end. Adapters that emit those
387
- // spans explicitly are no-ops here by design — preserving the
388
- // single-source-of-truth for those transitions.
736
+ // Adapter-emitted boundaries (OpenClaw chat.aborted 'manual',
737
+ // OpenClaw idle-gap timer 'idle_gap') close the active task.
738
+ // `session_end` is intentionally excluded: closeRun fires that
739
+ // path itself, and a duplicate here would double-emit onTaskClosed.
740
+ if (signal === 'manual' || signal === 'idle_gap') {
741
+ this.closeTask(sessionId, signal);
742
+ return;
743
+ }
744
+ // `todo_complete` is a soft hint, not a boundary (see ingestHook
745
+ // TodoWrite handling) — record it in the trajectory without splitting
746
+ // the task. OpenCode's TodoWrite-all-completed routes here.
747
+ if (signal === 'todo_complete') {
748
+ const task = this.sessionToTask.get(sessionId);
749
+ const turnIndex = this.sessionToTurn.get(sessionId)?.index;
750
+ if (task && turnIndex !== undefined) {
751
+ this.appendSampleEvent({ taskId: task.id, runId: task.runId, turnIndex }, { kind: 'state', dedupCore: `todos_complete:${turnIndex}`, payloadObj: { state: 'todos_completed' } });
752
+ this.fireTaskMilestone(sessionId, task.id, task.runId, turnIndex, null);
753
+ }
754
+ return;
755
+ }
756
+ debug('APME', `task_boundary span dropped: unknown signal=${signal ?? '<none>'}`);
389
757
  return;
390
758
  }
391
759
  case 'session_meta': {
@@ -429,7 +797,10 @@ export class ApmeCollector {
429
797
  }
430
798
  catch { /* ignore */ }
431
799
  }
432
- /** Update token / cost columns from the bridge's UsageTracker snapshot. */
800
+ /** Update token / cost columns from the bridge's UsageTracker snapshot.
801
+ * Snapshots carry CUMULATIVE session totals, so we emit a priced ModelEvent
802
+ * for the delta and attribute it to the active task (the SessionSample cost
803
+ * is the sum of its ModelEvents). */
433
804
  updateUsage(sessionId, snapshot) {
434
805
  if (!this.store.enabled)
435
806
  return;
@@ -444,6 +815,35 @@ export class ApmeCollector {
444
815
  });
445
816
  }
446
817
  catch { /* ignore */ }
818
+ // ── Per-task ModelEvent from the cumulative delta ──
819
+ const curIn = snapshot.inputTokens ?? 0;
820
+ const curOut = snapshot.outputTokens ?? 0;
821
+ const prev = this.sessionToUsage.get(sessionId) ?? { in: 0, out: 0 };
822
+ const dIn = Math.max(0, curIn - prev.in);
823
+ const dOut = Math.max(0, curOut - prev.out);
824
+ this.sessionToUsage.set(sessionId, { in: curIn, out: curOut });
825
+ if (dIn === 0 && dOut === 0)
826
+ return;
827
+ const task = this.sessionToTask.get(sessionId);
828
+ if (!task)
829
+ return;
830
+ const run = this.store.getRun(runId);
831
+ const model = run?.modelId ?? null;
832
+ const turnIndex = this.sessionToTurn.get(sessionId)?.index ?? task.lastTurnIndex ?? 0;
833
+ // Prefer the agent-reported marginal cost when available, else price the delta.
834
+ const cost = priceUsd(model, dIn, dOut);
835
+ this.appendSampleEvent({ taskId: task.id, runId, turnIndex }, {
836
+ kind: 'model', model: model ?? undefined, inputTokens: dIn, outputTokens: dOut,
837
+ costUsd: cost, latencyMs: 0, dedupCore: `${curIn}:${curOut}`,
838
+ });
839
+ try {
840
+ this.store.updateTask(task.id, {
841
+ modelId: model ?? undefined,
842
+ modelConfig: JSON.stringify({ modelId: model ?? 'unknown', provider: providerFor(model) }),
843
+ });
844
+ this.store.recomputeSampleCost(task.id);
845
+ }
846
+ catch { /* ignore */ }
447
847
  }
448
848
  /** Split the current run — closes the active run and opens a fresh one.
449
849
  * Triggered on `/clear` or other context-reset events so each logical
@@ -500,6 +900,7 @@ export class ApmeCollector {
500
900
  this.sessionToRun.delete(sessionId);
501
901
  this.sessionToLastTurnId.delete(sessionId);
502
902
  this.runTaskCount.delete(runId);
903
+ this.sessionToUsage.delete(sessionId);
503
904
  // Mark empty runs so the dashboard can filter them out.
504
905
  // Don't delete — FK constraints and concurrent access make deletion risky.
505
906
  const run = this.store.getRun(runId);
@@ -593,6 +994,21 @@ export class ApmeCollector {
593
994
  /** Extract the todos array from a TodoWrite PostToolUse payload. Returns null
594
995
  * if the shape doesn't match (payload malformed, older CC versions). Accepts
595
996
  * `tool_input.todos` (hook standard) and `todos` (legacy flat shape). */
997
+ /** Canonicalize a hook event name to the PascalCase form `ingestHook` matches
998
+ * on. Accepts the daemon's snake_case (`user_prompt_submit`, `tool_start`,
999
+ * `tool_end`, `pre/post_tool_use`) and passes PascalCase / span aliases
1000
+ * (`tool_result`) through unchanged. Keeps segmentation working regardless of
1001
+ * which surface (managed bridge, daemon `/hooks/`, OTel span) drives it. */
1002
+ function normalizeHookEventName(event) {
1003
+ switch (event) {
1004
+ case 'user_prompt_submit': return 'UserPromptSubmit';
1005
+ case 'tool_start':
1006
+ case 'pre_tool_use': return 'PreToolUse';
1007
+ case 'tool_end':
1008
+ case 'post_tool_use': return 'PostToolUse';
1009
+ default: return event;
1010
+ }
1011
+ }
596
1012
  function extractTodos(data) {
597
1013
  const fromToolInput = data.tool_input?.todos;
598
1014
  const fromFlat = data.todos;
@@ -615,6 +1031,37 @@ function extractTodos(data) {
615
1031
  }
616
1032
  return items;
617
1033
  }
1034
+ /** Short stable hash for sample-event dedup keys (content-derived). */
1035
+ function hashCore(s) {
1036
+ return createHash('sha1').update(s).digest('hex').slice(0, 16);
1037
+ }
1038
+ /** Best-effort tool-input extraction from a hook/span payload. Claude Code
1039
+ * hooks carry `tool_input`; spans pass the raw payload directly. Capped to
1040
+ * keep the trajectory row small. */
1041
+ function extractToolInput(data) {
1042
+ const ti = data.tool_input ?? data.input ?? null;
1043
+ return clampPayload(ti);
1044
+ }
1045
+ /** Best-effort tool-output extraction. Claude Code PostToolUse carries
1046
+ * `tool_response`; other sources use `output` / `result`. */
1047
+ function extractToolOutput(data) {
1048
+ const out = data.tool_response ?? data.output ?? data.result;
1049
+ return out === undefined ? undefined : clampPayload(out);
1050
+ }
1051
+ /** Trim large tool payloads so a single event row stays bounded (≤4KB JSON). */
1052
+ function clampPayload(v) {
1053
+ if (v == null)
1054
+ return v;
1055
+ try {
1056
+ const s = typeof v === 'string' ? v : JSON.stringify(v);
1057
+ if (s.length <= 4_000)
1058
+ return v;
1059
+ return (typeof v === 'string' ? v : s).slice(0, 4_000) + '…';
1060
+ }
1061
+ catch {
1062
+ return undefined;
1063
+ }
1064
+ }
618
1065
  function readGitHead(cwd) {
619
1066
  if (!cwd)
620
1067
  return null;