@agentdeck/bridge 0.1.0 → 0.2.0

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 (380) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/claude-code.d.ts +11 -27
  3. package/dist/adapters/claude-code.d.ts.map +1 -1
  4. package/dist/adapters/claude-code.js +29 -123
  5. package/dist/adapters/claude-code.js.map +1 -1
  6. package/dist/adapters/codex-cli.d.ts +21 -0
  7. package/dist/adapters/codex-cli.d.ts.map +1 -0
  8. package/dist/adapters/codex-cli.js +53 -0
  9. package/dist/adapters/codex-cli.js.map +1 -0
  10. package/dist/adapters/index.d.ts +4 -0
  11. package/dist/adapters/index.d.ts.map +1 -1
  12. package/dist/adapters/index.js +13 -0
  13. package/dist/adapters/index.js.map +1 -1
  14. package/dist/adapters/monitor.d.ts +34 -0
  15. package/dist/adapters/monitor.d.ts.map +1 -0
  16. package/dist/adapters/monitor.js +86 -0
  17. package/dist/adapters/monitor.js.map +1 -0
  18. package/dist/adapters/openclaw.d.ts +36 -2
  19. package/dist/adapters/openclaw.d.ts.map +1 -1
  20. package/dist/adapters/openclaw.js +329 -59
  21. package/dist/adapters/openclaw.js.map +1 -1
  22. package/dist/adapters/opencode-adapter.d.ts +48 -0
  23. package/dist/adapters/opencode-adapter.d.ts.map +1 -0
  24. package/dist/adapters/opencode-adapter.js +345 -0
  25. package/dist/adapters/opencode-adapter.js.map +1 -0
  26. package/dist/adapters/pty-adapter.d.ts +56 -0
  27. package/dist/adapters/pty-adapter.d.ts.map +1 -0
  28. package/dist/adapters/pty-adapter.js +143 -0
  29. package/dist/adapters/pty-adapter.js.map +1 -0
  30. package/dist/adb-reverse.d.ts +25 -0
  31. package/dist/adb-reverse.d.ts.map +1 -0
  32. package/dist/adb-reverse.js +129 -0
  33. package/dist/adb-reverse.js.map +1 -0
  34. package/dist/antigravity-local.d.ts +7 -0
  35. package/dist/antigravity-local.d.ts.map +1 -0
  36. package/dist/antigravity-local.js +153 -0
  37. package/dist/antigravity-local.js.map +1 -0
  38. package/dist/apme/adapters/claude-hook.d.ts +15 -0
  39. package/dist/apme/adapters/claude-hook.d.ts.map +1 -0
  40. package/dist/apme/adapters/claude-hook.js +85 -0
  41. package/dist/apme/adapters/claude-hook.js.map +1 -0
  42. package/dist/apme/adapters/claude-pty.d.ts +28 -0
  43. package/dist/apme/adapters/claude-pty.d.ts.map +1 -0
  44. package/dist/apme/adapters/claude-pty.js +88 -0
  45. package/dist/apme/adapters/claude-pty.js.map +1 -0
  46. package/dist/apme/adapters/timeline.d.ts +16 -0
  47. package/dist/apme/adapters/timeline.d.ts.map +1 -0
  48. package/dist/apme/adapters/timeline.js +66 -0
  49. package/dist/apme/adapters/timeline.js.map +1 -0
  50. package/dist/apme/classifier.d.ts +53 -0
  51. package/dist/apme/classifier.d.ts.map +1 -0
  52. package/dist/apme/classifier.js +266 -0
  53. package/dist/apme/classifier.js.map +1 -0
  54. package/dist/apme/claude-transcript-reader.d.ts +38 -0
  55. package/dist/apme/claude-transcript-reader.d.ts.map +1 -0
  56. package/dist/apme/claude-transcript-reader.js +127 -0
  57. package/dist/apme/claude-transcript-reader.js.map +1 -0
  58. package/dist/apme/collector.d.ts +108 -0
  59. package/dist/apme/collector.d.ts.map +1 -0
  60. package/dist/apme/collector.js +659 -0
  61. package/dist/apme/collector.js.map +1 -0
  62. package/dist/apme/dashboard-html.d.ts +9 -0
  63. package/dist/apme/dashboard-html.d.ts.map +1 -0
  64. package/dist/apme/dashboard-html.js +416 -0
  65. package/dist/apme/dashboard-html.js.map +1 -0
  66. package/dist/apme/http.d.ts +19 -0
  67. package/dist/apme/http.d.ts.map +1 -0
  68. package/dist/apme/http.js +210 -0
  69. package/dist/apme/http.js.map +1 -0
  70. package/dist/apme/hw-sampler.d.ts +30 -0
  71. package/dist/apme/hw-sampler.d.ts.map +1 -0
  72. package/dist/apme/hw-sampler.js +106 -0
  73. package/dist/apme/hw-sampler.js.map +1 -0
  74. package/dist/apme/index.d.ts +43 -0
  75. package/dist/apme/index.d.ts.map +1 -0
  76. package/dist/apme/index.js +75 -0
  77. package/dist/apme/index.js.map +1 -0
  78. package/dist/apme/outcome.d.ts +48 -0
  79. package/dist/apme/outcome.d.ts.map +1 -0
  80. package/dist/apme/outcome.js +232 -0
  81. package/dist/apme/outcome.js.map +1 -0
  82. package/dist/apme/recommend.d.ts +32 -0
  83. package/dist/apme/recommend.d.ts.map +1 -0
  84. package/dist/apme/recommend.js +41 -0
  85. package/dist/apme/recommend.js.map +1 -0
  86. package/dist/apme/runner.d.ts +94 -0
  87. package/dist/apme/runner.d.ts.map +1 -0
  88. package/dist/apme/runner.js +812 -0
  89. package/dist/apme/runner.js.map +1 -0
  90. package/dist/apme/settings.d.ts +65 -0
  91. package/dist/apme/settings.d.ts.map +1 -0
  92. package/dist/apme/settings.js +97 -0
  93. package/dist/apme/settings.js.map +1 -0
  94. package/dist/apme/store.d.ts +99 -0
  95. package/dist/apme/store.d.ts.map +1 -0
  96. package/dist/apme/store.js +911 -0
  97. package/dist/apme/store.js.map +1 -0
  98. package/dist/apme/tuner.d.ts +64 -0
  99. package/dist/apme/tuner.d.ts.map +1 -0
  100. package/dist/apme/tuner.js +297 -0
  101. package/dist/apme/tuner.js.map +1 -0
  102. package/dist/apme/types.d.ts +11 -0
  103. package/dist/apme/types.d.ts.map +1 -0
  104. package/dist/apme/types.js +10 -0
  105. package/dist/apme/types.js.map +1 -0
  106. package/dist/auth.d.ts +1 -1
  107. package/dist/auth.d.ts.map +1 -1
  108. package/dist/auth.js +15 -5
  109. package/dist/auth.js.map +1 -1
  110. package/dist/bridge-core.d.ts +162 -0
  111. package/dist/bridge-core.d.ts.map +1 -0
  112. package/dist/bridge-core.js +627 -0
  113. package/dist/bridge-core.js.map +1 -0
  114. package/dist/check-deps.d.ts +3 -1
  115. package/dist/check-deps.d.ts.map +1 -1
  116. package/dist/check-deps.js +34 -13
  117. package/dist/check-deps.js.map +1 -1
  118. package/dist/cli.d.ts +3 -0
  119. package/dist/cli.d.ts.map +1 -0
  120. package/dist/cli.js +1235 -0
  121. package/dist/cli.js.map +1 -0
  122. package/dist/codex-auth.d.ts +10 -0
  123. package/dist/codex-auth.d.ts.map +1 -0
  124. package/dist/codex-auth.js +63 -0
  125. package/dist/codex-auth.js.map +1 -0
  126. package/dist/codex-output-parser.d.ts +21 -0
  127. package/dist/codex-output-parser.d.ts.map +1 -0
  128. package/dist/codex-output-parser.js +253 -0
  129. package/dist/codex-output-parser.js.map +1 -0
  130. package/dist/d200h/hid-protocol.d.ts +62 -0
  131. package/dist/d200h/hid-protocol.d.ts.map +1 -0
  132. package/dist/d200h/hid-protocol.js +124 -0
  133. package/dist/d200h/hid-protocol.js.map +1 -0
  134. package/dist/d200h/image-renderer.d.ts +36 -0
  135. package/dist/d200h/image-renderer.d.ts.map +1 -0
  136. package/dist/d200h/image-renderer.js +487 -0
  137. package/dist/d200h/image-renderer.js.map +1 -0
  138. package/dist/daemon-server.d.ts +19 -0
  139. package/dist/daemon-server.d.ts.map +1 -0
  140. package/dist/daemon-server.js +1168 -0
  141. package/dist/daemon-server.js.map +1 -0
  142. package/dist/daemon-ws-client.d.ts +82 -0
  143. package/dist/daemon-ws-client.d.ts.map +1 -0
  144. package/dist/daemon-ws-client.js +179 -0
  145. package/dist/daemon-ws-client.js.map +1 -0
  146. package/dist/daemon.d.ts +10 -0
  147. package/dist/daemon.d.ts.map +1 -0
  148. package/dist/daemon.js +56 -0
  149. package/dist/daemon.js.map +1 -0
  150. package/dist/display-monitor.d.ts +25 -0
  151. package/dist/display-monitor.d.ts.map +1 -0
  152. package/dist/display-monitor.js +143 -0
  153. package/dist/display-monitor.js.map +1 -0
  154. package/dist/esp32-serial.d.ts +112 -0
  155. package/dist/esp32-serial.d.ts.map +1 -0
  156. package/dist/esp32-serial.js +502 -0
  157. package/dist/esp32-serial.js.map +1 -0
  158. package/dist/gateway-probe.d.ts +12 -0
  159. package/dist/gateway-probe.d.ts.map +1 -0
  160. package/dist/gateway-probe.js +39 -0
  161. package/dist/gateway-probe.js.map +1 -0
  162. package/dist/hook-server.d.ts +29 -0
  163. package/dist/hook-server.d.ts.map +1 -1
  164. package/dist/hook-server.js +366 -8
  165. package/dist/hook-server.js.map +1 -1
  166. package/dist/index.d.ts +20 -2
  167. package/dist/index.d.ts.map +1 -1
  168. package/dist/index.js +1305 -562
  169. package/dist/index.js.map +1 -1
  170. package/dist/log-stream.d.ts +26 -0
  171. package/dist/log-stream.d.ts.map +1 -0
  172. package/dist/log-stream.js +111 -0
  173. package/dist/log-stream.js.map +1 -0
  174. package/dist/logger.d.ts +9 -2
  175. package/dist/logger.d.ts.map +1 -1
  176. package/dist/logger.js +36 -5
  177. package/dist/logger.js.map +1 -1
  178. package/dist/mdns.d.ts +15 -1
  179. package/dist/mdns.d.ts.map +1 -1
  180. package/dist/mdns.js +143 -28
  181. package/dist/mdns.js.map +1 -1
  182. package/dist/mlx-probe.d.ts +14 -0
  183. package/dist/mlx-probe.d.ts.map +1 -0
  184. package/dist/mlx-probe.js +44 -0
  185. package/dist/mlx-probe.js.map +1 -0
  186. package/dist/model-catalog.d.ts +5 -3
  187. package/dist/model-catalog.d.ts.map +1 -1
  188. package/dist/model-catalog.js +45 -29
  189. package/dist/model-catalog.js.map +1 -1
  190. package/dist/modules/adb-module.d.ts +14 -0
  191. package/dist/modules/adb-module.d.ts.map +1 -0
  192. package/dist/modules/adb-module.js +36 -0
  193. package/dist/modules/adb-module.js.map +1 -0
  194. package/dist/modules/d200h-module.d.ts +43 -0
  195. package/dist/modules/d200h-module.d.ts.map +1 -0
  196. package/dist/modules/d200h-module.js +360 -0
  197. package/dist/modules/d200h-module.js.map +1 -0
  198. package/dist/modules/d200h-renderer.d.ts +7 -0
  199. package/dist/modules/d200h-renderer.d.ts.map +1 -0
  200. package/dist/modules/d200h-renderer.js +321 -0
  201. package/dist/modules/d200h-renderer.js.map +1 -0
  202. package/dist/modules/index.d.ts +23 -0
  203. package/dist/modules/index.d.ts.map +1 -0
  204. package/dist/modules/index.js +65 -0
  205. package/dist/modules/index.js.map +1 -0
  206. package/dist/modules/mdns-module.d.ts +12 -0
  207. package/dist/modules/mdns-module.d.ts.map +1 -0
  208. package/dist/modules/mdns-module.js +21 -0
  209. package/dist/modules/mdns-module.js.map +1 -0
  210. package/dist/modules/pixoo-module.d.ts +8 -0
  211. package/dist/modules/pixoo-module.d.ts.map +1 -0
  212. package/dist/modules/pixoo-module.js +24 -0
  213. package/dist/modules/pixoo-module.js.map +1 -0
  214. package/dist/modules/serial-module.d.ts +18 -0
  215. package/dist/modules/serial-module.d.ts.map +1 -0
  216. package/dist/modules/serial-module.js +47 -0
  217. package/dist/modules/serial-module.js.map +1 -0
  218. package/dist/modules/types.d.ts +39 -0
  219. package/dist/modules/types.d.ts.map +1 -0
  220. package/dist/modules/types.js +2 -0
  221. package/dist/modules/types.js.map +1 -0
  222. package/dist/ollama-probe.d.ts +13 -0
  223. package/dist/ollama-probe.d.ts.map +1 -0
  224. package/dist/ollama-probe.js +46 -0
  225. package/dist/ollama-probe.js.map +1 -0
  226. package/dist/opencode-client.d.ts +126 -0
  227. package/dist/opencode-client.d.ts.map +1 -0
  228. package/dist/opencode-client.js +181 -0
  229. package/dist/opencode-client.js.map +1 -0
  230. package/dist/output-parser.d.ts +3 -0
  231. package/dist/output-parser.d.ts.map +1 -1
  232. package/dist/output-parser.js +29 -3
  233. package/dist/output-parser.js.map +1 -1
  234. package/dist/passive-observer.d.ts +38 -0
  235. package/dist/passive-observer.d.ts.map +1 -0
  236. package/dist/passive-observer.js +632 -0
  237. package/dist/passive-observer.js.map +1 -0
  238. package/dist/pixoo/pixoo-bridge.d.ts +50 -0
  239. package/dist/pixoo/pixoo-bridge.d.ts.map +1 -0
  240. package/dist/pixoo/pixoo-bridge.js +280 -0
  241. package/dist/pixoo/pixoo-bridge.js.map +1 -0
  242. package/dist/pixoo/pixoo-camera.d.ts +80 -0
  243. package/dist/pixoo/pixoo-camera.d.ts.map +1 -0
  244. package/dist/pixoo/pixoo-camera.js +271 -0
  245. package/dist/pixoo/pixoo-camera.js.map +1 -0
  246. package/dist/pixoo/pixoo-client.d.ts +67 -0
  247. package/dist/pixoo/pixoo-client.d.ts.map +1 -0
  248. package/dist/pixoo/pixoo-client.js +335 -0
  249. package/dist/pixoo/pixoo-client.js.map +1 -0
  250. package/dist/pixoo/pixoo-font.d.ts +24 -0
  251. package/dist/pixoo/pixoo-font.d.ts.map +1 -0
  252. package/dist/pixoo/pixoo-font.js +121 -0
  253. package/dist/pixoo/pixoo-font.js.map +1 -0
  254. package/dist/pixoo/pixoo-renderer.d.ts +32 -0
  255. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -0
  256. package/dist/pixoo/pixoo-renderer.js +696 -0
  257. package/dist/pixoo/pixoo-renderer.js.map +1 -0
  258. package/dist/pixoo/pixoo-settings.d.ts +10 -0
  259. package/dist/pixoo/pixoo-settings.d.ts.map +1 -0
  260. package/dist/pixoo/pixoo-settings.js +47 -0
  261. package/dist/pixoo/pixoo-settings.js.map +1 -0
  262. package/dist/pixoo/pixoo-sprites.d.ts +187 -0
  263. package/dist/pixoo/pixoo-sprites.d.ts.map +1 -0
  264. package/dist/pixoo/pixoo-sprites.js +926 -0
  265. package/dist/pixoo/pixoo-sprites.js.map +1 -0
  266. package/dist/pty-manager.d.ts +1 -1
  267. package/dist/pty-manager.d.ts.map +1 -1
  268. package/dist/pty-manager.js +36 -15
  269. package/dist/pty-manager.js.map +1 -1
  270. package/dist/session-aggregator.d.ts +41 -0
  271. package/dist/session-aggregator.d.ts.map +1 -0
  272. package/dist/session-aggregator.js +76 -0
  273. package/dist/session-aggregator.js.map +1 -0
  274. package/dist/session-focus-relay.d.ts +35 -0
  275. package/dist/session-focus-relay.d.ts.map +1 -0
  276. package/dist/session-focus-relay.js +127 -0
  277. package/dist/session-focus-relay.js.map +1 -0
  278. package/dist/session-registry.d.ts +88 -1
  279. package/dist/session-registry.d.ts.map +1 -1
  280. package/dist/session-registry.js +222 -15
  281. package/dist/session-registry.js.map +1 -1
  282. package/dist/session-timeline-relay.d.ts +27 -0
  283. package/dist/session-timeline-relay.d.ts.map +1 -0
  284. package/dist/session-timeline-relay.js +124 -0
  285. package/dist/session-timeline-relay.js.map +1 -0
  286. package/dist/state-machine.d.ts +1 -0
  287. package/dist/state-machine.d.ts.map +1 -1
  288. package/dist/state-machine.js +14 -0
  289. package/dist/state-machine.js.map +1 -1
  290. package/dist/terminal-status.d.ts +28 -0
  291. package/dist/terminal-status.d.ts.map +1 -0
  292. package/dist/terminal-status.js +152 -0
  293. package/dist/terminal-status.js.map +1 -0
  294. package/dist/timeline-store.d.ts +22 -0
  295. package/dist/timeline-store.d.ts.map +1 -0
  296. package/dist/timeline-store.js +79 -0
  297. package/dist/timeline-store.js.map +1 -0
  298. package/dist/timeline-summarizer.d.ts +14 -0
  299. package/dist/timeline-summarizer.d.ts.map +1 -0
  300. package/dist/timeline-summarizer.js +154 -0
  301. package/dist/timeline-summarizer.js.map +1 -0
  302. package/dist/tts.d.ts +25 -0
  303. package/dist/tts.d.ts.map +1 -0
  304. package/dist/tts.js +99 -0
  305. package/dist/tts.js.map +1 -0
  306. package/dist/tui/ansi.d.ts +92 -0
  307. package/dist/tui/ansi.d.ts.map +1 -0
  308. package/dist/tui/ansi.js +181 -0
  309. package/dist/tui/ansi.js.map +1 -0
  310. package/dist/tui/dashboard.d.ts +35 -0
  311. package/dist/tui/dashboard.d.ts.map +1 -0
  312. package/dist/tui/dashboard.js +436 -0
  313. package/dist/tui/dashboard.js.map +1 -0
  314. package/dist/tui/gauge.d.ts +15 -0
  315. package/dist/tui/gauge.d.ts.map +1 -0
  316. package/dist/tui/gauge.js +82 -0
  317. package/dist/tui/gauge.js.map +1 -0
  318. package/dist/tui/renderer.d.ts +15 -0
  319. package/dist/tui/renderer.d.ts.map +1 -0
  320. package/dist/tui/renderer.js +758 -0
  321. package/dist/tui/renderer.js.map +1 -0
  322. package/dist/tui/screen.d.ts +27 -0
  323. package/dist/tui/screen.d.ts.map +1 -0
  324. package/dist/tui/screen.js +109 -0
  325. package/dist/tui/screen.js.map +1 -0
  326. package/dist/tui/terrarium.d.ts +96 -0
  327. package/dist/tui/terrarium.d.ts.map +1 -0
  328. package/dist/tui/terrarium.js +912 -0
  329. package/dist/tui/terrarium.js.map +1 -0
  330. package/dist/types.d.ts +4 -2
  331. package/dist/types.d.ts.map +1 -1
  332. package/dist/types.js +3 -2
  333. package/dist/types.js.map +1 -1
  334. package/dist/usage-api.d.ts +9 -2
  335. package/dist/usage-api.d.ts.map +1 -1
  336. package/dist/usage-api.js +224 -42
  337. package/dist/usage-api.js.map +1 -1
  338. package/dist/usage-event.d.ts +12 -0
  339. package/dist/usage-event.d.ts.map +1 -0
  340. package/dist/usage-event.js +78 -0
  341. package/dist/usage-event.js.map +1 -0
  342. package/dist/usage-tracker.d.ts +4 -0
  343. package/dist/usage-tracker.d.ts.map +1 -1
  344. package/dist/usage-tracker.js +17 -0
  345. package/dist/usage-tracker.js.map +1 -1
  346. package/dist/utility-proxy.d.ts +30 -0
  347. package/dist/utility-proxy.d.ts.map +1 -0
  348. package/dist/utility-proxy.js +217 -0
  349. package/dist/utility-proxy.js.map +1 -0
  350. package/dist/utils/project-name.d.ts +22 -0
  351. package/dist/utils/project-name.d.ts.map +1 -0
  352. package/dist/utils/project-name.js +72 -0
  353. package/dist/utils/project-name.js.map +1 -0
  354. package/dist/version-check.d.ts +26 -0
  355. package/dist/version-check.d.ts.map +1 -0
  356. package/dist/version-check.js +225 -0
  357. package/dist/version-check.js.map +1 -0
  358. package/dist/voice-assistant.d.ts +63 -0
  359. package/dist/voice-assistant.d.ts.map +1 -0
  360. package/dist/voice-assistant.js +406 -0
  361. package/dist/voice-assistant.js.map +1 -0
  362. package/dist/voice.d.ts +2 -0
  363. package/dist/voice.d.ts.map +1 -1
  364. package/dist/voice.js +26 -1
  365. package/dist/voice.js.map +1 -1
  366. package/dist/wake-word.d.ts +35 -0
  367. package/dist/wake-word.d.ts.map +1 -0
  368. package/dist/wake-word.js +225 -0
  369. package/dist/wake-word.js.map +1 -0
  370. package/dist/whisper-server-manager.d.ts +1 -1
  371. package/dist/whisper-server-manager.js +1 -1
  372. package/dist/wifi-config.d.ts +24 -0
  373. package/dist/wifi-config.d.ts.map +1 -0
  374. package/dist/wifi-config.js +84 -0
  375. package/dist/wifi-config.js.map +1 -0
  376. package/dist/ws-server.d.ts +12 -0
  377. package/dist/ws-server.d.ts.map +1 -1
  378. package/dist/ws-server.js +81 -3
  379. package/dist/ws-server.js.map +1 -1
  380. package/package.json +22 -13
@@ -0,0 +1,1168 @@
1
+ /**
2
+ * AgentDeck Daemon — lightweight monitoring server.
3
+ *
4
+ * No PTY, no voice, no utility. Provides:
5
+ * - WS server for display clients
6
+ * - mDNS advertisement
7
+ * - OpenClaw Gateway proxy
8
+ * - Usage relay (sibling HTTP → WS → direct API)
9
+ * - Pixoo + ADB + Serial device modules
10
+ *
11
+ * Exports `startDaemon()` called by cli.ts.
12
+ */
13
+ import { createServer } from 'http';
14
+ import WebSocket from 'ws';
15
+ import { BridgeCore } from './bridge-core.js';
16
+ import { OpenClawAdapter } from './adapters/openclaw.js';
17
+ import { BridgeLogStream } from './log-stream.js';
18
+ import { PassiveSessionObserver } from './passive-observer.js';
19
+ import { SessionTimelineRelay } from './session-timeline-relay.js';
20
+ import { SessionFocusRelay } from './session-focus-relay.js';
21
+ import { updatePushState } from './session-aggregator.js';
22
+ import { VoiceManager } from './voice.js';
23
+ import { VoiceAssistantManager } from './voice-assistant.js';
24
+ import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, writeDaemonInfo, removeDaemonInfo, readDaemonInfo, } from './session-registry.js';
25
+ import { fetchUsageFromApi, hasOAuthToken, resetConsecutiveFailures } from './usage-api.js';
26
+ import { isLocalConnection, validateToken } from './auth.js';
27
+ import { getLastFrame, renderPreviewFrame, onFrameRendered, offFrameRendered } from './pixoo/pixoo-bridge.js';
28
+ import { handlePixooWake } from './pixoo/pixoo-client.js';
29
+ import { triggerMdnsRecovery } from './mdns.js';
30
+ import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
31
+ import { enableDebugLog, debug } from './logger.js';
32
+ import { initApme } from './apme/index.js';
33
+ import { handleApmeRequest } from './apme/http.js';
34
+ import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
35
+ import { esp32ConnectionCount, getESP32DeviceInfo, onESP32Message, sendWifiProvisionToAll, handleESP32Wake } from './esp32-serial.js';
36
+ import { loadWifiConfig } from './wifi-config.js';
37
+ import { getConnectedAdbDevices, hasAdb } from './adb-reverse.js';
38
+ import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js';
39
+ import { getLanIp } from '@agentdeck/shared';
40
+ import { readFileSync } from 'fs';
41
+ import { join } from 'path';
42
+ import { homedir } from 'os';
43
+ import { OPENCLAW_CAPABILITIES, State, } from './types.js';
44
+ function loadDaemonSettings() {
45
+ try {
46
+ return JSON.parse(readFileSync(join(homedir(), '.agentdeck', 'settings.json'), 'utf-8'));
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ }
52
+ function log(msg) {
53
+ process.stderr.write(msg + '\n');
54
+ }
55
+ async function fetchUsageViaHttp(siblings) {
56
+ for (const sibling of siblings) {
57
+ try {
58
+ const controller = new AbortController();
59
+ const timeout = setTimeout(() => controller.abort(), 2000);
60
+ const res = await fetch(`http://127.0.0.1:${sibling.port}/usage`, { signal: controller.signal });
61
+ clearTimeout(timeout);
62
+ if (!res.ok)
63
+ continue;
64
+ const data = await res.json();
65
+ if (!data.usage)
66
+ continue;
67
+ const age = Date.now() - data.fetchedAt;
68
+ if (age > 5 * 60 * 1000)
69
+ continue;
70
+ return { usage: data.usage, fetchedAt: data.fetchedAt };
71
+ }
72
+ catch { /* try next */ }
73
+ }
74
+ return null;
75
+ }
76
+ async function fetchUsageViaWs(siblings) {
77
+ for (const sibling of siblings) {
78
+ try {
79
+ const usage = await new Promise((resolve, reject) => {
80
+ const ws = new WebSocket(`ws://127.0.0.1:${sibling.port}`);
81
+ const timer = setTimeout(() => { ws.close(); reject(new Error('timeout')); }, 3000);
82
+ ws.on('message', (raw) => {
83
+ try {
84
+ const evt = JSON.parse(raw.toString());
85
+ if (evt.type === 'usage_update' && evt.fiveHourPercent != null) {
86
+ clearTimeout(timer);
87
+ ws.close();
88
+ resolve({
89
+ fiveHourPercent: evt.fiveHourPercent ?? null,
90
+ fiveHourResetsAt: evt.fiveHourResetsAt ?? null,
91
+ sevenDayPercent: evt.sevenDayPercent ?? null,
92
+ sevenDayResetsAt: evt.sevenDayResetsAt ?? null,
93
+ extraUsageEnabled: evt.extraUsageEnabled ?? false,
94
+ extraUsageMonthlyLimit: evt.extraUsageMonthlyLimit ?? null,
95
+ extraUsageUsedCredits: evt.extraUsageUsedCredits ?? null,
96
+ extraUsageUtilization: evt.extraUsageUtilization ?? null,
97
+ inferredBillingType: null,
98
+ });
99
+ }
100
+ }
101
+ catch { /* ignore */ }
102
+ });
103
+ ws.on('error', () => { clearTimeout(timer); reject(new Error('ws error')); });
104
+ ws.on('close', () => { clearTimeout(timer); reject(new Error('ws closed')); });
105
+ });
106
+ if (usage)
107
+ return usage;
108
+ }
109
+ catch { /* try next */ }
110
+ }
111
+ return null;
112
+ }
113
+ async function fetchUsageRelayed(selfPort) {
114
+ const sessions = listActiveSessions();
115
+ const siblings = sessions.filter(s => s.port !== selfPort && s.agentType !== 'daemon');
116
+ if (siblings.length > 0) {
117
+ const httpResult = await fetchUsageViaHttp(siblings);
118
+ if (httpResult)
119
+ return httpResult.usage;
120
+ const wsResult = await fetchUsageViaWs(siblings);
121
+ if (wsResult)
122
+ return wsResult;
123
+ debug('daemon', 'Siblings exist but relay failed — skipping direct API');
124
+ return null;
125
+ }
126
+ debug('daemon', 'No siblings, using direct API');
127
+ return fetchUsageFromApi();
128
+ }
129
+ function buildNodeModuleHealth(startedModules) {
130
+ const started = new Set(startedModules.map((m) => m.name));
131
+ const modules = {};
132
+ if (started.has('adb')) {
133
+ const adbAvailable = hasAdb();
134
+ const devices = adbAvailable ? getConnectedAdbDevices() : [];
135
+ modules.adb = {
136
+ available: adbAvailable,
137
+ devices,
138
+ classifiedDevices: [],
139
+ reverseReadyCount: devices.length,
140
+ lastError: adbAvailable ? null : 'adb not found',
141
+ };
142
+ }
143
+ const d200h = startedModules.find((m) => m.name === 'd200h');
144
+ if (d200h?.statusSnapshot) {
145
+ modules.d200h = d200h.statusSnapshot();
146
+ }
147
+ if (started.has('pixoo') || pixooDeviceCount() > 0) {
148
+ const details = getPixooDeviceDetails();
149
+ modules.pixoo = {
150
+ configuredDeviceCount: pixooDeviceCount(),
151
+ deviceIps: details.map((d) => d.ip),
152
+ hasFrame: true,
153
+ displayDimmed: false,
154
+ devices: details.map((d) => ({
155
+ ip: d.ip,
156
+ name: d.name,
157
+ online: !d.backedOff,
158
+ failures: d.failures,
159
+ backedOff: d.backedOff,
160
+ })),
161
+ };
162
+ }
163
+ if (started.has('serial')) {
164
+ const connections = getESP32DeviceInfo().map((info) => ({
165
+ port: info.port,
166
+ connected: true,
167
+ deviceInfo: {
168
+ board: info.board,
169
+ version: info.version,
170
+ wifiConfigured: info.wifiConfigured,
171
+ wifiConnected: info.wifiConnected,
172
+ },
173
+ }));
174
+ modules.serial = {
175
+ connectedPorts: connections.map((c) => c.port),
176
+ connections,
177
+ lastError: null,
178
+ };
179
+ }
180
+ return modules;
181
+ }
182
+ // ===== startDaemon =====
183
+ export async function startDaemon(opts) {
184
+ if (opts.debug) {
185
+ enableDebugLog('/tmp/agentdeck-debug.log');
186
+ log('[agentdeck] Debug logging enabled');
187
+ }
188
+ // CLI --wake-word flag OR settings.json wakeWord: true
189
+ const settings = loadDaemonSettings();
190
+ const wakeWordEnabled = opts.wakeWord || settings.wakeWord === true;
191
+ // ===== Singleton guard + port allocation =====
192
+ // 1. Check daemon.json and sessions.json for existing daemon
193
+ const existingInfo = readDaemonInfo();
194
+ if (existingInfo) {
195
+ log(`[agentdeck] Daemon already running on port ${existingInfo.port} (PID ${existingInfo.pid}).`);
196
+ process.exit(0);
197
+ }
198
+ const existingSession = findExistingDaemon();
199
+ if (existingSession) {
200
+ log(`[agentdeck] Daemon already running on port ${existingSession.port} (PID ${existingSession.pid}).`);
201
+ process.exit(0);
202
+ }
203
+ // 2. Determine port — try default first, fallback if occupied by non-daemon
204
+ const requestedPort = opts.port ?? DAEMON_DEFAULT_PORT;
205
+ let port = requestedPort;
206
+ // If using default port, check if it's available
207
+ if (requestedPort === DAEMON_DEFAULT_PORT) {
208
+ const health = await probeDaemonHealth(requestedPort);
209
+ if (health) {
210
+ if (health.mode === 'daemon') {
211
+ // Daemon alive but not in our registry — race condition or stale state
212
+ log(`[agentdeck] Daemon already running on port ${requestedPort} (detected via /health).`);
213
+ process.exit(0);
214
+ }
215
+ // Port occupied by non-daemon (e.g. session bridge) — find alternative
216
+ log(`[agentdeck] Port ${requestedPort} occupied (${health.mode ?? 'unknown'}), finding alternative...`);
217
+ port = await findAvailablePort();
218
+ }
219
+ }
220
+ log(`[agentdeck] Starting daemon on port ${port}...`);
221
+ // ===== APME (lazy — may be null if better-sqlite3 isn't installed) =====
222
+ let apme = null;
223
+ // Declare early — HTTP /health handler references this in its closure.
224
+ // Must be declared before the HTTP server so it's initialized (not in TDZ)
225
+ // when the first /health request arrives.
226
+ let gatewayAdapter = null;
227
+ let gatewayConnecting = false;
228
+ let moduleHealthProvider = () => ({});
229
+ // ===== HTTP server =====
230
+ const httpServer = createServer((req, res) => {
231
+ // APME routes: auth-gated (task prompts, project paths, hook payloads are sensitive).
232
+ if ((req.url ?? '').startsWith('/apme')) {
233
+ const ip = req.socket.remoteAddress ?? '';
234
+ if (!isLocalConnection(ip)) {
235
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
236
+ const token = url.searchParams.get('token') ?? '';
237
+ if (!validateToken(token)) {
238
+ res.writeHead(401, { 'Content-Type': 'application/json' });
239
+ res.end(JSON.stringify({ error: 'Unauthorized — token required for APME routes' }));
240
+ return;
241
+ }
242
+ }
243
+ void handleApmeRequest(req, res, apme).catch((err) => {
244
+ try {
245
+ res.writeHead(500, { 'Content-Type': 'application/json' });
246
+ res.end(JSON.stringify({ error: String(err) }));
247
+ }
248
+ catch { /* ignore */ }
249
+ });
250
+ return;
251
+ }
252
+ const pathname = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname;
253
+ // Health check is public (no auth) — used by iOS/Android for pairing token discovery
254
+ if (req.method === 'GET' && pathname === '/health') {
255
+ const snap = core.stateMachine.getSnapshot();
256
+ res.writeHead(200, { 'Content-Type': 'application/json' });
257
+ res.end(JSON.stringify({
258
+ status: 'ok', mode: 'daemon', state: snap.state,
259
+ gateway: gatewayAdapter?.isAlive() ? 'connected' : 'disconnected',
260
+ uptime: process.uptime(), port,
261
+ pairingToken: core.authToken,
262
+ modules: moduleHealthProvider(),
263
+ }));
264
+ return;
265
+ }
266
+ if (req.method === 'GET' && pathname === '/status') {
267
+ const snap = core.stateMachine.getSnapshot();
268
+ res.writeHead(200, { 'Content-Type': 'application/json' });
269
+ res.end(JSON.stringify({
270
+ status: 'ok',
271
+ state: snap.state,
272
+ daemon: { port, pid: process.pid },
273
+ gateway: {
274
+ available: core.cachedGatewayAvailable,
275
+ connected: core.cachedGatewayConnected,
276
+ hasError: core.cachedGatewayHasError,
277
+ },
278
+ clients: core.wsServer.getClientCount(),
279
+ modules: moduleHealthProvider(),
280
+ }));
281
+ return;
282
+ }
283
+ if (req.method === 'GET' && pathname === '/devices') {
284
+ res.writeHead(200, { 'Content-Type': 'application/json' });
285
+ res.end(JSON.stringify({ devices: [], modules: moduleHealthProvider() }));
286
+ return;
287
+ }
288
+ if (req.method === 'GET' && pathname === '/pixoo/stream') {
289
+ res.writeHead(200, {
290
+ 'Content-Type': 'text/event-stream',
291
+ 'Cache-Control': 'no-cache',
292
+ 'Connection': 'keep-alive',
293
+ 'Access-Control-Allow-Origin': '*',
294
+ });
295
+ res.on('error', () => { }); // Prevent unhandled stream error on client disconnect
296
+ const listener = (frame) => {
297
+ const bmp = rgbToBmp(frame, 64, 64);
298
+ const b64 = bmp.toString('base64');
299
+ try {
300
+ res.write(`event: frame\ndata: ${b64}\n\n`);
301
+ }
302
+ catch { /* client gone */ }
303
+ };
304
+ onFrameRendered(listener);
305
+ // Send current frame immediately
306
+ const current = getLastFrame() ?? renderPreviewFrame();
307
+ listener(current);
308
+ // Heartbeat
309
+ const heartbeat = setInterval(() => {
310
+ try {
311
+ res.write(':heartbeat\n\n');
312
+ }
313
+ catch { /* */ }
314
+ }, 30_000);
315
+ req.on('close', () => {
316
+ offFrameRendered(listener);
317
+ clearInterval(heartbeat);
318
+ });
319
+ return;
320
+ }
321
+ if (req.method === 'GET' && pathname === '/pixoo/frame') {
322
+ const rgb = getLastFrame() ?? renderPreviewFrame();
323
+ const bmp = rgbToBmp(rgb, 64, 64);
324
+ res.writeHead(200, {
325
+ 'Content-Type': 'image/bmp',
326
+ 'Cache-Control': 'no-store',
327
+ 'Access-Control-Allow-Origin': '*',
328
+ });
329
+ res.end(bmp);
330
+ return;
331
+ }
332
+ if (req.method === 'GET' && pathname === '/pixoo') {
333
+ res.writeHead(200, { 'Content-Type': 'text/html' });
334
+ res.end(pixooLiveHtml({ projectName: 'AgentDeck' }));
335
+ return;
336
+ }
337
+ if (req.method === 'GET' && pathname === '/sse') {
338
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
339
+ res.on('error', () => { }); // Prevent unhandled stream error on client disconnect
340
+ res.write(`event: connected\ndata: {}\n\n`);
341
+ req.on('close', () => { });
342
+ return;
343
+ }
344
+ // Hook endpoint — receives Claude Code hook POSTs at /hooks/:eventName.
345
+ // Routes through APME collector the same way session bridge's hook-server does.
346
+ if (req.method === 'POST' && pathname.startsWith('/hooks/')) {
347
+ const eventName = pathname.slice('/hooks/'.length);
348
+ let body = '';
349
+ req.on('data', (c) => { body += c; if (body.length > 1_000_000)
350
+ req.destroy(); });
351
+ req.on('end', () => {
352
+ let json = {};
353
+ try {
354
+ json = body ? JSON.parse(body) : {};
355
+ }
356
+ catch { /* ignore */ }
357
+ // Map PascalCase event names to snake_case for state machine + APME
358
+ const eventMap = {
359
+ SessionStart: 'session_start', SessionEnd: 'session_end',
360
+ PreToolUse: 'tool_start', PostToolUse: 'tool_end',
361
+ Stop: 'stop', UserPromptSubmit: 'user_prompt_submit',
362
+ Notification: 'notification',
363
+ };
364
+ const mapped = eventMap[eventName] ?? eventName;
365
+ // State machine
366
+ if (mapped === 'session_start')
367
+ core.stateMachine.handleHookEvent('SessionStart', json);
368
+ else if (mapped === 'session_end')
369
+ core.stateMachine.handleHookEvent('SessionEnd', json);
370
+ else if (mapped === 'user_prompt_submit')
371
+ core.stateMachine.handleHookEvent('UserPromptSubmit', json);
372
+ else if (mapped === 'stop')
373
+ core.stateMachine.handleHookEvent('Stop', json);
374
+ else if (mapped === 'tool_start') {
375
+ core.stateMachine.handleHookEvent('PreToolUse', json);
376
+ }
377
+ else if (mapped === 'tool_end') {
378
+ core.stateMachine.handleHookEvent('PostToolUse', json);
379
+ }
380
+ // APME collector
381
+ if (apme) {
382
+ // Use a stable "hook session" for the daemon — hooks from direct `claude` runs
383
+ // don't have AGENTDECK_PORT, so they all come here.
384
+ const hookSessionId = 'daemon-hook';
385
+ if (mapped === 'session_start') {
386
+ // Extract prompt source from message.content (Claude v2.1+) or prompt field
387
+ apme.collector.openRun({
388
+ sessionId: hookSessionId,
389
+ agentType: 'claude-code',
390
+ projectName: json.project_name ?? undefined,
391
+ projectPath: json.project_path ?? undefined,
392
+ });
393
+ }
394
+ apme.collector.ingestHook(hookSessionId, mapped, json);
395
+ if (mapped === 'session_end') {
396
+ apme.collector.closeRun(hookSessionId);
397
+ }
398
+ }
399
+ });
400
+ res.writeHead(200, { 'Content-Type': 'application/json' });
401
+ res.end(JSON.stringify({ received: true }));
402
+ return;
403
+ }
404
+ if (req.method === 'POST' && pathname === '/shutdown') {
405
+ res.writeHead(200, { 'Content-Type': 'application/json' });
406
+ res.end(JSON.stringify({ status: 'shutting_down' }));
407
+ core.shutdown();
408
+ return;
409
+ }
410
+ res.writeHead(404, { 'Content-Type': 'application/json' });
411
+ res.end(JSON.stringify({ error: 'Not found' }));
412
+ });
413
+ // Catch HTTP-level client errors (malformed requests, abrupt disconnects during upgrade)
414
+ httpServer.on('clientError', (err, socket) => {
415
+ debug('daemon', `HTTP client error: ${err.message}`);
416
+ if (!socket.destroyed)
417
+ socket.destroy();
418
+ });
419
+ await new Promise((resolve, reject) => {
420
+ httpServer.on('error', (err) => {
421
+ if (err.code === 'EADDRINUSE') {
422
+ // Port was grabbed between our check and bind — find alternative
423
+ reject(new Error(`EADDRINUSE:${port}`));
424
+ }
425
+ else {
426
+ reject(err);
427
+ }
428
+ });
429
+ httpServer.listen(port, '0.0.0.0', () => resolve());
430
+ }).catch(async (err) => {
431
+ // Handle race condition: port became unavailable after probe
432
+ if (err.message.startsWith('EADDRINUSE:') && port === requestedPort) {
433
+ port = await findAvailablePort();
434
+ log(`[agentdeck] Port ${requestedPort} grabbed, retrying on ${port}...`);
435
+ await new Promise((resolve, reject) => {
436
+ httpServer.on('error', (e) => reject(e));
437
+ httpServer.listen(port, '0.0.0.0', () => resolve());
438
+ });
439
+ }
440
+ else {
441
+ throw err;
442
+ }
443
+ });
444
+ // Write daemon.json for client discovery (must be after successful bind)
445
+ writeDaemonInfo({ port, pid: process.pid, startedAt: new Date().toISOString() });
446
+ // ===== BridgeCore =====
447
+ const core = new BridgeCore({
448
+ port,
449
+ projectName: 'AgentDeck',
450
+ httpServer,
451
+ });
452
+ // Timeline
453
+ const bridgeLogStream = new BridgeLogStream();
454
+ core.wireTimeline(bridgeLogStream);
455
+ core.wireDisplayMonitor();
456
+ // System wake recovery — re-publish mDNS, reconnect devices, refresh usage
457
+ core.onSystemWake(() => {
458
+ log('[daemon] System wake detected — recovering devices');
459
+ triggerMdnsRecovery();
460
+ handleESP32Wake();
461
+ handlePixooWake();
462
+ // Reset backoff from pre-sleep failures and fetch fresh usage after network stabilizes
463
+ resetConsecutiveFailures();
464
+ setTimeout(() => {
465
+ fetchUsageRelayed(port).then((usage) => {
466
+ if (usage)
467
+ core.updateApiUsage(usage);
468
+ else {
469
+ core.oauthConnected = hasOAuthToken();
470
+ if (core.cachedApiUsage)
471
+ core.apiUsageStale = true;
472
+ }
473
+ });
474
+ }, 4000);
475
+ });
476
+ // Subscribe to sibling session bridges' timelines + modelCatalog relay
477
+ const timelineRelay = new SessionTimelineRelay(port, core.bridgeTimeline);
478
+ timelineRelay.setOnModelCatalog((models) => {
479
+ // Merge modelCatalog from sibling Claude Code sessions (daemon doesn't run PTY).
480
+ // Gateway may also set catalog — merge both, dedup by key.
481
+ const existing = core.cachedModelCatalog ?? [];
482
+ const existingKeys = new Set(existing.map(m => m.key));
483
+ const merged = [...existing];
484
+ for (const m of models) {
485
+ if (!existingKeys.has(m.key)) {
486
+ merged.push(m);
487
+ existingKeys.add(m.key);
488
+ }
489
+ }
490
+ if (merged.length !== existing.length) {
491
+ core.cachedModelCatalog = merged;
492
+ debug('daemon', `Model catalog merged from sibling: ${merged.length} models total`);
493
+ const snap = core.stateMachine.getSnapshot();
494
+ const stateEvent = core.buildStateEvent({
495
+ agentType: gatewayAdapter?.isAlive() ? 'openclaw' : 'daemon',
496
+ agentCapabilities: gatewayAdapter?.isAlive() ? OPENCLAW_CAPABILITIES : undefined,
497
+ snapshot: snap,
498
+ });
499
+ lastStateEvent = stateEvent;
500
+ core.broadcast(stateEvent);
501
+ core.broadcastUsage();
502
+ }
503
+ });
504
+ timelineRelay.start();
505
+ // Session focus relay — allows SD plugin to interact with a specific session via daemon
506
+ const focusRelay = new SessionFocusRelay();
507
+ focusRelay.setEventHandler((evt) => {
508
+ if (evt.type === 'state_update') {
509
+ // Merge daemon metadata into the session's state_update
510
+ const merged = {
511
+ ...evt,
512
+ sessionId: focusRelay.getFocusedSessionId(),
513
+ modelCatalog: evt.modelCatalog ?? core.cachedModelCatalog,
514
+ gatewayAvailable: core.cachedGatewayAvailable,
515
+ gatewayConnected: core.cachedGatewayConnected,
516
+ ollamaStatus: core.cachedOllamaStatus,
517
+ gatewayHasError: evt.gatewayHasError ?? core.cachedGatewayHasError,
518
+ moduleHealth: moduleHealthProvider(),
519
+ };
520
+ lastStateEvent = merged;
521
+ core.wsServer.broadcast(merged);
522
+ }
523
+ else if (evt.type === 'usage_update') {
524
+ // Sync daemon cache with relay's already-adjusted values (prevents oscillation)
525
+ const u = evt;
526
+ if (core.cachedApiUsage && u.fiveHourPercent != null) {
527
+ core.cachedApiUsage.fiveHourPercent = u.fiveHourPercent;
528
+ core.cachedApiUsage.fiveHourResetsAt = u.fiveHourResetsAt ?? null;
529
+ core.cachedApiUsage.sevenDayPercent = u.sevenDayPercent ?? core.cachedApiUsage.sevenDayPercent;
530
+ core.cachedApiUsage.sevenDayResetsAt = u.sevenDayResetsAt ?? core.cachedApiUsage.sevenDayResetsAt ?? null;
531
+ core.apiUsagePreAdjusted = true;
532
+ }
533
+ core.wsServer.broadcast(evt);
534
+ }
535
+ else {
536
+ // prompt_options — relay as-is
537
+ core.wsServer.broadcast(evt);
538
+ }
539
+ });
540
+ // mDNS + device modules
541
+ const deviceModules = createDefaultModules('daemon');
542
+ const startedModules = await initModules(deviceModules, { mdns: true, adb: 'auto', serial: 'auto', pixoo: 'auto', d200h: 'auto' }, { port, authToken: core.authToken, projectName: 'AgentDeck', wsServer: core.wsServer });
543
+ moduleHealthProvider = () => buildNodeModuleHealth(startedModules);
544
+ core.setModuleHealthProvider(moduleHealthProvider);
545
+ // Serial module state provider (heartbeat needs cached state)
546
+ let lastStateEvent = null;
547
+ const serialModule = startedModules.find(m => m.name === 'serial');
548
+ if (serialModule) {
549
+ serialModule.setStateProvider(() => lastStateEvent);
550
+ serialModule.setUsageProvider(() => core.buildUsage());
551
+ // Send full state (state + usage + sessions) when new ESP32 device connects
552
+ serialModule.setInitialStateProvider(() => {
553
+ const events = [];
554
+ if (lastStateEvent)
555
+ events.push(lastStateEvent);
556
+ events.push(core.buildUsage());
557
+ events.push({ type: 'display_state', displayOn: core.displayMonitor.isDisplayOn() });
558
+ // Sessions list (async enrichment runs synchronously from cache here)
559
+ core.broadcastSessionsList().catch(() => { });
560
+ return events;
561
+ });
562
+ // Include ESP32 serial connections in client count for polling guards
563
+ core.setExternalClientCountProvider(() => esp32ConnectionCount());
564
+ // WiFi auto-provisioning for ESP32 (enables independent WiFi operation)
565
+ const wifiConfig = loadWifiConfig();
566
+ if (wifiConfig?.autoProvision) {
567
+ const lanIp = getLanIp();
568
+ onESP32Message((portPath, msg) => {
569
+ if (msg.type === 'device_info' && !msg.wifiConnected) {
570
+ sendWifiProvisionToAll({
571
+ type: 'wifi_provision',
572
+ ssid: wifiConfig.ssid,
573
+ password: wifiConfig.password,
574
+ bridgeIp: lanIp,
575
+ bridgePort: port,
576
+ authToken: core.authToken,
577
+ });
578
+ log(`[agentdeck] WiFi provision sent to ESP32 on ${portPath}`);
579
+ }
580
+ else if (msg.type === 'wifi_provision_ack') {
581
+ log(msg.success ? `[agentdeck] ESP32 WiFi connected: ${msg.ip} ✓` : `[agentdeck] ESP32 WiFi failed: ${msg.error || 'unknown'}`);
582
+ }
583
+ });
584
+ }
585
+ }
586
+ log(`[agentdeck] WebSocket server ready on port ${port}`);
587
+ log(`[agentdeck] Pairing URL: ${core.wsUrl}`);
588
+ // Initialize APME store + collector so the daemon can serve /apme/* HTTP
589
+ // routes. `setApme` on core is gated against the `daemon` meta-session so
590
+ // register/deregister won't open a bogus run. Session bridges opening their
591
+ // own connection to the same sqlite file is safe under WAL mode.
592
+ apme = await initApme();
593
+ if (apme) {
594
+ core.setApme(apme);
595
+ log('[agentdeck] APME enabled — /apme/* routes active');
596
+ }
597
+ // Register session
598
+ core.registerSession('daemon');
599
+ const passiveSessionObserver = new PassiveSessionObserver();
600
+ // ===== Gateway adapter lifecycle =====
601
+ // (gatewayAdapter + gatewayConnecting declared earlier, before HTTP server)
602
+ // Inject OpenClaw virtual session only after Gateway authentication succeeds.
603
+ // Reachability alone is a topology signal, not proof that commands can route.
604
+ core.setSessionsEnricher((sessions) => {
605
+ const enrichedSessions = [...sessions, ...passiveSessionObserver.collect(sessions)];
606
+ const adapterAlive = gatewayAdapter?.isAlive() ?? false;
607
+ if (!adapterAlive && !core.cachedGatewayConnected)
608
+ return enrichedSessions;
609
+ if (enrichedSessions.some(s => s.agentType === 'openclaw'))
610
+ return enrichedSessions;
611
+ const snap = core.stateMachine.getSnapshot();
612
+ return [...enrichedSessions, {
613
+ id: 'openclaw-gateway',
614
+ port: 18789,
615
+ projectName: adapterAlive ? (snap.projectName ?? 'OpenClaw') : 'OpenClaw',
616
+ agentType: 'openclaw',
617
+ alive: true,
618
+ state: adapterAlive ? snap.state : 'idle',
619
+ modelName: adapterAlive ? (snap.modelName ?? undefined) : undefined,
620
+ controlMode: 'managed',
621
+ }];
622
+ });
623
+ function connectGatewayAdapter() {
624
+ if (gatewayAdapter || gatewayConnecting)
625
+ return;
626
+ gatewayConnecting = true;
627
+ log('[agentdeck] OpenClaw Gateway detected, connecting...');
628
+ const adapter = new OpenClawAdapter({ autoReconnect: false });
629
+ adapter.on('event', (evt) => {
630
+ switch (evt.source) {
631
+ case 'hook':
632
+ if (evt.event === 'SessionStart')
633
+ core.stateMachine.handleHookEvent('SessionStart', {});
634
+ else if (evt.event === 'SessionEnd')
635
+ core.stateMachine.handleHookEvent('SessionEnd', {});
636
+ break;
637
+ case 'parser':
638
+ core.stateMachine.handleParserEvent(evt.event, evt.data);
639
+ break;
640
+ case 'metadata':
641
+ if (evt.event === 'model_catalog') {
642
+ const models = evt.data?.models;
643
+ if (models) {
644
+ core.cachedModelCatalog = models;
645
+ const snap = core.stateMachine.getSnapshot();
646
+ const stateEvent = core.buildStateEvent({
647
+ agentType: 'openclaw',
648
+ agentCapabilities: OPENCLAW_CAPABILITIES,
649
+ snapshot: snap,
650
+ });
651
+ lastStateEvent = stateEvent;
652
+ core.broadcast(stateEvent);
653
+ core.broadcastUsage();
654
+ }
655
+ }
656
+ else if (evt.event === 'gateway_health') {
657
+ // Use real-time health event from Gateway WS instead of polling `openclaw doctor`
658
+ const hasError = !evt.data?.ok;
659
+ const changed = hasError !== core.cachedGatewayHasError;
660
+ core.cachedGatewayHasError = hasError;
661
+ if (changed) {
662
+ core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
663
+ }
664
+ }
665
+ break;
666
+ case 'activity':
667
+ core.stateMachine.onPtyActivity();
668
+ break;
669
+ case 'timeline':
670
+ if (evt.entry) {
671
+ if (evt.upsert)
672
+ core.bridgeTimeline.upsertEntry(evt.entry);
673
+ else
674
+ core.bridgeTimeline.addEntry(evt.entry);
675
+ if (evt.entry.type === 'tool_request')
676
+ bridgeLogStream.trackToolRequest(evt.entry.raw);
677
+ }
678
+ break;
679
+ case 'connection': {
680
+ // Do NOT forward gateway adapter connection events as bridge connection
681
+ // events — WS clients would interpret them as their own bridge disconnect
682
+ // and show "disconnected" UI. Gateway status is conveyed via state_update
683
+ // (agentType/gatewayAvailable) and sessions_list.
684
+ if (evt.status === 'connected') {
685
+ core.cachedGatewayAvailable = true;
686
+ core.cachedGatewayConnected = true;
687
+ bridgeLogStream.start();
688
+ log('[agentdeck] OpenClaw Gateway connected');
689
+ if (core.stateMachine.getSnapshot().state === 'disconnected') {
690
+ core.stateMachine.handleHookEvent('SessionStart', {});
691
+ }
692
+ // Force full state broadcast
693
+ const snap = core.stateMachine.getSnapshot();
694
+ const gwStateEvent = core.buildStateEvent({
695
+ agentType: 'openclaw',
696
+ agentCapabilities: OPENCLAW_CAPABILITIES,
697
+ snapshot: snap,
698
+ });
699
+ lastStateEvent = gwStateEvent;
700
+ core.wsServer.broadcast(gwStateEvent);
701
+ core.broadcastUsage();
702
+ core.broadcastSessionsList().catch(() => { });
703
+ }
704
+ else {
705
+ core.cachedGatewayConnected = false;
706
+ bridgeLogStream.stop();
707
+ log('[agentdeck] OpenClaw Gateway disconnected');
708
+ core.broadcastSessionsList().catch(() => { });
709
+ }
710
+ break;
711
+ }
712
+ }
713
+ });
714
+ adapter.on('exit', () => disconnectGatewayAdapter());
715
+ adapter.start({ port, externalServer: httpServer }).then(() => {
716
+ gatewayAdapter = adapter;
717
+ gatewayConnecting = false;
718
+ }).catch((err) => {
719
+ log(`[agentdeck] Failed to connect to Gateway: ${err}`);
720
+ gatewayConnecting = false;
721
+ core.cachedGatewayConnected = false;
722
+ core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
723
+ });
724
+ }
725
+ function disconnectGatewayAdapter() {
726
+ if (!gatewayAdapter)
727
+ return;
728
+ log('[agentdeck] OpenClaw Gateway lost, cleaning up...');
729
+ const wasAlive = gatewayAdapter.isAlive();
730
+ gatewayAdapter.shutdown().catch(() => { });
731
+ gatewayAdapter = null;
732
+ core.cachedGatewayConnected = false;
733
+ core.cachedModelCatalog = null;
734
+ if (wasAlive)
735
+ core.stateMachine.handleHookEvent('SessionEnd', {});
736
+ // Do NOT broadcast connection:disconnected — that would make WS clients
737
+ // think they lost their bridge connection. State change to 'daemon' agentType
738
+ // and updated sessions_list convey the gateway loss.
739
+ core.broadcastSessionsList().catch(() => { });
740
+ }
741
+ // ===== Voice assistant (wake word) =====
742
+ let voiceAssistant = null;
743
+ let voiceManager = null;
744
+ let previousDaemonState = State.IDLE;
745
+ if (wakeWordEnabled) {
746
+ voiceManager = new VoiceManager();
747
+ voiceManager.connectToServer().catch((err) => {
748
+ debug('daemon', `whisper-server connection failed: ${err}`);
749
+ });
750
+ voiceAssistant = new VoiceAssistantManager({
751
+ sendPrompt: (text) => {
752
+ if (gatewayAdapter?.isAlive() && gatewayAdapter.handleCommand({ type: 'send_prompt', text })) {
753
+ core.stateMachine.handleUserAction('send_prompt');
754
+ }
755
+ else {
756
+ debug('daemon', 'Wake word prompt but no active adapter');
757
+ }
758
+ },
759
+ transcribeFile: (filePath) => voiceManager.transcribeFile(filePath),
760
+ });
761
+ voiceAssistant.on('state_change', (info) => {
762
+ // Broadcast dedicated event (for plugin FORWARDED_EVENTS)
763
+ core.broadcast({
764
+ type: 'voice_assistant_state',
765
+ state: info.state,
766
+ deviceId: 'mac-builtin',
767
+ text: info.text,
768
+ responseText: info.responseText,
769
+ });
770
+ // Piggyback on state_update so all clients (Android/Apple/TUI) get it automatically
771
+ core.updateVoiceAssistantState(info.state, info.text, info.responseText);
772
+ });
773
+ voiceAssistant.on('wake_word_detected', (info) => {
774
+ core.broadcast({
775
+ type: 'wake_word_detected',
776
+ deviceId: info.deviceId,
777
+ timestamp: info.timestamp,
778
+ });
779
+ });
780
+ voiceAssistant.start().then((ok) => {
781
+ if (ok)
782
+ log('[agentdeck] Wake word voice assistant active ("오픈클로")');
783
+ else
784
+ log('[agentdeck] Wake word not available (missing model or access key)');
785
+ }).catch((err) => {
786
+ log(`[agentdeck] Wake word start failed: ${err}`);
787
+ });
788
+ }
789
+ // ===== State changed → broadcast =====
790
+ core.stateMachine.on('state_changed', (snapshot) => {
791
+ const gwAlive = gatewayAdapter?.isAlive() ?? false;
792
+ const stateEvent = core.buildStateEvent({
793
+ agentType: gwAlive ? 'openclaw' : 'daemon',
794
+ agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
795
+ snapshot,
796
+ });
797
+ lastStateEvent = stateEvent;
798
+ core.wsServer.broadcast(stateEvent);
799
+ core.maybeBroadcastSessionsList();
800
+ core.broadcastUsage();
801
+ // Voice assistant: reset timeout on any activity during processing
802
+ if (snapshot.state === State.PROCESSING && voiceAssistant?.getState() === 'processing') {
803
+ voiceAssistant.resetResponseTimeout();
804
+ }
805
+ // Voice assistant: PROCESSING→IDLE triggers TTS response
806
+ const wasActive = previousDaemonState === State.PROCESSING;
807
+ previousDaemonState = snapshot.state;
808
+ if (wasActive && snapshot.state === State.IDLE && voiceAssistant?.getState() === 'processing') {
809
+ const lastEntry = core.bridgeTimeline.getLastEntry('chat_end');
810
+ const responseText = lastEntry?.detail ?? lastEntry?.raw ?? '';
811
+ voiceAssistant.handleResponse(responseText || '완료했습니다.').catch((err) => {
812
+ debug('daemon', `Voice assistant TTS error: ${err}`);
813
+ });
814
+ }
815
+ });
816
+ // ===== Commands from WS clients =====
817
+ // ===== Internal WS: session push channel =====
818
+ core.wsServer.onRawMessage((msg, sender) => {
819
+ if (msg.type === 'session_push_register') {
820
+ const { sessionId, port: sessionPort, agentType: at, projectName: pn } = msg;
821
+ debug('daemon', `session_push_register: ${sessionId} port=${sessionPort} agent=${at}`);
822
+ // Acknowledge registration
823
+ try {
824
+ sender.send(JSON.stringify({ type: 'session_push_ack', sessionId }));
825
+ }
826
+ catch { /* client disconnecting */ }
827
+ return true; // consumed
828
+ }
829
+ if (msg.type === 'session_push_state') {
830
+ const { sessionId, state, modelName, effortLevel } = msg;
831
+ if (sessionId && state) {
832
+ updatePushState(sessionId, state, modelName, effortLevel);
833
+ // Trigger sessions list broadcast so clients get fresh state
834
+ core.maybeBroadcastSessionsList();
835
+ }
836
+ return true; // consumed
837
+ }
838
+ return false; // not consumed — pass to command handler
839
+ });
840
+ core.wsServer.onCommand((cmd) => {
841
+ debug('daemon', `cmd: ${cmd.type}`);
842
+ if (gatewayAdapter?.isAlive() && gatewayAdapter.handleCommand(cmd)) {
843
+ switch (cmd.type) {
844
+ case 'respond':
845
+ core.stateMachine.handleUserAction('respond');
846
+ break;
847
+ case 'interrupt':
848
+ core.stateMachine.handleUserAction('interrupt');
849
+ break;
850
+ case 'escape':
851
+ core.stateMachine.handleUserAction('interrupt');
852
+ break;
853
+ case 'select_option':
854
+ core.stateMachine.handleUserAction('select_option');
855
+ break;
856
+ case 'send_prompt':
857
+ core.stateMachine.handleUserAction('send_prompt');
858
+ break;
859
+ }
860
+ return;
861
+ }
862
+ if (cmd.type === 'switch_agent') {
863
+ focusRelay.unfocus(); // Clear session focus on agent switch
864
+ const target = cmd.agent;
865
+ if (target === 'openclaw' && gatewayAdapter?.isAlive()) {
866
+ // Force broadcast OpenClaw state to all clients
867
+ const snap = core.stateMachine.getSnapshot();
868
+ const gwStateEvent = core.buildStateEvent({
869
+ agentType: 'openclaw',
870
+ agentCapabilities: OPENCLAW_CAPABILITIES,
871
+ snapshot: snap,
872
+ });
873
+ lastStateEvent = gwStateEvent;
874
+ core.wsServer.broadcast(gwStateEvent);
875
+ }
876
+ else if (target === 'claude-code') {
877
+ // Broadcast daemon/claude-code state — clients reconnect to session bridges independently
878
+ const snap = core.stateMachine.getSnapshot();
879
+ const stateEvent = core.buildStateEvent({
880
+ agentType: 'daemon',
881
+ snapshot: snap,
882
+ });
883
+ lastStateEvent = stateEvent;
884
+ core.wsServer.broadcast(stateEvent);
885
+ }
886
+ return;
887
+ }
888
+ if (cmd.type === 'focus_session') {
889
+ const sessionId = cmd.sessionId;
890
+ if (!sessionId)
891
+ return;
892
+ focusRelay.focus(sessionId);
893
+ return;
894
+ }
895
+ // Session-scoped command: forward inner command to a specific session's bridge
896
+ if (cmd.type === 'session_command') {
897
+ const { sessionId, command } = cmd;
898
+ if (!sessionId || !command)
899
+ return;
900
+ const sessions = listActiveSessions();
901
+ const target = sessions.find(s => s.id === sessionId);
902
+ if (!target) {
903
+ debug('daemon', `session_command: session ${sessionId} not found`);
904
+ return;
905
+ }
906
+ // Focus the session first, then route the command
907
+ focusRelay.focus(sessionId);
908
+ // Small delay to let focus take effect, then route
909
+ setTimeout(() => focusRelay.routeCommand(command), 100);
910
+ return;
911
+ }
912
+ // Route interactive commands to focused session (if any)
913
+ if (focusRelay.getFocusedSessionId() && focusRelay.routeCommand(cmd)) {
914
+ return;
915
+ }
916
+ if (cmd.type === 'query_usage') {
917
+ fetchUsageRelayed(port).then((usage) => {
918
+ if (usage)
919
+ core.updateApiUsage(usage);
920
+ else if (core.cachedApiUsage)
921
+ core.apiUsageStale = true;
922
+ });
923
+ }
924
+ });
925
+ // ===== Client connect =====
926
+ core.wsServer.onClientConnect((ws) => {
927
+ const gwAlive = gatewayAdapter?.isAlive() ?? false;
928
+ core.sendInitialState(ws, {
929
+ agentType: gwAlive ? 'openclaw' : 'daemon',
930
+ agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
931
+ isAlive: true, // WS client IS connected to daemon — gateway status conveyed via state_update
932
+ });
933
+ // Fetch usage on connect if stale
934
+ const cacheAge = Date.now() - core.lastApiFetchTime;
935
+ if (!core.cachedApiUsage || (core.lastApiFetchTime > 0 && cacheAge > 5 * 60 * 1000)) {
936
+ fetchUsageRelayed(port).then((usage) => {
937
+ if (usage)
938
+ core.updateApiUsage(usage);
939
+ else {
940
+ core.oauthConnected = hasOAuthToken();
941
+ if (core.cachedApiUsage)
942
+ core.apiUsageStale = true;
943
+ }
944
+ });
945
+ }
946
+ });
947
+ // ===== Probes & polling =====
948
+ core.startOllamaProbe();
949
+ core.startMlxProbe();
950
+ core.startAntigravityProbe();
951
+ core.startGatewayProbe(5000, () => connectGatewayAdapter(), () => { if (gatewayAdapter && !gatewayAdapter.isAlive())
952
+ disconnectGatewayAdapter(); });
953
+ core.startGatewayHealthCheck();
954
+ core.startUsageTick();
955
+ core.startApiUsagePolling(60_000, () => fetchUsageRelayed(port));
956
+ core.startSessionsListPolling();
957
+ // APME: periodically pick up runs that session bridges closed but couldn't
958
+ // eval (session exits within 2s of shutdown). Daemon is long-lived, so it
959
+ // can run the full deterministic + judge pipeline without time pressure.
960
+ if (apme) {
961
+ const { evaluateOutcome } = await import('./apme/outcome.js');
962
+ const { classifyRunSmart } = await import('./apme/classifier.js');
963
+ const { aggregateOverall } = await import('./apme/http.js');
964
+ // Broadcast eval results to all WS clients + timeline when runner completes
965
+ apme.runner.onResult(({ runId, turnId }) => {
966
+ // Turn-level eval: broadcast and add timeline entry with turn score
967
+ if (turnId) {
968
+ const run = apme.store.getRun(runId);
969
+ if (!run)
970
+ return;
971
+ const turnEvals = apme.store.listEvalsForTurn(turnId);
972
+ const overall = turnEvals.find(e => e.metric === 'overall');
973
+ if (!overall)
974
+ return;
975
+ // Persist turn-level outcome + composite so downstream analytics
976
+ // (category scorecard, recommender) can aggregate per-turn scores.
977
+ try {
978
+ apme.store.updateTurn(turnId, {
979
+ outcome: 'committed',
980
+ compositeScore: overall.score,
981
+ });
982
+ }
983
+ catch { /* ignore */ }
984
+ const pct = Math.round(overall.score * 100);
985
+ // WS broadcast — reuse apme_eval event for turn eval so dashboards pick it up
986
+ const turnEvalEvent = {
987
+ type: 'apme_eval',
988
+ run: {
989
+ runId: run.id, sessionId: run.sessionId, agentType: run.agentType, startedAt: run.startedAt,
990
+ modelId: run.modelId ?? undefined, projectName: run.projectName ?? undefined,
991
+ taskPrompt: run.taskPrompt ?? undefined, taskCategory: run.taskCategory ?? undefined,
992
+ outcome: 'committed',
993
+ compositeScore: overall.score,
994
+ overallScore: overall.score,
995
+ evals: turnEvals.map(e => ({
996
+ layer: e.layer, metric: e.metric, score: e.score,
997
+ judgeModel: e.judgeModel ?? undefined, createdAt: e.createdAt,
998
+ })),
999
+ },
1000
+ };
1001
+ core.broadcast(turnEvalEvent);
1002
+ // Change 8: include axis scores + judge reasoning in turn eval detail
1003
+ const turnAxes = turnEvals.filter(e => e.metric !== 'overall');
1004
+ const turnAxisStr = turnAxes.map(e => `${e.metric}=${Math.round(e.score * 100)}%`).join(' ');
1005
+ let turnDetail = turnAxisStr
1006
+ ? `${turnAxisStr}\n${run.taskPrompt?.slice(0, 80) ?? ''}`
1007
+ : `Turn eval · ${run.taskPrompt?.slice(0, 80) ?? ''}`;
1008
+ if (overall.raw) {
1009
+ try {
1010
+ const parsed = JSON.parse(overall.raw);
1011
+ const done = parsed.done?.slice(0, 2).join(', ') || '';
1012
+ const missed = parsed.missed?.slice(0, 2).join(', ') || '';
1013
+ if (done)
1014
+ turnDetail += `\n✓ ${done}`;
1015
+ if (missed)
1016
+ turnDetail += `\n✗ ${missed}`;
1017
+ }
1018
+ catch { /* ignore */ }
1019
+ }
1020
+ core.bridgeTimeline.addEntry({
1021
+ ts: Date.now(), type: 'eval_result',
1022
+ raw: `★ turn ${pct}% [${run.taskCategory ?? '?'}]`,
1023
+ detail: turnDetail,
1024
+ agentType: run.agentType,
1025
+ });
1026
+ return;
1027
+ }
1028
+ const run = apme.store.getRun(runId);
1029
+ if (!run)
1030
+ return;
1031
+ const evals = apme.store.listEvalsForRun(runId);
1032
+ const overallScore = aggregateOverall(evals);
1033
+ // WS broadcast: apme_eval event (type already in protocol.ts)
1034
+ const evalEvent = {
1035
+ type: 'apme_eval',
1036
+ run: {
1037
+ runId: run.id, sessionId: run.sessionId, agentType: run.agentType, startedAt: run.startedAt,
1038
+ modelId: run.modelId ?? undefined, projectName: run.projectName ?? undefined,
1039
+ taskPrompt: run.taskPrompt ?? undefined, taskCategory: run.taskCategory ?? undefined,
1040
+ outcome: run.outcome ?? undefined,
1041
+ compositeScore: run.compositeScore ?? undefined,
1042
+ overallScore: overallScore ?? undefined,
1043
+ evals: evals.map(e => ({
1044
+ layer: e.layer, metric: e.metric, score: e.score,
1045
+ judgeModel: e.judgeModel ?? undefined, createdAt: e.createdAt,
1046
+ })),
1047
+ },
1048
+ };
1049
+ core.broadcast(evalEvent);
1050
+ // Change 9: separate deterministic layer timeline entry (lint/build/test)
1051
+ const detEvals = evals.filter(e => e.layer === 'deterministic');
1052
+ if (detEvals.length > 0) {
1053
+ const detResults = detEvals.map(e => `${e.metric}=${e.score ? '✓' : '✗'}`).join(' ');
1054
+ core.bridgeTimeline.addEntry({
1055
+ ts: Date.now(), type: 'eval_result',
1056
+ raw: `⚡ ${detResults}`,
1057
+ detail: `Deterministic eval · ${run.projectName ?? ''}`,
1058
+ agentType: run.agentType,
1059
+ });
1060
+ }
1061
+ // Change 7: enriched run-level eval_result with axis scores + deterministic summary
1062
+ const pct = Math.round((overallScore ?? run.compositeScore ?? 0) * 100);
1063
+ const judgeEvals = evals.filter(e => e.layer === 'llm_judge' && e.metric !== 'overall');
1064
+ const axisStr = judgeEvals.map(e => `${e.metric}=${Math.round(e.score * 100)}%`).join(' ');
1065
+ const detStr = detEvals.length > 0
1066
+ ? detEvals.map(e => `${e.metric}=${e.score ? '✓' : '✗'}`).join(' ') + ' · '
1067
+ : '';
1068
+ const enrichedDetail = axisStr
1069
+ ? `${detStr}${axisStr}\n${run.projectName ?? ''} · ${run.taskPrompt?.slice(0, 100) ?? ''}`
1070
+ : `${run.projectName ?? ''} · ${run.taskPrompt?.slice(0, 100) ?? ''}`;
1071
+ core.bridgeTimeline.addEntry({
1072
+ ts: Date.now(),
1073
+ type: 'eval_result',
1074
+ raw: `★ [${run.taskCategory ?? '?'}] ${pct}% · ${run.outcome ?? 'pending'}`,
1075
+ detail: enrichedDetail,
1076
+ agentType: run.agentType,
1077
+ });
1078
+ });
1079
+ const apmeEvalTimer = setInterval(() => {
1080
+ // 1. Enqueue unevaluated runs for deterministic + judge
1081
+ const pending = apme.store.listUnevaluatedRuns(5);
1082
+ for (const run of pending) {
1083
+ apme.runner.enqueue({ runId: run.id, projectPath: run.projectPath ?? undefined });
1084
+ }
1085
+ // 2. Run outcome detection + composite scoring on recently closed runs
1086
+ // that don't have an outcome yet.
1087
+ const closedRuns = apme.store.listRuns({ limit: 20 });
1088
+ for (const run of closedRuns) {
1089
+ if (run.endedAt && !run.outcome) {
1090
+ // Wait at least 10s after close before judging outcome
1091
+ const elapsed = Date.now() - run.endedAt;
1092
+ if (elapsed > 10_000) {
1093
+ evaluateOutcome(apme.store, run.id);
1094
+ }
1095
+ }
1096
+ }
1097
+ // 3. Classify unclassified runs (fire-and-forget from session bridge may
1098
+ // have been killed by process exit — daemon retries here).
1099
+ const unclassified = apme.store.listUnclassifiedRuns(5);
1100
+ for (const run of unclassified) {
1101
+ void classifyRunSmart(apme.store, run.id).then(({ signals, category, source }) => {
1102
+ apme.store.updateRun(run.id, {
1103
+ taskSignals: JSON.stringify(signals),
1104
+ taskCategory: category,
1105
+ taskCategorySource: source,
1106
+ });
1107
+ }).catch(() => { });
1108
+ }
1109
+ // 3b. Backfill turn outcome/composite for turns with captured response.
1110
+ // Turn-level judge (turn_judge) only fires for non-code categories,
1111
+ // so code-category turns never get outcome/composite otherwise.
1112
+ // Heuristic: response captured = 'committed', composite from overall
1113
+ // turn_judge score if present, otherwise null (not inflated).
1114
+ const needOutcome = apme.store.listTurnsNeedingOutcome(20);
1115
+ for (const t of needOutcome) {
1116
+ const evs = apme.store.listEvalsForTurn(t.id);
1117
+ const overall = evs.find(e => e.layer === 'turn_judge' && e.metric === 'overall');
1118
+ try {
1119
+ apme.store.updateTurn(t.id, {
1120
+ outcome: 'committed',
1121
+ ...(overall ? { compositeScore: overall.score } : {}),
1122
+ });
1123
+ }
1124
+ catch { /* ignore */ }
1125
+ }
1126
+ // 4. Clean up orphaned runs — session bridges that crashed without graceful
1127
+ // shutdown leave runs with no ended_at, no turns, no prompt. Tag as _empty
1128
+ // so the dashboard filters them out.
1129
+ const orphans = apme.store.listOrphanedRuns(1800); // 30 min stale threshold
1130
+ for (const id of orphans) {
1131
+ apme.store.updateRun(id, { endedAt: Date.now(), taskCategory: '_empty' });
1132
+ }
1133
+ }, 30_000); // every 30s
1134
+ core.addInterval(apmeEvalTimer);
1135
+ }
1136
+ // Initial usage fetch (delayed 10s)
1137
+ core.addTimeout(setTimeout(() => {
1138
+ fetchUsageRelayed(port).then((usage) => {
1139
+ if (usage)
1140
+ core.updateApiUsage(usage);
1141
+ else {
1142
+ core.oauthConnected = hasOAuthToken();
1143
+ if (core.cachedApiUsage)
1144
+ core.apiUsageStale = true;
1145
+ }
1146
+ });
1147
+ }, 10_000));
1148
+ // ===== Shutdown =====
1149
+ core.onShutdown(async () => {
1150
+ removeDaemonInfo();
1151
+ focusRelay.stop();
1152
+ timelineRelay.stop();
1153
+ voiceAssistant?.stop();
1154
+ voiceManager?.disconnectFromServer();
1155
+ bridgeLogStream.stop();
1156
+ await Promise.all([
1157
+ gatewayAdapter ? gatewayAdapter.shutdown().catch(() => { }) : Promise.resolve(),
1158
+ stopModules(startedModules)
1159
+ ]);
1160
+ gatewayAdapter = null;
1161
+ httpServer.close(() => process.exit(0));
1162
+ // Force exit if httpServer.close() hangs on CLOSE_WAIT connections
1163
+ setTimeout(() => process.exit(0), 5000).unref();
1164
+ });
1165
+ core.registerProcessHandlers('agentdeck');
1166
+ log(`[agentdeck] Daemon running. Gateway probe active.`);
1167
+ }
1168
+ //# sourceMappingURL=daemon-server.js.map