@agentdeck/bridge 0.2.2 → 1.0.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 (435) hide show
  1. package/assets/fm-helper/agentdeck-fm-helper +0 -0
  2. package/assets/fonts/IBMPlexSans-Bold.ttf +0 -0
  3. package/assets/fonts/IBMPlexSans-Regular.ttf +0 -0
  4. package/assets/fonts/JetBrainsMono-Bold.ttf +0 -0
  5. package/assets/fonts/JetBrainsMono-Regular.ttf +0 -0
  6. package/assets/fonts/LICENSES.md +22 -0
  7. package/dist/adapters/claude-code.d.ts +6 -0
  8. package/dist/adapters/claude-code.d.ts.map +1 -1
  9. package/dist/adapters/claude-code.js +8 -0
  10. package/dist/adapters/claude-code.js.map +1 -1
  11. package/dist/adapters/openclaw.d.ts +24 -0
  12. package/dist/adapters/openclaw.d.ts.map +1 -1
  13. package/dist/adapters/openclaw.js +291 -35
  14. package/dist/adapters/openclaw.js.map +1 -1
  15. package/dist/adapters/opencode-adapter.d.ts +59 -0
  16. package/dist/adapters/opencode-adapter.d.ts.map +1 -1
  17. package/dist/adapters/opencode-adapter.js +297 -35
  18. package/dist/adapters/opencode-adapter.js.map +1 -1
  19. package/dist/adb-reverse.d.ts +1 -1
  20. package/dist/adb-reverse.js +1 -1
  21. package/dist/antigravity-local.d.ts +1 -0
  22. package/dist/antigravity-local.d.ts.map +1 -1
  23. package/dist/antigravity-local.js +33 -0
  24. package/dist/antigravity-local.js.map +1 -1
  25. package/dist/apme/adapters/claude-hook.d.ts.map +1 -1
  26. package/dist/apme/adapters/claude-hook.js +5 -0
  27. package/dist/apme/adapters/claude-hook.js.map +1 -1
  28. package/dist/apme/adapters/codex-hook.d.ts +17 -0
  29. package/dist/apme/adapters/codex-hook.d.ts.map +1 -0
  30. package/dist/apme/adapters/codex-hook.js +95 -0
  31. package/dist/apme/adapters/codex-hook.js.map +1 -0
  32. package/dist/apme/adapters/codex-turn-manager.d.ts +86 -0
  33. package/dist/apme/adapters/codex-turn-manager.d.ts.map +1 -0
  34. package/dist/apme/adapters/codex-turn-manager.js +496 -0
  35. package/dist/apme/adapters/codex-turn-manager.js.map +1 -0
  36. package/dist/apme/adapters/openclaw-hook.d.ts +99 -0
  37. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -0
  38. package/dist/apme/adapters/openclaw-hook.js +234 -0
  39. package/dist/apme/adapters/openclaw-hook.js.map +1 -0
  40. package/dist/apme/adapters/opencode-hook.d.ts +56 -0
  41. package/dist/apme/adapters/opencode-hook.d.ts.map +1 -0
  42. package/dist/apme/adapters/opencode-hook.js +182 -0
  43. package/dist/apme/adapters/opencode-hook.js.map +1 -0
  44. package/dist/apme/adapters/timeline.d.ts.map +1 -1
  45. package/dist/apme/adapters/timeline.js +6 -1
  46. package/dist/apme/adapters/timeline.js.map +1 -1
  47. package/dist/apme/antigravity-transcript.d.ts +18 -0
  48. package/dist/apme/antigravity-transcript.d.ts.map +1 -0
  49. package/dist/apme/antigravity-transcript.js +94 -0
  50. package/dist/apme/antigravity-transcript.js.map +1 -0
  51. package/dist/apme/classify-turn.d.ts +13 -0
  52. package/dist/apme/classify-turn.d.ts.map +1 -0
  53. package/dist/apme/classify-turn.js +49 -0
  54. package/dist/apme/classify-turn.js.map +1 -0
  55. package/dist/apme/claude-transcript-reader.d.ts +10 -0
  56. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  57. package/dist/apme/claude-transcript-reader.js +35 -0
  58. package/dist/apme/claude-transcript-reader.js.map +1 -1
  59. package/dist/apme/collector.d.ts +133 -6
  60. package/dist/apme/collector.d.ts.map +1 -1
  61. package/dist/apme/collector.js +471 -23
  62. package/dist/apme/collector.js.map +1 -1
  63. package/dist/apme/dashboard-html.d.ts.map +1 -1
  64. package/dist/apme/dashboard-html.js +117 -11
  65. package/dist/apme/dashboard-html.js.map +1 -1
  66. package/dist/apme/http.d.ts.map +1 -1
  67. package/dist/apme/http.js +52 -6
  68. package/dist/apme/http.js.map +1 -1
  69. package/dist/apme/index.d.ts +26 -5
  70. package/dist/apme/index.d.ts.map +1 -1
  71. package/dist/apme/index.js +146 -9
  72. package/dist/apme/index.js.map +1 -1
  73. package/dist/apme/judge-detect.d.ts +26 -0
  74. package/dist/apme/judge-detect.d.ts.map +1 -0
  75. package/dist/apme/judge-detect.js +69 -0
  76. package/dist/apme/judge-detect.js.map +1 -0
  77. package/dist/apme/pareto.d.ts +39 -0
  78. package/dist/apme/pareto.d.ts.map +1 -0
  79. package/dist/apme/pareto.js +59 -0
  80. package/dist/apme/pareto.js.map +1 -0
  81. package/dist/apme/recommend.d.ts +3 -0
  82. package/dist/apme/recommend.d.ts.map +1 -1
  83. package/dist/apme/recommend.js +43 -1
  84. package/dist/apme/recommend.js.map +1 -1
  85. package/dist/apme/runner.d.ts +99 -4
  86. package/dist/apme/runner.d.ts.map +1 -1
  87. package/dist/apme/runner.js +743 -72
  88. package/dist/apme/runner.js.map +1 -1
  89. package/dist/apme/sample-to-timeline.d.ts +26 -0
  90. package/dist/apme/sample-to-timeline.d.ts.map +1 -0
  91. package/dist/apme/sample-to-timeline.js +95 -0
  92. package/dist/apme/sample-to-timeline.js.map +1 -0
  93. package/dist/apme/scorers/index.d.ts +47 -0
  94. package/dist/apme/scorers/index.d.ts.map +1 -0
  95. package/dist/apme/scorers/index.js +96 -0
  96. package/dist/apme/scorers/index.js.map +1 -0
  97. package/dist/apme/settings.d.ts +17 -11
  98. package/dist/apme/settings.d.ts.map +1 -1
  99. package/dist/apme/settings.js +30 -9
  100. package/dist/apme/settings.js.map +1 -1
  101. package/dist/apme/store.d.ts +33 -1
  102. package/dist/apme/store.d.ts.map +1 -1
  103. package/dist/apme/store.js +334 -25
  104. package/dist/apme/store.js.map +1 -1
  105. package/dist/awaiting-overlay.d.ts +112 -0
  106. package/dist/awaiting-overlay.d.ts.map +1 -0
  107. package/dist/awaiting-overlay.js +179 -0
  108. package/dist/awaiting-overlay.js.map +1 -0
  109. package/dist/ble-sync-spawn.d.ts +63 -0
  110. package/dist/ble-sync-spawn.d.ts.map +1 -0
  111. package/dist/ble-sync-spawn.js +196 -0
  112. package/dist/ble-sync-spawn.js.map +1 -0
  113. package/dist/bridge-core.d.ts +34 -1
  114. package/dist/bridge-core.d.ts.map +1 -1
  115. package/dist/bridge-core.js +207 -24
  116. package/dist/bridge-core.js.map +1 -1
  117. package/dist/broadcast.d.ts +3 -0
  118. package/dist/broadcast.d.ts.map +1 -0
  119. package/dist/broadcast.js +122 -0
  120. package/dist/broadcast.js.map +1 -0
  121. package/dist/check-deps.d.ts.map +1 -1
  122. package/dist/check-deps.js +25 -9
  123. package/dist/check-deps.js.map +1 -1
  124. package/dist/claude-permission-rules.d.ts +55 -0
  125. package/dist/claude-permission-rules.d.ts.map +1 -0
  126. package/dist/claude-permission-rules.js +197 -0
  127. package/dist/claude-permission-rules.js.map +1 -0
  128. package/dist/cli.d.ts +15 -1
  129. package/dist/cli.d.ts.map +1 -1
  130. package/dist/cli.js +1030 -93
  131. package/dist/cli.js.map +1 -1
  132. package/dist/codex-auth.d.ts +21 -0
  133. package/dist/codex-auth.d.ts.map +1 -1
  134. package/dist/codex-auth.js +52 -1
  135. package/dist/codex-auth.js.map +1 -1
  136. package/dist/codex-output-parser.d.ts +8 -0
  137. package/dist/codex-output-parser.d.ts.map +1 -1
  138. package/dist/codex-output-parser.js +110 -25
  139. package/dist/codex-output-parser.js.map +1 -1
  140. package/dist/codex-rate-limits.d.ts +16 -0
  141. package/dist/codex-rate-limits.d.ts.map +1 -0
  142. package/dist/codex-rate-limits.js +197 -0
  143. package/dist/codex-rate-limits.js.map +1 -0
  144. package/dist/codex-rollout-response.d.ts +31 -0
  145. package/dist/codex-rollout-response.d.ts.map +1 -0
  146. package/dist/codex-rollout-response.js +135 -0
  147. package/dist/codex-rollout-response.js.map +1 -0
  148. package/dist/d200h/image-renderer.d.ts +4 -16
  149. package/dist/d200h/image-renderer.d.ts.map +1 -1
  150. package/dist/d200h/image-renderer.js +46 -212
  151. package/dist/d200h/image-renderer.js.map +1 -1
  152. package/dist/daemon-server.d.ts +85 -0
  153. package/dist/daemon-server.d.ts.map +1 -1
  154. package/dist/daemon-server.js +1975 -159
  155. package/dist/daemon-server.js.map +1 -1
  156. package/dist/daemon.js +15 -6
  157. package/dist/daemon.js.map +1 -1
  158. package/dist/display-dim.d.ts +9 -0
  159. package/dist/display-dim.d.ts.map +1 -0
  160. package/dist/display-dim.js +53 -0
  161. package/dist/display-dim.js.map +1 -0
  162. package/dist/display-monitor.d.ts +15 -2
  163. package/dist/display-monitor.d.ts.map +1 -1
  164. package/dist/display-monitor.js +104 -26
  165. package/dist/display-monitor.js.map +1 -1
  166. package/dist/esp32-serial.d.ts +190 -1
  167. package/dist/esp32-serial.d.ts.map +1 -1
  168. package/dist/esp32-serial.js +1090 -156
  169. package/dist/esp32-serial.js.map +1 -1
  170. package/dist/fallback-task-timeline.d.ts +42 -0
  171. package/dist/fallback-task-timeline.d.ts.map +1 -0
  172. package/dist/fallback-task-timeline.js +119 -0
  173. package/dist/fallback-task-timeline.js.map +1 -0
  174. package/dist/foundation-models-helper.d.ts +11 -0
  175. package/dist/foundation-models-helper.d.ts.map +1 -0
  176. package/dist/foundation-models-helper.js +241 -0
  177. package/dist/foundation-models-helper.js.map +1 -0
  178. package/dist/hook-migration.js +1 -1
  179. package/dist/hook-migration.js.map +1 -1
  180. package/dist/hook-server.d.ts +2 -3
  181. package/dist/hook-server.d.ts.map +1 -1
  182. package/dist/hook-server.js +23 -10
  183. package/dist/hook-server.js.map +1 -1
  184. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts +33 -0
  185. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -0
  186. package/dist/idotmatrix/idotmatrix-daemon-sync.js +164 -0
  187. package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -0
  188. package/dist/idotmatrix/idotmatrix-discover.d.ts +18 -0
  189. package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -0
  190. package/dist/idotmatrix/idotmatrix-discover.js +85 -0
  191. package/dist/idotmatrix/idotmatrix-discover.js.map +1 -0
  192. package/dist/idotmatrix/idotmatrix-settings.d.ts +20 -0
  193. package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -0
  194. package/dist/idotmatrix/idotmatrix-settings.js +57 -0
  195. package/dist/idotmatrix/idotmatrix-settings.js.map +1 -0
  196. package/dist/index.d.ts.map +1 -1
  197. package/dist/index.js +217 -179
  198. package/dist/index.js.map +1 -1
  199. package/dist/log-stream.d.ts +15 -16
  200. package/dist/log-stream.d.ts.map +1 -1
  201. package/dist/log-stream.js +18 -96
  202. package/dist/log-stream.js.map +1 -1
  203. package/dist/logger.d.ts +3 -1
  204. package/dist/logger.d.ts.map +1 -1
  205. package/dist/logger.js +18 -5
  206. package/dist/logger.js.map +1 -1
  207. package/dist/mdns.d.ts +17 -0
  208. package/dist/mdns.d.ts.map +1 -1
  209. package/dist/mdns.js +52 -18
  210. package/dist/mdns.js.map +1 -1
  211. package/dist/model-catalog.d.ts +1 -1
  212. package/dist/model-catalog.d.ts.map +1 -1
  213. package/dist/model-catalog.js +2 -4
  214. package/dist/model-catalog.js.map +1 -1
  215. package/dist/modules/adb-module.d.ts +1 -1
  216. package/dist/modules/adb-module.d.ts.map +1 -1
  217. package/dist/modules/adb-module.js +4 -3
  218. package/dist/modules/adb-module.js.map +1 -1
  219. package/dist/modules/broadcast-module.d.ts +17 -0
  220. package/dist/modules/broadcast-module.d.ts.map +1 -0
  221. package/dist/modules/broadcast-module.js +26 -0
  222. package/dist/modules/broadcast-module.js.map +1 -0
  223. package/dist/modules/d200h-module.d.ts +22 -0
  224. package/dist/modules/d200h-module.d.ts.map +1 -1
  225. package/dist/modules/d200h-module.js +200 -65
  226. package/dist/modules/d200h-module.js.map +1 -1
  227. package/dist/modules/idotmatrix-module.d.ts +9 -0
  228. package/dist/modules/idotmatrix-module.d.ts.map +1 -0
  229. package/dist/modules/idotmatrix-module.js +46 -0
  230. package/dist/modules/idotmatrix-module.js.map +1 -0
  231. package/dist/modules/index.d.ts +3 -1
  232. package/dist/modules/index.d.ts.map +1 -1
  233. package/dist/modules/index.js +9 -3
  234. package/dist/modules/index.js.map +1 -1
  235. package/dist/modules/pixoo-module.d.ts.map +1 -1
  236. package/dist/modules/pixoo-module.js +14 -4
  237. package/dist/modules/pixoo-module.js.map +1 -1
  238. package/dist/modules/serial-module.d.ts +4 -0
  239. package/dist/modules/serial-module.d.ts.map +1 -1
  240. package/dist/modules/serial-module.js +14 -12
  241. package/dist/modules/serial-module.js.map +1 -1
  242. package/dist/modules/timebox-module.d.ts +9 -0
  243. package/dist/modules/timebox-module.d.ts.map +1 -0
  244. package/dist/modules/timebox-module.js +46 -0
  245. package/dist/modules/timebox-module.js.map +1 -0
  246. package/dist/modules/trmnl-module.d.ts +38 -0
  247. package/dist/modules/trmnl-module.d.ts.map +1 -0
  248. package/dist/modules/trmnl-module.js +111 -0
  249. package/dist/modules/trmnl-module.js.map +1 -0
  250. package/dist/modules/types.d.ts +4 -2
  251. package/dist/modules/types.d.ts.map +1 -1
  252. package/dist/observed-steering.d.ts +93 -0
  253. package/dist/observed-steering.d.ts.map +1 -0
  254. package/dist/observed-steering.js +247 -0
  255. package/dist/observed-steering.js.map +1 -0
  256. package/dist/openclaw-session.d.ts +18 -0
  257. package/dist/openclaw-session.d.ts.map +1 -0
  258. package/dist/openclaw-session.js +34 -0
  259. package/dist/openclaw-session.js.map +1 -0
  260. package/dist/opencode-steering.d.ts +36 -0
  261. package/dist/opencode-steering.d.ts.map +1 -0
  262. package/dist/opencode-steering.js +84 -0
  263. package/dist/opencode-steering.js.map +1 -0
  264. package/dist/output-parser.d.ts +30 -0
  265. package/dist/output-parser.d.ts.map +1 -1
  266. package/dist/output-parser.js +172 -18
  267. package/dist/output-parser.js.map +1 -1
  268. package/dist/passive-observer.d.ts +32 -0
  269. package/dist/passive-observer.d.ts.map +1 -1
  270. package/dist/passive-observer.js +288 -49
  271. package/dist/passive-observer.js.map +1 -1
  272. package/dist/permission-resolver.d.ts +55 -0
  273. package/dist/permission-resolver.d.ts.map +1 -0
  274. package/dist/permission-resolver.js +112 -0
  275. package/dist/permission-resolver.js.map +1 -0
  276. package/dist/pixoo/micro-glyphs.d.ts +18 -0
  277. package/dist/pixoo/micro-glyphs.d.ts.map +1 -0
  278. package/dist/pixoo/micro-glyphs.js +143 -0
  279. package/dist/pixoo/micro-glyphs.js.map +1 -0
  280. package/dist/pixoo/official-dot-glyphs.generated.d.ts +8 -0
  281. package/dist/pixoo/official-dot-glyphs.generated.d.ts.map +1 -0
  282. package/dist/pixoo/official-dot-glyphs.generated.js +247 -0
  283. package/dist/pixoo/official-dot-glyphs.generated.js.map +1 -0
  284. package/dist/pixoo/pixoo-bridge.d.ts +16 -8
  285. package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
  286. package/dist/pixoo/pixoo-bridge.js +127 -35
  287. package/dist/pixoo/pixoo-bridge.js.map +1 -1
  288. package/dist/pixoo/pixoo-camera.d.ts +19 -3
  289. package/dist/pixoo/pixoo-camera.d.ts.map +1 -1
  290. package/dist/pixoo/pixoo-camera.js +62 -27
  291. package/dist/pixoo/pixoo-camera.js.map +1 -1
  292. package/dist/pixoo/pixoo-client.d.ts +13 -3
  293. package/dist/pixoo/pixoo-client.d.ts.map +1 -1
  294. package/dist/pixoo/pixoo-client.js +51 -22
  295. package/dist/pixoo/pixoo-client.js.map +1 -1
  296. package/dist/pixoo/pixoo-discover.d.ts +37 -0
  297. package/dist/pixoo/pixoo-discover.d.ts.map +1 -0
  298. package/dist/pixoo/pixoo-discover.js +105 -0
  299. package/dist/pixoo/pixoo-discover.js.map +1 -0
  300. package/dist/pixoo/pixoo-renderer.d.ts +27 -3
  301. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  302. package/dist/pixoo/pixoo-renderer.js +533 -135
  303. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  304. package/dist/pixoo/pixoo-settings.d.ts +6 -0
  305. package/dist/pixoo/pixoo-settings.d.ts.map +1 -1
  306. package/dist/pixoo/pixoo-settings.js +9 -0
  307. package/dist/pixoo/pixoo-settings.js.map +1 -1
  308. package/dist/pixoo/pixoo-sprites.d.ts +84 -11
  309. package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
  310. package/dist/pixoo/pixoo-sprites.js +620 -209
  311. package/dist/pixoo/pixoo-sprites.js.map +1 -1
  312. package/dist/pty-manager.d.ts.map +1 -1
  313. package/dist/pty-manager.js +8 -2
  314. package/dist/pty-manager.js.map +1 -1
  315. package/dist/review-runner.d.ts +113 -0
  316. package/dist/review-runner.d.ts.map +1 -0
  317. package/dist/review-runner.js +428 -0
  318. package/dist/review-runner.js.map +1 -0
  319. package/dist/session-activity.d.ts +14 -0
  320. package/dist/session-activity.d.ts.map +1 -0
  321. package/dist/session-activity.js +160 -0
  322. package/dist/session-activity.js.map +1 -0
  323. package/dist/session-aggregator.d.ts +25 -0
  324. package/dist/session-aggregator.d.ts.map +1 -1
  325. package/dist/session-aggregator.js +48 -9
  326. package/dist/session-aggregator.js.map +1 -1
  327. package/dist/session-focus-relay.d.ts.map +1 -1
  328. package/dist/session-focus-relay.js +7 -1
  329. package/dist/session-focus-relay.js.map +1 -1
  330. package/dist/session-registry.d.ts +74 -3
  331. package/dist/session-registry.d.ts.map +1 -1
  332. package/dist/session-registry.js +140 -8
  333. package/dist/session-registry.js.map +1 -1
  334. package/dist/session-timeline-relay.d.ts.map +1 -1
  335. package/dist/session-timeline-relay.js +13 -4
  336. package/dist/session-timeline-relay.js.map +1 -1
  337. package/dist/session-transcript-timeline.d.ts +49 -0
  338. package/dist/session-transcript-timeline.d.ts.map +1 -0
  339. package/dist/session-transcript-timeline.js +276 -0
  340. package/dist/session-transcript-timeline.js.map +1 -0
  341. package/dist/state-machine.d.ts +6 -1
  342. package/dist/state-machine.d.ts.map +1 -1
  343. package/dist/state-machine.js +86 -28
  344. package/dist/state-machine.js.map +1 -1
  345. package/dist/timebox/timebox-daemon-sync.d.ts +19 -0
  346. package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -0
  347. package/dist/timebox/timebox-daemon-sync.js +135 -0
  348. package/dist/timebox/timebox-daemon-sync.js.map +1 -0
  349. package/dist/timebox/timebox-discover.d.ts +19 -0
  350. package/dist/timebox/timebox-discover.d.ts.map +1 -0
  351. package/dist/timebox/timebox-discover.js +87 -0
  352. package/dist/timebox/timebox-discover.js.map +1 -0
  353. package/dist/timebox/timebox-settings.d.ts +32 -0
  354. package/dist/timebox/timebox-settings.d.ts.map +1 -0
  355. package/dist/timebox/timebox-settings.js +80 -0
  356. package/dist/timebox/timebox-settings.js.map +1 -0
  357. package/dist/timeline-store.d.ts +112 -3
  358. package/dist/timeline-store.d.ts.map +1 -1
  359. package/dist/timeline-store.js +460 -14
  360. package/dist/timeline-store.js.map +1 -1
  361. package/dist/timeline-summarizer.d.ts.map +1 -1
  362. package/dist/timeline-summarizer.js +29 -5
  363. package/dist/timeline-summarizer.js.map +1 -1
  364. package/dist/trmnl/byos-server.d.ts +39 -0
  365. package/dist/trmnl/byos-server.d.ts.map +1 -0
  366. package/dist/trmnl/byos-server.js +301 -0
  367. package/dist/trmnl/byos-server.js.map +1 -0
  368. package/dist/trmnl/frame-cache.d.ts +37 -0
  369. package/dist/trmnl/frame-cache.d.ts.map +1 -0
  370. package/dist/trmnl/frame-cache.js +157 -0
  371. package/dist/trmnl/frame-cache.js.map +1 -0
  372. package/dist/trmnl/image-renderer.d.ts +27 -0
  373. package/dist/trmnl/image-renderer.d.ts.map +1 -0
  374. package/dist/trmnl/image-renderer.js +235 -0
  375. package/dist/trmnl/image-renderer.js.map +1 -0
  376. package/dist/trmnl/trmnl-settings.d.ts +96 -0
  377. package/dist/trmnl/trmnl-settings.d.ts.map +1 -0
  378. package/dist/trmnl/trmnl-settings.js +177 -0
  379. package/dist/trmnl/trmnl-settings.js.map +1 -0
  380. package/dist/trmnl/trmnl-telemetry.d.ts +47 -0
  381. package/dist/trmnl/trmnl-telemetry.d.ts.map +1 -0
  382. package/dist/trmnl/trmnl-telemetry.js +56 -0
  383. package/dist/trmnl/trmnl-telemetry.js.map +1 -0
  384. package/dist/tui/ansi.d.ts +0 -4
  385. package/dist/tui/ansi.d.ts.map +1 -1
  386. package/dist/tui/ansi.js +14 -7
  387. package/dist/tui/ansi.js.map +1 -1
  388. package/dist/tui/dashboard.d.ts +1 -0
  389. package/dist/tui/dashboard.d.ts.map +1 -1
  390. package/dist/tui/dashboard.js +38 -11
  391. package/dist/tui/dashboard.js.map +1 -1
  392. package/dist/tui/renderer.d.ts +36 -0
  393. package/dist/tui/renderer.d.ts.map +1 -1
  394. package/dist/tui/renderer.js +355 -121
  395. package/dist/tui/renderer.js.map +1 -1
  396. package/dist/tui/terrarium.d.ts.map +1 -1
  397. package/dist/tui/terrarium.js +44 -51
  398. package/dist/tui/terrarium.js.map +1 -1
  399. package/dist/types.d.ts +2 -2
  400. package/dist/types.d.ts.map +1 -1
  401. package/dist/types.js +2 -2
  402. package/dist/types.js.map +1 -1
  403. package/dist/usage-api.d.ts.map +1 -1
  404. package/dist/usage-api.js +7 -1
  405. package/dist/usage-api.js.map +1 -1
  406. package/dist/usage-event.d.ts +10 -3
  407. package/dist/usage-event.d.ts.map +1 -1
  408. package/dist/usage-event.js +98 -13
  409. package/dist/usage-event.js.map +1 -1
  410. package/dist/utility-proxy.d.ts.map +1 -1
  411. package/dist/utility-proxy.js +8 -0
  412. package/dist/utility-proxy.js.map +1 -1
  413. package/dist/utils/project-name.d.ts +3 -0
  414. package/dist/utils/project-name.d.ts.map +1 -1
  415. package/dist/utils/project-name.js +44 -2
  416. package/dist/utils/project-name.js.map +1 -1
  417. package/dist/version-check.d.ts.map +1 -1
  418. package/dist/version-check.js +1 -17
  419. package/dist/version-check.js.map +1 -1
  420. package/dist/windows-service.d.ts +26 -0
  421. package/dist/windows-service.d.ts.map +1 -0
  422. package/dist/windows-service.js +145 -0
  423. package/dist/windows-service.js.map +1 -0
  424. package/dist/ws-server.d.ts +17 -3
  425. package/dist/ws-server.d.ts.map +1 -1
  426. package/dist/ws-server.js +78 -8
  427. package/dist/ws-server.js.map +1 -1
  428. package/fm-helper/AgentDeckFMHelper.swift +112 -0
  429. package/package.json +10 -4
  430. package/scripts/build-fm-helper.mjs +26 -0
  431. package/LICENSE +0 -21
  432. package/dist/apme/tuner.d.ts +0 -64
  433. package/dist/apme/tuner.d.ts.map +0 -1
  434. package/dist/apme/tuner.js +0 -297
  435. package/dist/apme/tuner.js.map +0 -1
@@ -11,46 +11,466 @@
11
11
  * Exports `startDaemon()` called by cli.ts.
12
12
  */
13
13
  import { createServer } from 'http';
14
+ import { createHash, randomUUID } from 'crypto';
14
15
  import WebSocket from 'ws';
15
- import { BridgeCore } from './bridge-core.js';
16
+ import { BridgeCore, buildCappedTimelineHistory } from './bridge-core.js';
17
+ import { buildDisplayStateEvent } from './display-dim.js';
18
+ import { SERIAL_FORWARDED_EVENTS } from '@agentdeck/shared/protocol';
19
+ import { prepareForSerial } from './esp32-serial.js';
16
20
  import { OpenClawAdapter } from './adapters/openclaw.js';
17
21
  import { BridgeLogStream } from './log-stream.js';
18
22
  import { PassiveSessionObserver } from './passive-observer.js';
19
23
  import { SessionTimelineRelay } from './session-timeline-relay.js';
20
24
  import { SessionFocusRelay } from './session-focus-relay.js';
21
25
  import { updatePushState } from './session-aggregator.js';
26
+ import { setAwaitingOverlay, clearAwaitingOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved } from './awaiting-overlay.js';
27
+ import { registerPending, resolvePending, sweepStalePending, drainAllPending, isPendingRequest } from './permission-resolver.js';
28
+ import { shouldHoldPreToolUse, gateReleased, buildGateQuestion, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirective, clearOnUserPrompt, clearSession as clearSteeringSession, notePermissionPromptShown, noteToolEnd, steeringSnapshot, } from './observed-steering.js';
29
+ import { enqueueOpenCodeCommand, pollOpenCodeCommands } from './opencode-steering.js';
30
+ import { runSessionReview, reviewSnapshot } from './review-runner.js';
31
+ /** Observed-session device approval gate (PreToolUse hold). Default ON — the
32
+ * precision guards in observed-steering.ts/claude-permission-rules.ts ensure
33
+ * only genuine would-prompt calls are held. Kill switch for field issues. */
34
+ const OBSERVED_APPROVAL_ENABLED = process.env.AGENTDECK_OBSERVED_APPROVAL !== '0';
35
+ /** How long a held gate waits for a device decision before releasing the tool
36
+ * call to Claude's own permission flow. Must stay well under the hook curl's
37
+ * --max-time 60 so the release reaches Claude before curl gives up. */
38
+ const OBSERVED_APPROVAL_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGENTDECK_APPROVAL_HOLD_MS) || 25_000));
22
39
  import { VoiceManager } from './voice.js';
23
40
  import { VoiceAssistantManager } from './voice-assistant.js';
24
- import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, writeDaemonInfo, removeDaemonInfo, readDaemonInfo, } from './session-registry.js';
41
+ import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, writeDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getOwnTimelineFile, } from './session-registry.js';
25
42
  import { fetchUsageFromApi, hasOAuthToken, resetConsecutiveFailures } from './usage-api.js';
26
43
  import { isLocalConnection, validateToken } from './auth.js';
27
44
  import { getLastFrame, renderPreviewFrame, onFrameRendered, offFrameRendered } from './pixoo/pixoo-bridge.js';
45
+ import { loadIDotMatrixDevices } from './idotmatrix/idotmatrix-settings.js';
28
46
  import { handlePixooWake } from './pixoo/pixoo-client.js';
29
47
  import { triggerMdnsRecovery } from './mdns.js';
30
48
  import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
31
49
  import { enableDebugLog, debug } from './logger.js';
32
- import { initApme } from './apme/index.js';
50
+ import { initApme, isTimelineProjectionEnabled, loadApmeConfig } from './apme/index.js';
51
+ import { FallbackTaskTimeline } from './fallback-task-timeline.js';
33
52
  import { handleApmeRequest } from './apme/http.js';
53
+ import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
54
+ import { transcriptTimelineForSession, lastAssistantTextFromTranscript } from './session-transcript-timeline.js';
55
+ import { lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
56
+ import { callFoundationModelsHelper } from './foundation-models-helper.js';
34
57
  import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
35
- import { esp32ConnectionCount, getESP32DeviceInfo, onESP32Message, sendWifiProvisionToAll, handleESP32Wake } from './esp32-serial.js';
58
+ import { esp32ConnectionCount, getESP32DeviceInfo, onESP32Message, sendWifiProvisionToAll, handleESP32Wake, getESP32Ports, getSerialConnectionStatus, getSerialLastError, getSerialReachableBoards } from './esp32-serial.js';
36
59
  import { loadWifiConfig } from './wifi-config.js';
37
- import { getConnectedAdbDevices, hasAdb } from './adb-reverse.js';
60
+ import { getConnectedAdbDevices, hasAdb, getAdbDeviceCount } from './adb-reverse.js';
38
61
  import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js';
39
- import { getLanIp } from '@agentdeck/shared';
40
- import { readFileSync } from 'fs';
62
+ import { loadTimeboxDevices } from './timebox/timebox-settings.js';
63
+ import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail, normalizeCommandPrompt, formatDurationSec } from '@agentdeck/shared';
64
+ import { injectOpenClawSession } from './openclaw-session.js';
65
+ import { readFileSync, statSync } from 'fs';
41
66
  import { join } from 'path';
42
67
  import { homedir } from 'os';
43
68
  import { OPENCLAW_CAPABILITIES, State, } from './types.js';
44
- function loadDaemonSettings() {
45
- try {
46
- return JSON.parse(readFileSync(join(homedir(), '.agentdeck', 'settings.json'), 'utf-8'));
69
+ function exitProcessNow(code = 0) {
70
+ if (code === 0) {
71
+ try {
72
+ process.kill(process.pid, 'SIGKILL');
73
+ return;
74
+ }
75
+ catch {
76
+ // fall through
77
+ }
78
+ }
79
+ process.exit(code);
80
+ }
81
+ const wifiEsp32Devices = new Map();
82
+ const wifiEsp32Sockets = new Map();
83
+ const otaWaiters = new Map();
84
+ function listWifiEsp32Devices() {
85
+ const now = Date.now();
86
+ const serialBoards = getSerialReachableBoards();
87
+ const out = [];
88
+ for (const [key, d] of wifiEsp32Devices) {
89
+ if (now - d.lastSeenMs > 60 * 60 * 1000) {
90
+ wifiEsp32Devices.delete(key);
91
+ wifiEsp32Sockets.delete(key);
92
+ continue;
93
+ }
94
+ // serialActive: this WiFi board is also live on USB serial, so its WiFi path
95
+ // is a hot standby — display events are driven over serial (single path).
96
+ const serialActive = isWifiTransportRedundant({ board: d.board, ip: d.ip }, serialBoards);
97
+ out.push({ ...d, stale: now - d.lastSeenMs > 90_000, serialActive });
98
+ }
99
+ return out;
100
+ }
101
+ function wifiEsp32Key(d) {
102
+ const board = typeof d.board === 'string' && d.board ? d.board : 'unknown';
103
+ const ip = typeof d.ip === 'string' && d.ip ? d.ip : 'no-ip';
104
+ return `${board}:${ip}`;
105
+ }
106
+ /**
107
+ * Single-path transport dedup. A physical ESP32 can be reachable over BOTH a
108
+ * USB serial connection and a WiFi WebSocket at once (e.g. inkdeck/ttgo/tc001
109
+ * plugged in for flashing while still joined to the AP). Serial is the more
110
+ * reliable, lower-latency path, so when a board is live on serial we drive it
111
+ * over serial only and suppress the redundant WiFi copy — no board receives the
112
+ * same event twice.
113
+ *
114
+ * Match is by board id, confirmed by the board's WiFi IP when both sides report
115
+ * one. If the serial side reports no IP (radio parked / pre-DHCP) the board id
116
+ * is the only shared key and we still prefer serial. Pure + exported for tests.
117
+ */
118
+ export function isWifiTransportRedundant(wifi, serialBoards) {
119
+ if (!wifi || !wifi.board || wifi.board === 'unknown')
120
+ return false;
121
+ for (const s of serialBoards) {
122
+ if (s.board !== wifi.board)
123
+ continue;
124
+ // Same board type on serial: dedup unless both report IPs that differ
125
+ // (two distinct physical units of the same board model).
126
+ if (!s.ip || !wifi.ip || s.ip === wifi.ip)
127
+ return true;
128
+ }
129
+ return false;
130
+ }
131
+ /** Reverse-lookup a WiFi ESP32 socket to its registered {board, ip} identity. */
132
+ function wifiEsp32IdentityForSocket(ws) {
133
+ for (const [key, registeredWs] of wifiEsp32Sockets) {
134
+ if (registeredWs !== ws)
135
+ continue;
136
+ const d = wifiEsp32Devices.get(key);
137
+ return d ? { board: d.board, ip: d.ip } : null;
138
+ }
139
+ return null;
140
+ }
141
+ /** True when this WiFi ESP32 socket's board is also live on USB serial, so its
142
+ * WiFi event copy should be suppressed in favour of the serial path. */
143
+ function isWifiEsp32RedundantWithSerial(ws) {
144
+ return isWifiTransportRedundant(wifiEsp32IdentityForSocket(ws), getSerialReachableBoards());
145
+ }
146
+ function registerWifiEsp32(d, ws) {
147
+ const key = wifiEsp32Key(d);
148
+ const previous = wifiEsp32Devices.get(key);
149
+ const uptimeSec = typeof d.uptimeSec === 'number' ? d.uptimeSec : undefined;
150
+ const resetReason = typeof d.resetReason === 'string' ? d.resetReason : undefined;
151
+ const resetReasonCode = typeof d.resetReasonCode === 'number' ? d.resetReasonCode : undefined;
152
+ wifiEsp32Devices.set(key, {
153
+ board: typeof d.board === 'string' ? d.board : 'unknown',
154
+ version: typeof d.version === 'string' ? d.version : undefined,
155
+ buildHash: typeof d.buildHash === 'string' ? d.buildHash : undefined,
156
+ buildEpoch: typeof d.buildEpoch === 'number' ? d.buildEpoch : undefined,
157
+ ip: typeof d.ip === 'string' ? d.ip : undefined,
158
+ protocolRevision: typeof d.protocolRevision === 'number' ? d.protocolRevision : undefined,
159
+ uptimeSec,
160
+ resetReason,
161
+ resetReasonCode,
162
+ otaSupported: typeof d.otaSupported === 'boolean' ? d.otaSupported : undefined,
163
+ otaSlotCount: typeof d.otaSlotCount === 'number' ? d.otaSlotCount : undefined,
164
+ otaSlotSize: typeof d.otaSlotSize === 'number' ? d.otaSlotSize : undefined,
165
+ otaFreeSketchSpace: typeof d.otaFreeSketchSpace === 'number' ? d.otaFreeSketchSpace : undefined,
166
+ otaReason: typeof d.otaReason === 'string' ? d.otaReason : undefined,
167
+ lastSeenMs: Date.now(),
168
+ });
169
+ wifiEsp32Sockets.set(key, ws);
170
+ if (previous?.uptimeSec != null && uptimeSec != null && uptimeSec + 10 < previous.uptimeSec) {
171
+ log(`[agentdeck] ESP32 reboot observed: ${key} uptime ${previous.uptimeSec}s -> ${uptimeSec}s reset=${resetReason ?? 'unknown'} code=${resetReasonCode ?? 'unknown'}`);
172
+ }
173
+ debug('daemon', `WiFi ESP32 registered: ${key} v${String(d.version ?? '')} build=${String(d.buildHash ?? '')} uptime=${String(uptimeSec ?? '')} reset=${String(resetReason ?? '')}`);
174
+ }
175
+ function touchWifiEsp32Socket(ws) {
176
+ const now = Date.now();
177
+ for (const [key, registeredWs] of wifiEsp32Sockets) {
178
+ if (registeredWs !== ws)
179
+ continue;
180
+ const device = wifiEsp32Devices.get(key);
181
+ if (device)
182
+ device.lastSeenMs = now;
183
+ return;
184
+ }
185
+ }
186
+ function unregisterWifiEsp32Socket(ws) {
187
+ for (const [key, registeredWs] of wifiEsp32Sockets) {
188
+ if (registeredWs !== ws)
189
+ continue;
190
+ wifiEsp32Sockets.delete(key);
191
+ // Keep the last device_info snapshot until normal age-out. A reconnect after
192
+ // a board reboot must compare the new uptime against the previous one; if we
193
+ // delete here every crash looks like a fresh device.
194
+ debug('daemon', `WiFi ESP32 disconnected: ${key}`);
195
+ }
196
+ }
197
+ function handleEsp32OtaReply(msg) {
198
+ if (msg.type !== 'esp32_ota_ack' && msg.type !== 'esp32_ota_error')
199
+ return false;
200
+ const otaId = typeof msg.otaId === 'string' ? msg.otaId : '';
201
+ const waiter = otaId ? otaWaiters.get(otaId) : undefined;
202
+ if (!waiter)
203
+ return true;
204
+ if (msg.type === 'esp32_ota_error') {
205
+ clearTimeout(waiter.timer);
206
+ otaWaiters.delete(otaId);
207
+ waiter.reject(new Error(typeof msg.error === 'string' ? msg.error : 'esp32_ota_error'));
208
+ return true;
209
+ }
210
+ const stage = typeof msg.stage === 'string' ? msg.stage : '';
211
+ const seq = typeof msg.seq === 'number' ? msg.seq : undefined;
212
+ if (stage !== waiter.stage)
213
+ return true;
214
+ if (waiter.seq != null && seq !== waiter.seq)
215
+ return true;
216
+ clearTimeout(waiter.timer);
217
+ otaWaiters.delete(otaId);
218
+ waiter.resolve(msg);
219
+ return true;
220
+ }
221
+ function waitForOtaAck(otaId, stage, seq, timeoutMs) {
222
+ return new Promise((resolve, reject) => {
223
+ const timer = setTimeout(() => {
224
+ otaWaiters.delete(otaId);
225
+ reject(new Error(`OTA ${stage}${seq != null ? ` #${seq}` : ''} timed out`));
226
+ }, timeoutMs);
227
+ otaWaiters.set(otaId, { stage, seq, resolve, reject, timer });
228
+ });
229
+ }
230
+ function findWifiOtaTarget(target) {
231
+ const matches = [];
232
+ for (const [key, device] of wifiEsp32Devices) {
233
+ const ws = wifiEsp32Sockets.get(key);
234
+ if (!ws || ws.readyState !== WebSocket.OPEN)
235
+ continue;
236
+ if (key === target || device.board === target || device.ip === target) {
237
+ matches.push({ key, device, ws });
238
+ }
239
+ }
240
+ if (matches.length === 0)
241
+ throw new Error(`No online WiFi ESP32 target matches "${target}"`);
242
+ if (matches.length > 1)
243
+ throw new Error(`Target "${target}" is ambiguous: ${matches.map(m => m.key).join(', ')}`);
244
+ return matches[0];
245
+ }
246
+ async function readJsonBody(req, maxBytes = 64 * 1024) {
247
+ const chunks = [];
248
+ let total = 0;
249
+ for await (const chunk of req) {
250
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
251
+ total += buf.length;
252
+ if (total > maxBytes)
253
+ throw new Error('request_body_too_large');
254
+ chunks.push(buf);
47
255
  }
48
- catch {
256
+ if (chunks.length === 0)
49
257
  return {};
258
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
259
+ }
260
+ // WiFi OTA ack timeouts. The transport is a WebSocket over TCP, so a chunk/ack
261
+ // is never lost while the connection is alive — a "timeout" means the board is
262
+ // slow (a classic ESP32 like TTGO/TC001 can stall past several seconds on a
263
+ // flash-sector erase or a brief WiFi-stack starvation) or the WiFi link briefly
264
+ // blipped and TCP is retransmitting. Both recover if we wait, so the timeouts
265
+ // are generous: a healthy board still acks in milliseconds, and these only cap
266
+ // how long we ride out a stall before declaring the transfer dead. Resending on
267
+ // the SAME live socket would be wrong — it desyncs the firmware's strict
268
+ // seq/offset cursor (handleOtaChunk → "unexpected_offset"); a timeout followed
269
+ // by a board RECONNECT is the one case where resend is correct (see
270
+ // performWifiEsp32Ota — the board never processed the lost chunk, and its otaRx
271
+ // cursor persisted across the reconnect). `let` (not const) so tests can shrink
272
+ // them via __setOtaTimeoutsForTest; production never mutates them.
273
+ let OTA_BEGIN_ACK_TIMEOUT_MS = 15_000; // erasing/preparing the target OTA slot
274
+ let OTA_CHUNK_ACK_TIMEOUT_MS = 30_000; // slow classic-ESP32 flash write / WiFi stall
275
+ let OTA_END_ACK_TIMEOUT_MS = 30_000; // whole-image MD5 verify + set-boot-partition
276
+ // How long to wait for a board to re-register a live WS after it drops its
277
+ // socket mid-OTA, and the cap on such reconnect-driven resends per transfer.
278
+ // Serial-backtrace diagnosis of the TTGO 2.5MB WiFi OTA showed the board does
279
+ // NOT reset/brownout/watchdog mid-transfer — it survives, prints "[WS]
280
+ // Disconnected", and re-registers under the SAME board:ip key with a NEW socket
281
+ // ~3-4s later (classic single-core ESP32 WiFi-flash coexistence briefly starves
282
+ // the TCP task → the connection drops, not the board). The old code captured the
283
+ // socket once and kept sending into the dead one, so the transfer stalled at the
284
+ // disconnect and failed 30s later on the chunk-ack timeout. Following the board
285
+ // to its reconnected socket lets the transfer resume where it stalled, because
286
+ // acks route by otaId (socket-independent) and the firmware keeps its otaRx
287
+ // cursor across the reconnect.
288
+ let OTA_RECONNECT_WAIT_MS = 20_000;
289
+ const OTA_MAX_RECONNECT_RESENDS = 12;
290
+ // ── Test-only seams for the WiFi-OTA reconnect-follow path ───────────────────
291
+ // The reconnect-follow bug is a socket-lifecycle race that hardware reproduces
292
+ // only probabilistically (a WiFi-flash coexistence drop). These exports let a
293
+ // deterministic unit test register/replace a board's socket mid-transfer and
294
+ // drive acks by otaId, with the ack timeouts shrunk so the drop→timeout→resend
295
+ // cycle runs in milliseconds. Not used by production code.
296
+ /** @internal */
297
+ export function __setOtaTimeoutsForTest(t) {
298
+ if (t.begin != null)
299
+ OTA_BEGIN_ACK_TIMEOUT_MS = t.begin;
300
+ if (t.chunk != null)
301
+ OTA_CHUNK_ACK_TIMEOUT_MS = t.chunk;
302
+ if (t.end != null)
303
+ OTA_END_ACK_TIMEOUT_MS = t.end;
304
+ if (t.reconnectWait != null)
305
+ OTA_RECONNECT_WAIT_MS = t.reconnectWait;
306
+ }
307
+ /** @internal */
308
+ export function __resetWifiEsp32OtaState() {
309
+ for (const [, w] of otaWaiters)
310
+ clearTimeout(w.timer);
311
+ otaWaiters.clear();
312
+ wifiEsp32Devices.clear();
313
+ wifiEsp32Sockets.clear();
314
+ OTA_BEGIN_ACK_TIMEOUT_MS = 15_000;
315
+ OTA_CHUNK_ACK_TIMEOUT_MS = 30_000;
316
+ OTA_END_ACK_TIMEOUT_MS = 30_000;
317
+ OTA_RECONNECT_WAIT_MS = 20_000;
318
+ }
319
+ /** @internal */
320
+ export const __wifiOtaTestApi = {
321
+ registerWifiEsp32,
322
+ unregisterWifiEsp32Socket,
323
+ handleEsp32OtaReply,
324
+ performWifiEsp32Ota,
325
+ };
326
+ async function performWifiEsp32Ota(core, target, firmwarePath) {
327
+ const { key, device } = findWifiOtaTarget(target);
328
+ if (device.otaSupported !== true) {
329
+ throw new Error(`Target ${key} does not report OTA support${device.otaReason ? ` (${device.otaReason})` : ''}`);
330
+ }
331
+ const firmware = readFileSync(firmwarePath);
332
+ if (device.otaSlotSize && firmware.length > device.otaSlotSize) {
333
+ throw new Error(`Firmware is ${firmware.length} bytes, OTA slot is ${device.otaSlotSize} bytes`);
334
+ }
335
+ const otaId = randomUUID();
336
+ const md5 = createHash('md5').update(firmware).digest('hex');
337
+ // Always resolve the board's CURRENTLY-registered socket, never a captured
338
+ // reference — the board re-registers a fresh socket on a mid-OTA reconnect.
339
+ const liveSocket = () => {
340
+ const s = wifiEsp32Sockets.get(key);
341
+ return s && s.readyState === WebSocket.OPEN ? s : undefined;
342
+ };
343
+ // Wait up to ms for a live socket (rides out the ~3-4s reconnect gap).
344
+ const awaitLiveSocket = async (ms) => {
345
+ const deadline = Date.now() + ms;
346
+ let s = liveSocket();
347
+ while (!s && Date.now() < deadline) {
348
+ await new Promise((r) => setTimeout(r, 200));
349
+ s = liveSocket();
350
+ }
351
+ return s;
352
+ };
353
+ const sendVia = (sock, evt) => core.wsServer.sendTo(sock, evt);
354
+ let reconnectResends = 0;
355
+ // Send one OTA frame and wait for its ack, following the board to a fresh
356
+ // socket if it reconnects mid-flight. On an ack timeout, if a NEW live socket
357
+ // has appeared (the board reconnected), the frame was lost to the dead socket
358
+ // — resend the same seq on the new socket once. The firmware's otaRx cursor
359
+ // persists across the reconnect, so a resend of the not-yet-processed seq is
360
+ // accepted; a genuinely-gone board still fails after OTA_RECONNECT_WAIT_MS.
361
+ const sendAndAck = async (evt, stage, seq, timeoutMs) => {
362
+ let sock = await awaitLiveSocket(OTA_RECONNECT_WAIT_MS);
363
+ if (!sock)
364
+ throw new Error(`OTA ${stage}${seq != null ? ` #${seq}` : ''}: board ${key} offline (no live WS)`);
365
+ sendVia(sock, evt);
366
+ try {
367
+ await waitForOtaAck(otaId, stage, seq, timeoutMs);
368
+ }
369
+ catch (ackErr) {
370
+ const fresh = await awaitLiveSocket(OTA_RECONNECT_WAIT_MS);
371
+ if (fresh && fresh !== sock && reconnectResends < OTA_MAX_RECONNECT_RESENDS) {
372
+ reconnectResends++;
373
+ debug('daemon', `OTA ${key}: ${stage}${seq != null ? ` #${seq}` : ''} ack lost on dropped socket — resending on reconnected WS (resend ${reconnectResends})`);
374
+ sendVia(fresh, evt);
375
+ await waitForOtaAck(otaId, stage, seq, timeoutMs);
376
+ return;
377
+ }
378
+ throw ackErr;
379
+ }
380
+ };
381
+ try {
382
+ await sendAndAck({ type: 'esp32_ota_begin', otaId, size: firmware.length, md5 }, 'begin', undefined, OTA_BEGIN_ACK_TIMEOUT_MS);
383
+ const chunkSize = 1024;
384
+ let offset = 0;
385
+ let seq = 0;
386
+ while (offset < firmware.length) {
387
+ const chunk = firmware.subarray(offset, Math.min(offset + chunkSize, firmware.length));
388
+ await sendAndAck({
389
+ type: 'esp32_ota_chunk',
390
+ otaId,
391
+ seq,
392
+ offset,
393
+ data: chunk.toString('base64'),
394
+ }, 'chunk', seq, OTA_CHUNK_ACK_TIMEOUT_MS);
395
+ offset += chunk.length;
396
+ seq++;
397
+ }
398
+ await sendAndAck({ type: 'esp32_ota_end', otaId }, 'end', undefined, OTA_END_ACK_TIMEOUT_MS);
399
+ wifiEsp32Sockets.delete(key);
400
+ wifiEsp32Devices.delete(key);
401
+ return {
402
+ ok: true,
403
+ target: key,
404
+ board: device.board,
405
+ bytes: firmware.length,
406
+ chunks: seq,
407
+ reconnectResends,
408
+ md5,
409
+ };
410
+ }
411
+ catch (err) {
412
+ const sock = liveSocket();
413
+ if (sock) {
414
+ try {
415
+ sendVia(sock, { type: 'esp32_ota_abort', otaId });
416
+ }
417
+ catch { /* best effort */ }
418
+ }
419
+ throw err;
50
420
  }
51
421
  }
422
+ function loadDaemonSettings() {
423
+ // Newest settings.json across candidate data dirs — mirrors the
424
+ // daemon.json/sessions.json cross-dir discovery (and honors the
425
+ // AGENTDECK_DATA_DIR test override, which the old hardcoded
426
+ // ~/.agentdeck path ignored). The App Store sandbox container is
427
+ // intentionally NOT a candidate (TCC hang risk — see
428
+ // getCandidateDataDirs), so settings written by the sandboxed Swift
429
+ // app stay invisible here; that coexistence limit is documented in
430
+ // docs/appstore-feature-matrix.md.
431
+ let best = null;
432
+ for (const dir of getCandidateDataDirs()) {
433
+ try {
434
+ const path = join(dir, 'settings.json');
435
+ const mtime = statSync(path).mtimeMs;
436
+ if (best && best.mtime >= mtime)
437
+ continue;
438
+ best = { mtime, parsed: JSON.parse(readFileSync(path, 'utf-8')) };
439
+ }
440
+ catch {
441
+ // Missing or unreadable — skip this candidate.
442
+ }
443
+ }
444
+ return best?.parsed ?? {};
445
+ }
446
+ function latestTimelinePath() {
447
+ let best = null;
448
+ for (const dir of getCandidateDataDirs()) {
449
+ try {
450
+ const path = join(dir, 'timeline.json');
451
+ const mtime = statSync(path).mtimeMs;
452
+ if (!best || mtime > best.mtime)
453
+ best = { path, mtime };
454
+ }
455
+ catch {
456
+ // Missing or unreadable — skip this candidate.
457
+ }
458
+ }
459
+ return best?.path ?? null;
460
+ }
461
+ // Observed-session attention history: the held PreToolUse device-approval gate
462
+ // was removed on 2026-06-27 and stays removed — PreToolUse fires even for tools
463
+ // Claude auto-approves, so gating on it produced false attention + a fabricated
464
+ // Allow/Deny that never matched Claude's real prompt. The *display-only*
465
+ // Notification overlay was restored on 2026-07-05: `notification_type:
466
+ // "permission_prompt"` fires only when a permission prompt is actually shown to
467
+ // the user, so it is a genuine awaiting signal. Observed sessions surface
468
+ // awaiting + question with NO requestId (respond-in-terminal UX); steering with
469
+ // real options still exists only on PTY-managed sessions (`agentdeck claude`).
52
470
  function log(msg) {
53
- process.stderr.write(msg + '\n');
471
+ // Timestamped like logger.ts — daemon stderr lands in a long-lived log file,
472
+ // and un-datable restart/incident lines repeatedly blocked root-cause work.
473
+ process.stderr.write(`${new Date().toISOString()} ${msg}\n`);
54
474
  }
55
475
  async function fetchUsageViaHttp(siblings) {
56
476
  for (const sibling of siblings) {
@@ -126,6 +546,60 @@ async function fetchUsageRelayed(selfPort) {
126
546
  debug('daemon', 'No siblings, using direct API');
127
547
  return fetchUsageFromApi();
128
548
  }
549
+ /**
550
+ * Stamp OpenClaw origin onto a timeline entry emitted by the Gateway adapter.
551
+ *
552
+ * The adapter emits bare entries (no agentType/projectName); without this the
553
+ * BridgeCore attributor falls back to the daemon's hardcoded projectName
554
+ * ('AgentDeck') and leaves agentType null, so OpenClaw cron activity gets
555
+ * mis-grouped under AgentDeck and never renders as OpenClaw. `?? ` fallbacks
556
+ * preserve any value the adapter did set, and the downstream attributor's own
557
+ * `?? ` keeps these in turn.
558
+ */
559
+ export function enrichGatewayTimelineEntry(entry) {
560
+ return {
561
+ ...entry,
562
+ agentType: entry.agentType ?? 'openclaw',
563
+ projectName: entry.projectName ?? 'OpenClaw',
564
+ };
565
+ }
566
+ /**
567
+ * Classify a `/hooks/<eventName>` POST for the observed-session pipeline.
568
+ *
569
+ * Claude hooks arrive as PascalCase names (already snake_cased by the
570
+ * caller's eventMap → `mapped`); Codex lifecycle hooks (installed into
571
+ * `~/.codex/config.toml`) arrive as `codex_*`; the AgentDeck OpenCode
572
+ * observer plugin posts `opencode_*`. All three share one pipeline —
573
+ * APME run/turn management, the chat_start row, and the stop-completion
574
+ * row — keyed off the returned agent-neutral `boundary`, so a direct
575
+ * `codex` or standalone `opencode` run gets the same prompt → response
576
+ * turn shape on the timeline as a direct `claude` run. (The Swift daemon
577
+ * has carried Codex parity since its observation pass —
578
+ * DaemonServer.swift appendCodexChatStart/End; this closes the same gap
579
+ * on the Node daemon.)
580
+ *
581
+ * Codex notify's `turn_complete` maps to `stop`: it closes a turn the
582
+ * same way, and the caller's open-turn guard collapses a stop +
583
+ * turn_complete pair into a single completion row.
584
+ *
585
+ * `antigravity_*` is accepted for forward-compatibility: Antigravity has
586
+ * no hook installer yet (the IDE exposes no lifecycle hook config; its
587
+ * sessions surface via PassiveSessionObserver transcript polling), but
588
+ * anything that starts POSTing these names gets full turn parity with no
589
+ * daemon change.
590
+ */
591
+ export function classifyObservedHookEvent(eventName, mapped) {
592
+ const prefixed = /^(codex|opencode|antigravity)_(session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification|permission_asked|permission_replied)$/
593
+ .exec(eventName);
594
+ if (!prefixed)
595
+ return { boundary: mapped, agentType: 'claude-code' };
596
+ return {
597
+ boundary: prefixed[2] === 'turn_complete' ? 'stop' : prefixed[2],
598
+ agentType: prefixed[1] === 'codex' ? 'codex-cli'
599
+ : prefixed[1] === 'opencode' ? 'opencode'
600
+ : 'antigravity',
601
+ };
602
+ }
129
603
  function buildNodeModuleHealth(startedModules) {
130
604
  const started = new Set(startedModules.map((m) => m.name));
131
605
  const modules = {};
@@ -140,10 +614,6 @@ function buildNodeModuleHealth(startedModules) {
140
614
  lastError: adbAvailable ? null : 'adb not found',
141
615
  };
142
616
  }
143
- const d200h = startedModules.find((m) => m.name === 'd200h');
144
- if (d200h?.statusSnapshot) {
145
- modules.d200h = d200h.statusSnapshot();
146
- }
147
617
  if (started.has('pixoo') || pixooDeviceCount() > 0) {
148
618
  const details = getPixooDeviceDetails();
149
619
  modules.pixoo = {
@@ -160,21 +630,72 @@ function buildNodeModuleHealth(startedModules) {
160
630
  })),
161
631
  };
162
632
  }
633
+ const timebox = startedModules.find((m) => m.name === 'timebox');
634
+ const configuredTimebox = loadTimeboxDevices();
635
+ if (timebox?.statusSnapshot) {
636
+ modules.timebox = timebox.statusSnapshot();
637
+ }
638
+ else if (configuredTimebox.length > 0) {
639
+ modules.timebox = {
640
+ configuredDeviceCount: configuredTimebox.length,
641
+ devices: configuredTimebox.map((d) => ({
642
+ address: d.address,
643
+ name: d.name ?? 'Timebox Mini',
644
+ brightness: d.brightness ?? 100,
645
+ })),
646
+ };
647
+ }
648
+ const idotmatrix = startedModules.find((m) => m.name === 'idotmatrix');
649
+ const configuredIDotMatrix = loadIDotMatrixDevices();
650
+ if (idotmatrix?.statusSnapshot) {
651
+ modules.idotmatrix = idotmatrix.statusSnapshot();
652
+ }
653
+ else if (configuredIDotMatrix.length > 0) {
654
+ modules.idotmatrix = {
655
+ configuredDeviceCount: configuredIDotMatrix.length,
656
+ devices: configuredIDotMatrix.map((d) => ({
657
+ address: d.address,
658
+ name: d.name ?? 'iDotMatrix',
659
+ brightness: d.brightness ?? 100,
660
+ })),
661
+ };
662
+ }
163
663
  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
- },
664
+ const connectionStatus = getSerialConnectionStatus();
665
+ const connections = connectionStatus.map((status) => ({
666
+ port: status.port,
667
+ connected: status.connected,
668
+ transportOpen: status.transportOpen,
669
+ deviceInfo: status.board ? {
670
+ board: status.board,
671
+ version: status.version,
672
+ buildHash: status.buildHash,
673
+ buildEpoch: status.buildEpoch,
674
+ wifiConfigured: status.wifiConfigured,
675
+ wifiConnected: status.wifiConnected,
676
+ wifiRadioParked: status.wifiRadioParked,
677
+ uptimeSec: status.uptimeSec,
678
+ otaSupported: status.otaSupported,
679
+ otaSlotCount: status.otaSlotCount,
680
+ otaSlotSize: status.otaSlotSize,
681
+ otaFreeSketchSpace: status.otaFreeSketchSpace,
682
+ otaReason: status.otaReason,
683
+ timelineCount: status.timelineCount,
684
+ sessionCount: status.sessionCount,
685
+ usageFiveH: status.usageFiveH,
686
+ processingCount: status.processingCount,
687
+ } : null,
688
+ lastReadAt: status.lastReadAt,
689
+ lastWriteAt: status.lastWriteAt,
690
+ lastReadSecondsAgo: status.lastReadSecondsAgo,
691
+ lastWriteSecondsAgo: status.lastWriteSecondsAgo,
692
+ stale: status.stale,
173
693
  }));
174
694
  modules.serial = {
175
- connectedPorts: connections.map((c) => c.port),
695
+ connectedPorts: connections.filter((c) => c.connected).map((c) => c.port),
176
696
  connections,
177
- lastError: null,
697
+ lastError: getSerialLastError(),
698
+ connectionCount: connections.filter((c) => c.connected).length,
178
699
  };
179
700
  }
180
701
  return modules;
@@ -182,7 +703,7 @@ function buildNodeModuleHealth(startedModules) {
182
703
  // ===== startDaemon =====
183
704
  export async function startDaemon(opts) {
184
705
  if (opts.debug) {
185
- enableDebugLog('/tmp/agentdeck-debug.log');
706
+ enableDebugLog();
186
707
  log('[agentdeck] Debug logging enabled');
187
708
  }
188
709
  // CLI --wake-word flag OR settings.json wakeWord: true
@@ -192,13 +713,44 @@ export async function startDaemon(opts) {
192
713
  // 1. Check daemon.json and sessions.json for existing daemon
193
714
  const existingInfo = readDaemonInfo();
194
715
  if (existingInfo) {
195
- log(`[agentdeck] Daemon already running on port ${existingInfo.port} (PID ${existingInfo.pid}).`);
196
- process.exit(0);
716
+ const probePort = existingInfo.httpPort ?? existingInfo.port;
717
+ const health = await probeDaemonHealth(probePort);
718
+ if (health?.mode === 'daemon') {
719
+ if (health.isSwift) {
720
+ log(`[agentdeck] Swift daemon detected on port ${probePort}. Requesting shutdown to take over...`);
721
+ await requestDaemonShutdown(probePort);
722
+ await waitForDaemonExit(probePort);
723
+ removeDaemonInfo();
724
+ }
725
+ else {
726
+ log(`[agentdeck] Daemon already running on port ${existingInfo.port} (PID ${existingInfo.pid}).`);
727
+ process.exit(0);
728
+ }
729
+ }
730
+ else {
731
+ log(`[agentdeck] Ignoring stale daemon entry on port ${existingInfo.port} (PID ${existingInfo.pid}; /health did not respond).`);
732
+ removeDaemonInfo();
733
+ }
197
734
  }
198
735
  const existingSession = findExistingDaemon();
199
736
  if (existingSession) {
200
- log(`[agentdeck] Daemon already running on port ${existingSession.port} (PID ${existingSession.pid}).`);
201
- process.exit(0);
737
+ const health = await probeDaemonHealth(existingSession.port);
738
+ if (health?.mode === 'daemon') {
739
+ if (health.isSwift) {
740
+ log(`[agentdeck] Swift daemon detected on port ${existingSession.port}. Requesting shutdown to take over...`);
741
+ await requestDaemonShutdown(existingSession.port);
742
+ await waitForDaemonExit(existingSession.port);
743
+ removeDaemonSession(existingSession);
744
+ }
745
+ else {
746
+ log(`[agentdeck] Daemon already running on port ${existingSession.port} (PID ${existingSession.pid}).`);
747
+ process.exit(0);
748
+ }
749
+ }
750
+ else {
751
+ log(`[agentdeck] Ignoring stale daemon session on port ${existingSession.port} (PID ${existingSession.pid}; /health did not respond).`);
752
+ removeDaemonSession(existingSession);
753
+ }
202
754
  }
203
755
  // 2. Determine port — try default first, fallback if occupied by non-daemon
204
756
  const requestedPort = opts.port ?? DAEMON_DEFAULT_PORT;
@@ -208,24 +760,114 @@ export async function startDaemon(opts) {
208
760
  const health = await probeDaemonHealth(requestedPort);
209
761
  if (health) {
210
762
  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);
763
+ if (health.isSwift) {
764
+ log(`[agentdeck] Swift daemon detected on port ${requestedPort} via /health. Requesting shutdown to take over...`);
765
+ await requestDaemonShutdown(requestedPort);
766
+ await waitForDaemonExit(requestedPort);
767
+ }
768
+ else {
769
+ // Daemon alive but not in our registry — race condition or stale state
770
+ log(`[agentdeck] Daemon already running on port ${requestedPort} (detected via /health).`);
771
+ process.exit(0);
772
+ }
214
773
  }
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();
774
+ else {
775
+ // Port occupied by non-daemon (e.g. session bridge) find alternative
776
+ log(`[agentdeck] Port ${requestedPort} occupied (${health.mode ?? 'unknown'}), finding alternative...`);
777
+ port = await findAvailablePort();
778
+ }
779
+ }
780
+ }
781
+ // 3. Cross-implementation sweep of the daemon port window. File discovery
782
+ // has two blind spots that the checks above never cover: the App Store
783
+ // Swift daemon writes daemon.json into its private sandbox container (this
784
+ // process cannot read it), and transient 9120 contention can leave a daemon
785
+ // sitting on a fallback port (9121+). Missing it here means two live
786
+ // daemons: double mDNS advertising, duplicate Gateway/timeline relay, and
787
+ // adb-reverse flapping. Swift occupants are evicted (same policy as above);
788
+ // a live Node daemon wins and we exit.
789
+ const strayDaemons = await scanDaemonPortWindow(new Set([requestedPort]));
790
+ for (const stray of strayDaemons) {
791
+ if (stray.health.isSwift) {
792
+ log(`[agentdeck] Swift daemon detected on fallback port ${stray.port}. Requesting shutdown to take over...`);
793
+ await requestDaemonShutdown(stray.port);
794
+ await waitForDaemonExit(stray.port);
795
+ }
796
+ else if (shouldConcedePortToOccupant(stray.health, process.pid)) {
797
+ log(`[agentdeck] Daemon already running on port ${stray.port} (detected via port scan).`);
798
+ process.exit(0);
218
799
  }
219
800
  }
220
801
  log(`[agentdeck] Starting daemon on port ${port}...`);
221
802
  // ===== APME (lazy — may be null if better-sqlite3 isn't installed) =====
222
803
  let apme = null;
804
+ // Fallback task-row emitter, created only when initApme() returns null —
805
+ // keeps task_start/task_end rows on the timeline without the collector.
806
+ let fallbackTasks = null;
807
+ // Sessions that posted any hook since this daemon started — the delayed
808
+ // orphan-chat reaper must never force-close a turn belonging to a session
809
+ // that is provably alive (agentic turns regularly outlive any fixed age
810
+ // threshold). Declared before the HTTP handlers that populate it.
811
+ const hookSessionsSeen = new Set();
223
812
  // Declare early — HTTP /health handler references this in its closure.
224
813
  // Must be declared before the HTTP server so it's initialized (not in TDZ)
225
814
  // when the first /health request arrives.
226
815
  let gatewayAdapter = null;
227
816
  let gatewayConnecting = false;
228
817
  let moduleHealthProvider = () => ({});
818
+ // Wi-Fi WebSocket e-ink panels (XTeink X3/X4 CrossPoint fork) self-register
819
+ // their device roster via `client_register{clientType:"eink-device"}` — the
820
+ // same volunteer model as the Stream Deck plugin. This handler lived ONLY in
821
+ // the Swift daemon, so when the dashboard talked to the Node daemon (the
822
+ // common case) these devices never surfaced on the Downstream E-ink rail.
823
+ // Keyed by socket; a registration drops the moment its socket closes
824
+ // (filtered lazily in `collectEinkDevices` on readyState), so no separate
825
+ // TTL sweep is needed.
826
+ const einkRegistrations = new Map();
827
+ const collectEinkDevices = () => {
828
+ const out = [];
829
+ for (const [ws, reg] of einkRegistrations) {
830
+ if (ws.readyState !== WebSocket.OPEN) {
831
+ einkRegistrations.delete(ws);
832
+ continue;
833
+ }
834
+ for (const d of reg.devices)
835
+ out.push(d);
836
+ }
837
+ return out;
838
+ };
839
+ // Android dashboard apps (tablet / e-ink launcher) over Wi-Fi WS —
840
+ // `client_register{clientType:"android-dashboard"}`, same volunteer model.
841
+ const androidDashboardRegistrations = new Map();
842
+ const collectAndroidDashboards = () => {
843
+ const out = [];
844
+ for (const [ws, reg] of androidDashboardRegistrations) {
845
+ if (ws.readyState !== WebSocket.OPEN) {
846
+ androidDashboardRegistrations.delete(ws);
847
+ continue;
848
+ }
849
+ for (const d of reg.devices)
850
+ out.push(d);
851
+ }
852
+ return out;
853
+ };
854
+ // Elgato Stream Deck plugin — `client_register{clientType:"streamdeck-plugin"}`
855
+ // with the physical device roster. The Swift daemon has handled this since the
856
+ // Stream Deck topology row shipped; the Node daemon silently dropped it, so on
857
+ // the external-CLI tier the dashboard never showed a Stream Deck row at all.
858
+ const streamDeckRegistrations = new Map();
859
+ const collectStreamDeckDevices = () => {
860
+ const out = [];
861
+ for (const [ws, reg] of streamDeckRegistrations) {
862
+ if (ws.readyState !== WebSocket.OPEN) {
863
+ streamDeckRegistrations.delete(ws);
864
+ continue;
865
+ }
866
+ for (const d of reg.devices)
867
+ out.push(d);
868
+ }
869
+ return out;
870
+ };
229
871
  // ===== HTTP server =====
230
872
  const httpServer = createServer((req, res) => {
231
873
  // APME routes: auth-gated (task prompts, project paths, hook payloads are sensitive).
@@ -249,7 +891,8 @@ export async function startDaemon(opts) {
249
891
  });
250
892
  return;
251
893
  }
252
- const pathname = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname;
894
+ const parsedUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
895
+ const pathname = parsedUrl.pathname;
253
896
  // Health check is public (no auth) — used by iOS/Android for pairing token discovery
254
897
  if (req.method === 'GET' && pathname === '/health') {
255
898
  const snap = core.stateMachine.getSnapshot();
@@ -257,9 +900,16 @@ export async function startDaemon(opts) {
257
900
  res.end(JSON.stringify({
258
901
  status: 'ok', mode: 'daemon', state: snap.state,
259
902
  gateway: gatewayAdapter?.isAlive() ? 'connected' : 'disconnected',
260
- uptime: process.uptime(), port,
903
+ uptime: process.uptime(), port, pid: process.pid,
261
904
  pairingToken: core.authToken,
262
905
  modules: moduleHealthProvider(),
906
+ apme: apme
907
+ ? {
908
+ enabled: true,
909
+ dbPath: apme.store.dbPath,
910
+ judgeBackend: apme.runner.lastBackendProbe ?? { status: 'unknown', backend: loadApmeConfig().judge.backend },
911
+ }
912
+ : { enabled: false, error: apme === null ? 'see startup logs (initApme returned null)' : 'unknown' },
263
913
  }));
264
914
  return;
265
915
  }
@@ -282,7 +932,76 @@ export async function startDaemon(opts) {
282
932
  }
283
933
  if (req.method === 'GET' && pathname === '/devices') {
284
934
  res.writeHead(200, { 'Content-Type': 'application/json' });
285
- res.end(JSON.stringify({ devices: [], modules: moduleHealthProvider() }));
935
+ // Direct-HID D200H control was retired; the D200H is driven exclusively by
936
+ // the Ulanzi Studio plugin over WebSocket. Report it connected iff that
937
+ // plugin is attached.
938
+ const ulanziPluginConnected = core.wsServer.getUlanziClientCount() > 0;
939
+ res.end(JSON.stringify({
940
+ devices: [
941
+ { type: 'websocket', count: core.wsServer.getClientCount() },
942
+ { type: 'tui', devices: core.wsServer.getTuiClients() },
943
+ { type: 'esp32', count: esp32ConnectionCount(), ports: getESP32Ports(), devices: getESP32DeviceInfo() },
944
+ { type: 'esp32-wifi', devices: listWifiEsp32Devices() },
945
+ { type: 'pixoo', details: getPixooDeviceDetails() },
946
+ { type: 'timebox', devices: loadTimeboxDevices() },
947
+ { type: 'idotmatrix', devices: loadIDotMatrixDevices() },
948
+ { type: 'adb', count: getAdbDeviceCount() },
949
+ {
950
+ type: 'd200h',
951
+ connected: ulanziPluginConnected,
952
+ ulanziPluginConnected,
953
+ },
954
+ ],
955
+ modules: moduleHealthProvider(),
956
+ }));
957
+ return;
958
+ }
959
+ if (req.method === 'POST' && pathname === '/esp32/ota') {
960
+ (async () => {
961
+ const body = await readJsonBody(req);
962
+ const target = typeof body.target === 'string' ? body.target : '';
963
+ const firmwarePath = typeof body.firmwarePath === 'string' ? body.firmwarePath : '';
964
+ if (!target || !firmwarePath) {
965
+ throw new Error('target and firmwarePath are required');
966
+ }
967
+ return performWifiEsp32Ota(core, target, firmwarePath);
968
+ })().then((result) => {
969
+ res.writeHead(200, { 'Content-Type': 'application/json' });
970
+ res.end(JSON.stringify(result));
971
+ }).catch((err) => {
972
+ res.writeHead(500, { 'Content-Type': 'application/json' });
973
+ res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
974
+ });
975
+ return;
976
+ }
977
+ if (req.method === 'GET' && pathname === '/display-state') {
978
+ res.writeHead(200, {
979
+ 'Content-Type': 'application/json',
980
+ 'Cache-Control': 'no-store',
981
+ 'Access-Control-Allow-Origin': '*',
982
+ });
983
+ res.end(JSON.stringify(buildDisplayStateEvent(core.displayMonitor.isDisplayOn())));
984
+ return;
985
+ }
986
+ if (req.method === 'GET' && pathname === '/diag') {
987
+ const snap = core.stateMachine.getSnapshot();
988
+ res.writeHead(200, { 'Content-Type': 'application/json' });
989
+ res.end(JSON.stringify({
990
+ timestamp: new Date().toISOString(),
991
+ sessionInfo: {
992
+ state: snap.state,
993
+ permissionMode: snap.permissionMode,
994
+ suggestedPrompt: snap.suggestedPrompt,
995
+ lastValidSuggestedPrompt: core.stateMachine.getLastValidSuggestedPrompt(),
996
+ projectName: snap.projectName,
997
+ modelName: snap.modelName,
998
+ billingType: snap.billingType,
999
+ },
1000
+ wsClients: core.wsServer.getClientCount(),
1001
+ recentJournal: [],
1002
+ ptyTail: '',
1003
+ journalDir: join(homedir(), '.agentdeck'),
1004
+ }));
286
1005
  return;
287
1006
  }
288
1007
  if (req.method === 'GET' && pathname === '/pixoo/stream') {
@@ -319,8 +1038,14 @@ export async function startDaemon(opts) {
319
1038
  return;
320
1039
  }
321
1040
  if (req.method === 'GET' && pathname === '/pixoo/frame') {
322
- const rgb = getLastFrame() ?? renderPreviewFrame();
323
- const bmp = rgbToBmp(rgb, 64, 64);
1041
+ const sizeParam = parsedUrl.searchParams.get('size');
1042
+ const size = sizeParam === '11' ? 11 : sizeParam === '32' ? 32 : 64;
1043
+ const layout = parsedUrl.searchParams.get('layout') === 'micro' ? 'micro' : 'standard';
1044
+ // The frame cache holds the standard terrarium, so render micro fresh.
1045
+ const rgb = layout === 'micro'
1046
+ ? renderPreviewFrame(size, 'micro')
1047
+ : (getLastFrame(size) ?? renderPreviewFrame(size));
1048
+ const bmp = rgbToBmp(rgb, size, size);
324
1049
  res.writeHead(200, {
325
1050
  'Content-Type': 'image/bmp',
326
1051
  'Cache-Control': 'no-store',
@@ -362,6 +1087,13 @@ export async function startDaemon(opts) {
362
1087
  Notification: 'notification',
363
1088
  };
364
1089
  const mapped = eventMap[eventName] ?? eventName;
1090
+ // Agent-prefixed lifecycle hooks (codex_* / opencode_*) reuse the
1091
+ // same observed-session pipeline as Claude's PascalCase hooks —
1092
+ // `boundary` is the agent-neutral semantic every block below keys
1093
+ // off. State-machine calls stay Claude-only (`mapped`): observed
1094
+ // codex/opencode *state* is owned by the passive observer's turn
1095
+ // semantics, not these hooks.
1096
+ const { boundary, agentType: hookAgentType } = classifyObservedHookEvent(eventName, mapped);
365
1097
  // State machine
366
1098
  if (mapped === 'session_start')
367
1099
  core.stateMachine.handleHookEvent('SessionStart', json);
@@ -377,36 +1109,534 @@ export async function startDaemon(opts) {
377
1109
  else if (mapped === 'tool_end') {
378
1110
  core.stateMachine.handleHookEvent('PostToolUse', json);
379
1111
  }
380
- // APME collector
1112
+ // Per-session awaiting overlay (observed/direct-`claude` sessions),
1113
+ // display-only. Claude emits Notification with `notification_type:
1114
+ // "permission_prompt"` only when a permission prompt is actually shown
1115
+ // to the user (auto-approved tools fire PreToolUse but never this), so
1116
+ // it is a genuine "waiting for explicit response" signal — unlike the
1117
+ // removed PreToolUse gate. No requestId is ever set: surfaces render
1118
+ // awaiting + question with the respond-in-terminal fallback, never a
1119
+ // fabricated Allow/Deny. Keyed by Claude's own session_id and merged
1120
+ // into the observed-session list at enrich time (awaiting-overlay.ts).
1121
+ const claudeSid = typeof json.session_id === 'string' ? json.session_id : undefined;
1122
+ if (claudeSid) {
1123
+ if (mapped === 'notification') {
1124
+ const message = typeof json.message === 'string' ? json.message : '';
1125
+ const notificationType = typeof json.notification_type === 'string' ? json.notification_type : undefined;
1126
+ if (isPermissionNotification(notificationType, message)) {
1127
+ // A genuine terminal prompt appeared — recent undecided gate
1128
+ // releases were real prompts, so nothing gets learned as
1129
+ // auto-approved (observed-steering learner).
1130
+ notePermissionPromptShown(claudeSid);
1131
+ setAwaitingOverlay(claudeSid, message);
1132
+ // Broadcast immediately rather than waiting for the 2s debounce
1133
+ // or the 5s observer tick, so the prompt surfaces within one frame.
1134
+ core.broadcastSessionsList().catch(() => { });
1135
+ }
1136
+ }
1137
+ else if (mapped === 'tool_start' || mapped === 'tool_end' ||
1138
+ mapped === 'user_prompt_submit' || mapped === 'stop' ||
1139
+ mapped === 'session_start' || mapped === 'session_end') {
1140
+ // Steering lifecycle: a PostToolUse right after an undecided gate
1141
+ // release (no permission_prompt Notification in between) means
1142
+ // Claude auto-approved the call — learn the signature so it is
1143
+ // never held again this session. User prompt / session end clear
1144
+ // pending STOP + queued directives (the user took over).
1145
+ if (mapped === 'tool_end') {
1146
+ noteToolEnd(claudeSid, typeof json.tool_name === 'string' ? json.tool_name : undefined);
1147
+ }
1148
+ else if (mapped === 'user_prompt_submit') {
1149
+ if (clearOnUserPrompt(claudeSid))
1150
+ core.broadcastSessionsList().catch(() => { });
1151
+ }
1152
+ else if (mapped === 'session_end') {
1153
+ clearSteeringSession(claudeSid);
1154
+ }
1155
+ // Any subsequent hook means the prompt was answered — drop the
1156
+ // overlay. Only rebroadcast if there was actually one to clear
1157
+ // (direct-`claude` sessions fire tool hooks constantly).
1158
+ // EXCEPT while a held gate is still pending: parallel tool calls
1159
+ // fire their own PreToolUse/PostToolUse hooks, and clearing here
1160
+ // would strip the Allow/Deny UI out from under the open request
1161
+ // (its own onResolved clears the overlay when it settles).
1162
+ const heldOverlay = getAwaitingOverlay(claudeSid);
1163
+ const gateStillPending = Boolean(heldOverlay?.requestId && isPendingRequest(heldOverlay.requestId));
1164
+ if (!gateStillPending && clearAwaitingOverlay(claudeSid)) {
1165
+ core.broadcastSessionsList().catch(() => { });
1166
+ }
1167
+ }
1168
+ }
1169
+ // Observed-session activity log. Managed sessions relay their timeline
1170
+ // from the session bridge (AGENTDECK_PORT routes their hooks there),
1171
+ // but direct `claude`/`codex` runs only reach the daemon through these
1172
+ // hooks — without emitting here the daemon timeline (device ticker,
1173
+ // Android/Apple lists) goes silent whenever no managed session runs.
1174
+ //
1175
+ // Task GROUPING is fully hook-driven and TTY-independent: the boundary
1176
+ // state machine keys only off `user_prompt_submit` / `session_end` /
1177
+ // `/clear`, so it never depends on parsing the agent's terminal output
1178
+ // and stays stable across agent CLI UI changes. Per-tool rows are still
1179
+ // suppressed here (they drowned the glance surfaces); the `task_start` /
1180
+ // `task_end` headers + taskId-tagged `chat_start` rows now carry the
1181
+ // work-unit structure.
1182
+ //
1183
+ // Real per-session id shared by the collector, fallback, and the
1184
+ // chat_start row. Concurrent direct `claude`/`codex` runs must NOT
1185
+ // collapse onto one bucket: a shared id interleaves their turns into a
1186
+ // single task and lets one session's `session_end` tear down another's
1187
+ // run mapping. Falls back to a stable id only when a hook omits it.
1188
+ const hookSid = typeof json.session_id === 'string' && json.session_id
1189
+ ? json.session_id : 'daemon-hook';
1190
+ // Liveness marker for the delayed orphan-chat reaper: a session that
1191
+ // posts ANY hook after daemon startup is alive, and its open turn
1192
+ // (even one persisted before the restart) must not be force-closed.
1193
+ hookSessionsSeen.add(hookSid);
1194
+ // Claude hooks carry `cwd` (the worktree dir), not project_name/path —
1195
+ // capture it so APME runs are attributable to a specific worktree.
1196
+ const hookCwd = (typeof json.cwd === 'string' ? json.cwd
1197
+ : (typeof json.project_path === 'string' ? json.project_path : '')) || '';
1198
+ const hookProject = (typeof json.project_name === 'string' && json.project_name)
1199
+ ? json.project_name
1200
+ : (hookCwd ? hookCwd.split('/').filter(Boolean).pop() : undefined);
1201
+ const hookMessage = json.message;
1202
+ // Prompt shapes: Claude `{prompt}` / `{message:{content}}`; some Codex
1203
+ // builds send `{user_prompt}` (same fallback chain as codex-hook.ts).
1204
+ // Command XML envelopes (`<command-name>/merge</command-name>` …)
1205
+ // collapse to their "/merge args" form so a slash-command turn reads
1206
+ // as the command the user typed, not an XML blob.
1207
+ const hookPromptText = normalizeCommandPrompt(typeof json.prompt === 'string' ? json.prompt
1208
+ : (typeof hookMessage?.content === 'string' ? hookMessage.content
1209
+ : (typeof json.user_prompt === 'string' ? json.user_prompt : '')));
1210
+ const isClearBoundary = boundary === 'user_prompt_submit' && /^\s*\/clear\s*$/i.test(hookPromptText);
1211
+ // Session ended with a turn still open (the user interrupted the turn
1212
+ // then exited, or the process was torn down before its Stop could
1213
+ // fire): close the row honestly so dashboards stop the spinner instead
1214
+ // of showing "in progress" forever. Runs BEFORE the collector so the
1215
+ // interrupted chat_end sorts above the session_end task_end row.
1216
+ // Mirrors the Swift daemon's session_end force-close (`hasOpenTurn`
1217
+ // → interrupted chat_end).
1218
+ if (boundary === 'session_end') {
1219
+ const rows = core.bridgeTimeline.getHistoryForSession(hookSid, undefined, 24);
1220
+ let lastStart;
1221
+ let lastCompletionTs = 0;
1222
+ for (const row of rows) {
1223
+ if (row.type === 'chat_start')
1224
+ lastStart = row;
1225
+ else if (row.type === 'chat_response' || row.type === 'chat_end') {
1226
+ lastCompletionTs = Math.max(lastCompletionTs, row.ts);
1227
+ }
1228
+ }
1229
+ if (lastStart && lastStart.ts > lastCompletionTs) {
1230
+ const now = Date.now();
1231
+ const durS = Math.max(0, Math.round((now - (lastStart.startedAt ?? lastStart.ts)) / 1000));
1232
+ core.bridgeTimeline.addEntry({
1233
+ ts: now, type: 'chat_end',
1234
+ raw: `Interrupted · ${formatDurationSec(durS)}`,
1235
+ summaryKind: 'none',
1236
+ sessionId: hookSid,
1237
+ agentType: hookAgentType,
1238
+ startedAt: lastStart.startedAt ?? lastStart.ts,
1239
+ endedAt: now,
1240
+ ...(lastStart.taskId ? { taskId: lastStart.taskId } : {}),
1241
+ ...(hookProject ? { projectName: hookProject } : {}),
1242
+ });
1243
+ }
1244
+ }
1245
+ // ── APME collector (task/turn segmentation) ──
381
1246
  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,
1247
+ if (isClearBoundary) {
1248
+ // `/clear` closes the active task + run and opens a fresh run, so
1249
+ // the next prompt starts a new task 0 — the work-unit boundary.
1250
+ apme.collector.splitRun(hookSid, hookCwd || undefined);
1251
+ }
1252
+ else {
1253
+ // Lazy openRun: open a run when a turn-STARTING hook arrives with no
1254
+ // active run — either a normal `session_start`, or (crucially) the
1255
+ // first `user_prompt_submit` when `session_start` was missed/late.
1256
+ // Without this, `ingestHook` no-ops for want of a run, which is
1257
+ // exactly why direct-`claude` timelines showed zero task headers
1258
+ // (every follow-up looked like a new top-level chat). Gating on
1259
+ // start/prompt avoids two hazards: a stray post-`session_end` tool /
1260
+ // stop hook won't spawn a phantom run, and a late `session_start`
1261
+ // arriving after a lazy open won't double-open (the run already
1262
+ // exists → skip).
1263
+ if (!apme.collector.getRunId(hookSid)
1264
+ && (boundary === 'session_start' || boundary === 'user_prompt_submit')) {
1265
+ apme.collector.openRun({
1266
+ sessionId: hookSid,
1267
+ agentType: hookAgentType,
1268
+ projectName: hookProject,
1269
+ projectPath: hookCwd || undefined,
1270
+ });
1271
+ }
1272
+ // Feed the agent-neutral boundary name: the collector's
1273
+ // normalizeHookEventName only understands the Claude-shaped
1274
+ // vocabulary (user_prompt_submit / tool_start / …), so raw
1275
+ // codex_* / opencode_* names would silently skip turn management.
1276
+ apme.collector.ingestHook(hookSid, boundary, json);
1277
+ }
1278
+ // Direct `claude` runs reach the daemon only via these hooks, which
1279
+ // never carry the model — so every such run persisted model_id=NULL.
1280
+ // Recover it from the transcript Claude writes. Must run before
1281
+ // closeRun tears down the session→run mapping.
1282
+ if (boundary === 'stop' || boundary === 'session_end') {
1283
+ const tp = json.transcript_path;
1284
+ if (typeof tp === 'string' && tp) {
1285
+ const model = readModelFromTranscript(tp);
1286
+ if (model)
1287
+ apme.collector.updateModel(hookSid, model);
1288
+ }
1289
+ }
1290
+ if (boundary === 'session_end') {
1291
+ apme.collector.closeRun(hookSid);
1292
+ }
1293
+ }
1294
+ else if (fallbackTasks) {
1295
+ // APME is down — keep the timeline's task hierarchy alive from the
1296
+ // same boundary hooks (open on first prompt, close on /clear +
1297
+ // session_end), keyed by the real session id.
1298
+ fallbackTasks.ingestHook(hookSid, boundary, json);
1299
+ }
1300
+ // Timeline `chat_start`, emitted AFTER the collector so the active task
1301
+ // id is known — tag the row with it so the prompt nests under the
1302
+ // (possibly deferred) task_start header instead of reading as its own
1303
+ // top-level task. `/clear` is a boundary command, not a turn: no row.
1304
+ if (boundary === 'user_prompt_submit' && !isClearBoundary
1305
+ && hookPromptText.trim()
1306
+ // Codex's OTel turnStarted pseudo-prompt is a turn marker, not a
1307
+ // user prompt (same filter as the Swift daemon's chat_start path).
1308
+ && hookPromptText.trim() !== 'Codex turn started') {
1309
+ const taskId = (apme
1310
+ ? apme.collector.getActiveTaskId(hookSid)
1311
+ : fallbackTasks?.getActiveTaskId(hookSid)) ?? undefined;
1312
+ const promptText = stripUnsafeText(hookPromptText.trim());
1313
+ const now = Date.now();
1314
+ core.bridgeTimeline.addEntry({
1315
+ ts: now, type: 'chat_start',
1316
+ raw: promptText.slice(0, 160),
1317
+ // Long prompts keep their body reachable via the detail pane —
1318
+ // the 160-char raw alone read as clipped on the dashboards.
1319
+ ...(promptText.length > 160 ? { detail: promptText.slice(0, 1000) } : {}),
1320
+ sessionId: hookSid,
1321
+ agentType: hookAgentType,
1322
+ startedAt: now,
1323
+ ...(taskId ? { taskId } : {}),
1324
+ ...(hookProject ? { projectName: hookProject } : {}),
1325
+ });
1326
+ }
1327
+ // Timeline turn completion. Managed sessions relay chat_response from
1328
+ // their session bridge, but hook-observed (direct `claude` / `codex` /
1329
+ // standalone `opencode`) sessions only reach the daemon here — without
1330
+ // a completion row their chat_start rows spin as "running" forever on
1331
+ // every dashboard surface. Claude's Stop carries `transcript_path`
1332
+ // (the transcript tail's last assistant text is the turn's response —
1333
+ // same source the model reader uses above); Codex/OpenCode carry the
1334
+ // response inline (`last_assistant_message` / `response` / `output` /
1335
+ // `result`, the Swift daemon's field chain). Only emit while a turn is
1336
+ // actually open (a chat_start newer than the session's last
1337
+ // completion) so a duplicate/late Stop or an interrupted turn doesn't
1338
+ // re-emit the previous response.
1339
+ if (boundary === 'stop') {
1340
+ const rows = core.bridgeTimeline.getHistoryForSession(hookSid, undefined, 24);
1341
+ let lastStart;
1342
+ let lastResponse;
1343
+ let lastCompletionTs = 0;
1344
+ for (const row of rows) {
1345
+ if (row.type === 'chat_start')
1346
+ lastStart = row;
1347
+ else if (row.type === 'chat_response' || row.type === 'chat_end') {
1348
+ if (row.type === 'chat_response')
1349
+ lastResponse = row;
1350
+ lastCompletionTs = Math.max(lastCompletionTs, row.ts);
1351
+ }
1352
+ }
1353
+ const turnOpen = Boolean(lastStart && lastStart.ts > lastCompletionTs);
1354
+ const tp = typeof json.transcript_path === 'string' ? json.transcript_path : '';
1355
+ const inlineResponse = [json.last_assistant_message, json.response, json.output, json.result]
1356
+ .find((v) => typeof v === 'string' && v.trim().length > 0) ?? '';
1357
+ // Codex's stop payload rarely carries the text inline; its rollout
1358
+ // JSONL (agent_message / task_complete records) is the
1359
+ // authoritative source — the Codex counterpart of Claude's
1360
+ // transcript tail. Read it whenever a completion row might be
1361
+ // emitted (open turn, or a response-only close below).
1362
+ const rolloutResponse = !tp && !inlineResponse && hookAgentType === 'codex-cli'
1363
+ ? lastAgentMessageFromCodexRollout(hookSid)
1364
+ : '';
1365
+ const responseText = tp
1366
+ ? stripUnsafeText(lastAssistantTextFromTranscript(tp))
1367
+ : stripUnsafeText(inlineResponse || rolloutResponse);
1368
+ const respRaw = responseText.length > 0
1369
+ ? cleanRawText(responseText.length > 200 ? responseText.slice(0, 197) + '...' : responseText)
1370
+ : '';
1371
+ if (turnOpen && lastStart) {
1372
+ const now = Date.now();
1373
+ const taskId = (apme
1374
+ ? apme.collector.getActiveTaskId(hookSid)
1375
+ : fallbackTasks?.getActiveTaskId(hookSid)) ?? lastStart.taskId ?? undefined;
1376
+ const base = {
1377
+ sessionId: hookSid,
1378
+ agentType: hookAgentType,
1379
+ startedAt: lastStart.startedAt ?? lastStart.ts,
1380
+ endedAt: now,
1381
+ ...(taskId ? { taskId } : {}),
1382
+ ...(hookProject ? { projectName: hookProject } : {}),
1383
+ };
1384
+ if (responseText.length > 0) {
1385
+ core.bridgeTimeline.addEntry({
1386
+ ts: now, type: 'chat_response',
1387
+ raw: respRaw,
1388
+ detail: prepareMarkdownDetail(responseText.slice(0, 3000)) || undefined,
1389
+ ...base,
1390
+ });
1391
+ }
1392
+ else {
1393
+ // No readable response (interrupted / tool-only turn) — still
1394
+ // close the row so the dashboards stop the spinner.
1395
+ const durS = Math.max(0, Math.round((now - (lastStart.startedAt ?? lastStart.ts)) / 1000));
1396
+ core.bridgeTimeline.addEntry({
1397
+ ts: now, type: 'chat_end',
1398
+ raw: `Completed · ${formatDurationSec(durS)}`,
1399
+ summaryKind: 'none',
1400
+ ...base,
1401
+ });
1402
+ }
1403
+ }
1404
+ else if (respRaw && lastResponse?.raw !== respRaw) {
1405
+ // Response-only close (Swift daemon parity): the turn's prompt
1406
+ // hook was missed — notify-only Codex configs emit just
1407
+ // turn_complete, and an OpenCode prompt can surface empty — so
1408
+ // there is no open chat_start to anchor on, but the response
1409
+ // text is still the turn's result and worth a row. The
1410
+ // raw-equality guard keeps a duplicate/late stop from
1411
+ // re-emitting the same response.
1412
+ const now = Date.now();
1413
+ const taskId = (apme
1414
+ ? apme.collector.getActiveTaskId(hookSid)
1415
+ : fallbackTasks?.getActiveTaskId(hookSid)) ?? undefined;
1416
+ core.bridgeTimeline.addEntry({
1417
+ ts: now, type: 'chat_response',
1418
+ raw: respRaw,
1419
+ detail: prepareMarkdownDetail(responseText.slice(0, 3000)) || undefined,
1420
+ sessionId: hookSid,
1421
+ agentType: hookAgentType,
1422
+ endedAt: now,
1423
+ ...(taskId ? { taskId } : {}),
1424
+ ...(hookProject ? { projectName: hookProject } : {}),
1425
+ });
1426
+ }
1427
+ }
1428
+ // ── Response ──
1429
+ // PreToolUse and Stop are request-response steering channels (the hook
1430
+ // script echoes our body to Claude's stdout); everything else acks.
1431
+ if (eventName === 'PreToolUse') {
1432
+ const toolName = typeof json.tool_name === 'string' ? json.tool_name : '';
1433
+ const toolInput = (json.tool_input && typeof json.tool_input === 'object')
1434
+ ? json.tool_input : undefined;
1435
+ // 1. Soft STOP: the user pressed STOP on a device — deny this tool
1436
+ // call with a halt instruction. One-shot (flag consumed).
1437
+ if (claudeSid && consumeStop(claudeSid)) {
1438
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1439
+ res.end(JSON.stringify({
1440
+ hookSpecificOutput: {
1441
+ hookEventName: 'PreToolUse',
1442
+ permissionDecision: 'deny',
1443
+ permissionDecisionReason: STOP_DENY_REASON,
1444
+ },
1445
+ }));
1446
+ core.broadcastSessionsList().catch(() => { });
1447
+ return;
1448
+ }
1449
+ // 2. Device-approval gate: hold the response for a device decision,
1450
+ // but ONLY for calls the precision guards say Claude would
1451
+ // genuinely prompt for (see shouldHoldPreToolUse — mode gate,
1452
+ // never-prompt/prompt-prone sets, allowlist prediction, learned
1453
+ // auto-approvals, connected-client check). Anything uncertain
1454
+ // falls through to the empty pass-through below.
1455
+ if (claudeSid) {
1456
+ const gate = shouldHoldPreToolUse({
1457
+ sessionId: claudeSid,
1458
+ tool: toolName,
1459
+ toolInput,
1460
+ permissionMode: typeof json.permission_mode === 'string' ? json.permission_mode : undefined,
1461
+ cwd: hookCwd || undefined,
1462
+ clientCount: core.wsServer.getClientCount(),
1463
+ enabled: OBSERVED_APPROVAL_ENABLED,
392
1464
  });
1465
+ if (gate.hold && gate.requestId) {
1466
+ const requestId = gate.requestId;
1467
+ debug('daemon', `PreToolUse gate held: ${toolName} (${gate.reason}) req=${requestId}`);
1468
+ setAwaitingOverlay(claudeSid, buildGateQuestion(toolName, toolInput), requestId);
1469
+ core.broadcastSessionsList().catch(() => { });
1470
+ registerPending(requestId, res, {
1471
+ sessionId: claudeSid,
1472
+ tool: toolName,
1473
+ timeoutMs: OBSERVED_APPROVAL_HOLD_MS,
1474
+ onResolved: (decision) => {
1475
+ // `pass` (timeout) arms the auto-approval learner: if the
1476
+ // tool then runs with no permission_prompt Notification,
1477
+ // the signature is suppressed for this session.
1478
+ gateReleased(claudeSid, requestId, {
1479
+ undecided: decision === 'pass', tool: toolName, toolInput,
1480
+ });
1481
+ clearAwaitingOverlay(claudeSid);
1482
+ core.broadcastSessionsList().catch(() => { });
1483
+ },
1484
+ });
1485
+ return; // response held open — resolved by device / timeout
1486
+ }
1487
+ }
1488
+ // Pass-through: empty body → Claude's normal permission flow runs
1489
+ // untouched (zero added latency, no daemon-side gate).
1490
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1491
+ res.end('');
1492
+ }
1493
+ else if (eventName === 'Stop') {
1494
+ // Turn-end directive queue: deliver at most one queued deck command
1495
+ // by blocking the stop with the directive as the continuation
1496
+ // reason. Empty queue (the overwhelmingly common case) → empty body,
1497
+ // Claude ends the turn normally — no stop_hook_active loop possible.
1498
+ const directive = claudeSid ? takeDirective(claudeSid) : undefined;
1499
+ if (claudeSid)
1500
+ clearStop(claudeSid); // turn ended — a pending soft-stop is moot
1501
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1502
+ if (directive) {
1503
+ debug('daemon', `Stop hook delivering queued directive for ${claudeSid}: "${directive.slice(0, 60)}"`);
1504
+ res.end(JSON.stringify({
1505
+ decision: 'block',
1506
+ reason: `The user sent this follow-up from their AgentDeck controller: "${directive}". Carry out this instruction now.`,
1507
+ }));
1508
+ core.broadcastSessionsList().catch(() => { });
393
1509
  }
394
- apme.collector.ingestHook(hookSessionId, mapped, json);
395
- if (mapped === 'session_end') {
396
- apme.collector.closeRun(hookSessionId);
1510
+ else {
1511
+ res.end('');
397
1512
  }
398
1513
  }
1514
+ else {
1515
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1516
+ res.end(JSON.stringify({ received: true }));
1517
+ }
1518
+ });
1519
+ return;
1520
+ }
1521
+ // OpenCode observer-plugin steering: the plugin long-polls here and
1522
+ // executes returned commands via its in-process SDK client (abort /
1523
+ // prompt). See bridge/src/opencode-steering.ts.
1524
+ if (req.method === 'GET' && pathname === '/opencode/commands') {
1525
+ const sid = parsedUrl.searchParams.get('sid') ?? '';
1526
+ if (!sid) {
1527
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1528
+ res.end(JSON.stringify({ error: 'sid required' }));
1529
+ return;
1530
+ }
1531
+ const waitMs = Math.round(Number(parsedUrl.searchParams.get('wait') ?? '25') * 1000);
1532
+ pollOpenCodeCommands(sid, waitMs).then((commands) => {
1533
+ try {
1534
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1535
+ res.end(JSON.stringify({ commands }));
1536
+ }
1537
+ catch { /* client disconnected */ }
399
1538
  });
400
- res.writeHead(200, { 'Content-Type': 'application/json' });
401
- res.end(JSON.stringify({ received: true }));
402
1539
  return;
403
1540
  }
404
1541
  if (req.method === 'POST' && pathname === '/shutdown') {
405
1542
  res.writeHead(200, { 'Content-Type': 'application/json' });
406
1543
  res.end(JSON.stringify({ status: 'shutting_down' }));
1544
+ const hardExitTimer = setTimeout(() => {
1545
+ log('[agentdeck] Shutdown route timeout — forcing exit.');
1546
+ exitProcessNow(0);
1547
+ }, 5000);
407
1548
  core.shutdown();
408
1549
  return;
409
1550
  }
1551
+ // Manual task-close endpoint — drives `closeTaskExternal` on the APME
1552
+ // collector. Used by the CLI (`agentdeck task done` / `task cancel`)
1553
+ // and the macOS detail-pane "Mark task complete" button. Body:
1554
+ // { sessionId: string, signal?: 'manual', outcome?: 'success'|'fail'|'abandoned' }
1555
+ // sessionId defaults to the active session derived from the daemon's
1556
+ // registry; supplying it explicitly is the contract the CLI uses.
1557
+ if (req.method === 'POST' && pathname === '/task/close') {
1558
+ if (!apme) {
1559
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1560
+ res.end(JSON.stringify({ error: 'APME not initialized' }));
1561
+ return;
1562
+ }
1563
+ let body = '';
1564
+ req.on('data', (chunk) => { body += chunk; if (body.length > 4096)
1565
+ req.destroy(); });
1566
+ req.on('end', () => {
1567
+ try {
1568
+ const parsed = body ? JSON.parse(body) : {};
1569
+ const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId
1570
+ ? parsed.sessionId
1571
+ : (openclawApmeSessionId ?? '');
1572
+ if (!sessionId) {
1573
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1574
+ res.end(JSON.stringify({ error: 'sessionId required (no active session to default to)' }));
1575
+ return;
1576
+ }
1577
+ const signalRaw = typeof parsed.signal === 'string' ? parsed.signal : 'manual';
1578
+ const outcomeRaw = typeof parsed.outcome === 'string' ? parsed.outcome : undefined;
1579
+ const outcome = (outcomeRaw === 'success' || outcomeRaw === 'fail' || outcomeRaw === 'partial' || outcomeRaw === 'abandoned')
1580
+ ? outcomeRaw
1581
+ : undefined;
1582
+ // closeTaskExternal accepts TaskBoundarySignal (open union with
1583
+ // string fallback). Narrow only the well-known signals; pass
1584
+ // unknown strings through as-is (the runner labels them
1585
+ // generically).
1586
+ const apmeRef = apme;
1587
+ if (!apmeRef) {
1588
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1589
+ res.end(JSON.stringify({ error: 'APME not initialized' }));
1590
+ return;
1591
+ }
1592
+ const closed = apmeRef.collector.closeTaskExternal(sessionId, signalRaw, outcome);
1593
+ res.writeHead(closed ? 200 : 404, { 'Content-Type': 'application/json' });
1594
+ res.end(JSON.stringify({ closed, sessionId, signal: signalRaw, outcome: outcome ?? null }));
1595
+ }
1596
+ catch (err) {
1597
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1598
+ res.end(JSON.stringify({ error: `bad body: ${String(err)}` }));
1599
+ }
1600
+ });
1601
+ return;
1602
+ }
1603
+ // On-device Apple Intelligence (FoundationModels) text generation via the
1604
+ // daemon-managed *persistent* helper. Keeping it warm in the daemon avoids
1605
+ // the ~7s per-process cold start callers would otherwise pay each time
1606
+ // (e.g. wtcp branch naming). localhost only; lazily spawns the helper.
1607
+ if (req.method === 'POST' && pathname === '/generate') {
1608
+ let body = '';
1609
+ req.on('data', (chunk) => { body += chunk; if (body.length > 16384)
1610
+ req.destroy(); });
1611
+ req.on('end', () => {
1612
+ let prompt = '', instructions;
1613
+ try {
1614
+ const parsed = body ? JSON.parse(body) : {};
1615
+ prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
1616
+ instructions = typeof parsed.instructions === 'string' ? parsed.instructions : undefined;
1617
+ }
1618
+ catch {
1619
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1620
+ res.end(JSON.stringify({ error: 'bad JSON' }));
1621
+ return;
1622
+ }
1623
+ if (!prompt) {
1624
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1625
+ res.end(JSON.stringify({ error: 'prompt required' }));
1626
+ return;
1627
+ }
1628
+ callFoundationModelsHelper(prompt, instructions)
1629
+ .then((text) => {
1630
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1631
+ res.end(JSON.stringify({ text }));
1632
+ })
1633
+ .catch((err) => {
1634
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1635
+ res.end(JSON.stringify({ error: `generate failed: ${String(err)}` }));
1636
+ });
1637
+ });
1638
+ return;
1639
+ }
410
1640
  res.writeHead(404, { 'Content-Type': 'application/json' });
411
1641
  res.end(JSON.stringify({ error: 'Not found' }));
412
1642
  });
@@ -428,10 +1658,30 @@ export async function startDaemon(opts) {
428
1658
  });
429
1659
  httpServer.listen(port, '0.0.0.0', () => resolve());
430
1660
  }).catch(async (err) => {
431
- // Handle race condition: port became unavailable after probe
1661
+ // Handle race condition: port became unavailable after our pre-bind probe.
1662
+ // This is the concurrent-start case (e.g. two logon-trigger fires landing
1663
+ // within ~1s): both processes pass the singleton guard because neither has
1664
+ // bound yet, then exactly one wins the OS bind and the rest get EADDRINUSE.
432
1665
  if (err.message.startsWith('EADDRINUSE:') && port === requestedPort) {
1666
+ // Re-probe the occupant. If ANOTHER daemon grabbed the port, we lost the
1667
+ // race — exit instead of falling back to a new port, or we'd leave two
1668
+ // daemons running (and clobber daemon.json to point at the wrong one).
1669
+ // Only fall back when a *non-daemon* (e.g. a session bridge) holds it.
1670
+ const occupant = await probeDaemonHealth(requestedPort);
1671
+ // Harden against a forged/stale `mode:'daemon'` response squatting the
1672
+ // port: concede (exit) only to a verified live distinct daemon. A claim
1673
+ // backed by a dead/own PID is treated as stale → fall through to a fresh
1674
+ // port and keep running. See `shouldConcedePortToOccupant`.
1675
+ if (shouldConcedePortToOccupant(occupant, process.pid)) {
1676
+ const who = typeof occupant?.pid === 'number' ? `PID ${occupant.pid}` : 'a daemon';
1677
+ log(`[agentdeck] Lost startup race for port ${requestedPort} (${who} already serving). Exiting.`);
1678
+ process.exit(0);
1679
+ }
1680
+ if (occupant?.mode === 'daemon') {
1681
+ log(`[agentdeck] Port ${requestedPort} reports a daemon but PID ${occupant.pid} is not a live distinct process; treating as stale and falling back.`);
1682
+ }
433
1683
  port = await findAvailablePort();
434
- log(`[agentdeck] Port ${requestedPort} grabbed, retrying on ${port}...`);
1684
+ log(`[agentdeck] Port ${requestedPort} grabbed by ${occupant?.mode ?? 'a non-daemon'}, retrying on ${port}...`);
435
1685
  await new Promise((resolve, reject) => {
436
1686
  httpServer.on('error', (e) => reject(e));
437
1687
  httpServer.listen(port, '0.0.0.0', () => resolve());
@@ -448,11 +1698,88 @@ export async function startDaemon(opts) {
448
1698
  port,
449
1699
  projectName: 'AgentDeck',
450
1700
  httpServer,
1701
+ isDaemon: true,
1702
+ });
1703
+ const esp32WifiEvents = new Set([
1704
+ ...SERIAL_FORWARDED_EVENTS,
1705
+ 'esp32_ota_begin',
1706
+ 'esp32_ota_chunk',
1707
+ 'esp32_ota_end',
1708
+ 'esp32_ota_abort',
1709
+ 'device_info_request',
1710
+ ]);
1711
+ core.wsServer.setEventTransformer((event, client) => {
1712
+ if (!core.wsServer.isEsp32Client(client))
1713
+ return event;
1714
+ if (!esp32WifiEvents.has(event.type))
1715
+ return null;
1716
+ // Single-path guard: only the duplicated *display* payloads are deduped.
1717
+ // When the same board is live on USB serial, serial drives it and the WiFi
1718
+ // copy is redundant — drop it here. OTA control (esp32_ota_*, WiFi-only) and
1719
+ // device_info_request keep flowing so the standby WiFi socket stays live and
1720
+ // its lastSeen fresh. When serial disconnects, this flips false on the next
1721
+ // event and WiFi resumes automatically — no state migration needed.
1722
+ if (SERIAL_FORWARDED_EVENTS.has(event.type) && isWifiEsp32RedundantWithSerial(client))
1723
+ return null;
1724
+ return prepareForSerial(event);
451
1725
  });
452
1726
  // Timeline
1727
+ //
1728
+ // The daemon owns timeline persistence: every surface's entries flow through
1729
+ // it, so it is the only process positioned to hold the complete history and
1730
+ // the only one allowed to write the file. Reads still sweep every candidate
1731
+ // data dir (`latestTimelinePath`) so a Node daemon can pick up where a Swift
1732
+ // daemon left off, and vice versa — the two implementations share one
1733
+ // on-disk format.
453
1734
  const bridgeLogStream = new BridgeLogStream();
454
1735
  core.wireTimeline(bridgeLogStream);
1736
+ const timelinePath = latestTimelinePath();
1737
+ if (timelinePath) {
1738
+ const loaded = core.bridgeTimeline.loadPersistedFile(timelinePath);
1739
+ if (loaded > 0)
1740
+ log(`[agentdeck] Loaded ${loaded} persisted timeline entries`);
1741
+ // Close task_start rows orphaned by a previous daemon killed mid-task —
1742
+ // clients would otherwise spin their in-flight marker forever.
1743
+ const reaped = core.bridgeTimeline.reapOrphanTaskStarts();
1744
+ if (reaped > 0)
1745
+ log(`[agentdeck] Closed ${reaped} orphaned task_start rows as interrupted`);
1746
+ // Same for chat turns: a session killed mid-turn (no Stop / SessionEnd)
1747
+ // leaves its chat_start spinning "in progress" on every surface. Runs
1748
+ // DELAYED (5 min) so live sessions have posted a hook and registered in
1749
+ // `hookSessionsSeen` — only stale rows (30 min+) of sessions that stayed
1750
+ // silent since startup close; a live long-running turn is untouched and
1751
+ // its real Stop still lands normally.
1752
+ const chatReaperTimer = setTimeout(() => {
1753
+ const reapedTurns = core.bridgeTimeline.reapOrphanChatStarts(undefined, undefined, hookSessionsSeen);
1754
+ if (reapedTurns > 0)
1755
+ log(`[agentdeck] Closed ${reapedTurns} orphaned chat_start rows as interrupted`);
1756
+ }, 5 * 60_000);
1757
+ chatReaperTimer.unref?.();
1758
+ }
1759
+ // Enabled AFTER rehydration + reaping so the first write carries the restored
1760
+ // history instead of truncating the file to whatever this run has seen.
1761
+ core.bridgeTimeline.enablePersistence(getOwnTimelineFile());
455
1762
  core.wireDisplayMonitor();
1763
+ let lastStateEvent = null;
1764
+ let userFocusedSessionId = null;
1765
+ const attachFocusedSessionId = (event) => {
1766
+ if (event.type !== 'state_update')
1767
+ return event;
1768
+ return {
1769
+ ...event,
1770
+ focusedSessionId: userFocusedSessionId ?? '',
1771
+ };
1772
+ };
1773
+ const broadcastFocusedState = () => {
1774
+ const gwAlive = gatewayAdapter?.isAlive() ?? false;
1775
+ const stateEvent = attachFocusedSessionId(core.buildStateEvent({
1776
+ agentType: gwAlive ? 'openclaw' : 'daemon',
1777
+ agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
1778
+ snapshot: core.stateMachine.getSnapshot(),
1779
+ }));
1780
+ lastStateEvent = stateEvent;
1781
+ core.wsServer.broadcast(stateEvent);
1782
+ };
456
1783
  // System wake recovery — re-publish mDNS, reconnect devices, refresh usage
457
1784
  core.onSystemWake(() => {
458
1785
  log('[daemon] System wake detected — recovering devices');
@@ -491,11 +1818,11 @@ export async function startDaemon(opts) {
491
1818
  core.cachedModelCatalog = merged;
492
1819
  debug('daemon', `Model catalog merged from sibling: ${merged.length} models total`);
493
1820
  const snap = core.stateMachine.getSnapshot();
494
- const stateEvent = core.buildStateEvent({
1821
+ const stateEvent = attachFocusedSessionId(core.buildStateEvent({
495
1822
  agentType: gatewayAdapter?.isAlive() ? 'openclaw' : 'daemon',
496
1823
  agentCapabilities: gatewayAdapter?.isAlive() ? OPENCLAW_CAPABILITIES : undefined,
497
1824
  snapshot: snap,
498
- });
1825
+ }));
499
1826
  lastStateEvent = stateEvent;
500
1827
  core.broadcast(stateEvent);
501
1828
  core.broadcastUsage();
@@ -506,13 +1833,18 @@ export async function startDaemon(opts) {
506
1833
  const focusRelay = new SessionFocusRelay();
507
1834
  focusRelay.setEventHandler((evt) => {
508
1835
  if (evt.type === 'state_update') {
1836
+ const focusedId = focusRelay.getFocusedSessionId();
1837
+ if (focusedId)
1838
+ userFocusedSessionId = focusedId;
509
1839
  // Merge daemon metadata into the session's state_update
510
1840
  const merged = {
511
1841
  ...evt,
512
- sessionId: focusRelay.getFocusedSessionId(),
1842
+ sessionId: focusedId,
1843
+ focusedSessionId: userFocusedSessionId ?? '',
513
1844
  modelCatalog: evt.modelCatalog ?? core.cachedModelCatalog,
514
1845
  gatewayAvailable: core.cachedGatewayAvailable,
515
1846
  gatewayConnected: core.cachedGatewayConnected,
1847
+ gatewayAuthStatus: core.cachedGatewayAuthStatus,
516
1848
  ollamaStatus: core.cachedOllamaStatus,
517
1849
  gatewayHasError: evt.gatewayHasError ?? core.cachedGatewayHasError,
518
1850
  moduleHealth: moduleHealthProvider(),
@@ -539,11 +1871,69 @@ export async function startDaemon(opts) {
539
1871
  });
540
1872
  // mDNS + device modules
541
1873
  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);
1874
+ // AGENTDECK_DAEMON_NO_SERIAL=1 leaves the daemon fully functional over WiFi
1875
+ // (mDNS, WiFi WS, WiFi OTA) but never opens the USB serial ports. This frees
1876
+ // a board's /dev/cu.* for an exclusive external serial capture (e.g. reading a
1877
+ // panic backtrace during a WiFi OTA) that otherwise fights the daemon's serial
1878
+ // reader for bytes. Diagnostic gate only; normal daemons keep serial on.
1879
+ const serialMode = process.env.AGENTDECK_DAEMON_NO_SERIAL === '1' ? false : 'auto';
1880
+ // Note: the D200H has no device module — direct-HID control was retired. It is
1881
+ // driven exclusively by the Ulanzi Studio plugin (`ulanzi-plugin`) over WebSocket;
1882
+ // the daemon never opens it over HID. Its health is reported via the Ulanzi client
1883
+ // count in moduleHealthProvider below.
1884
+ const startedModules = await initModules(deviceModules, { mdns: true, broadcast: true, adb: 'auto', serial: serialMode, pixoo: 'auto', timebox: 'auto', idotmatrix: 'auto' }, { port, authToken: core.authToken, projectName: 'AgentDeck', wsServer: core.wsServer });
1885
+ moduleHealthProvider = () => {
1886
+ const modules = buildNodeModuleHealth(startedModules);
1887
+ const einkDevices = collectEinkDevices();
1888
+ if (einkDevices.length > 0) {
1889
+ modules.einkDevices = { available: true, devices: einkDevices };
1890
+ }
1891
+ const androidDashboards = collectAndroidDashboards();
1892
+ if (androidDashboards.length > 0) {
1893
+ modules.androidDashboards = { available: true, devices: androidDashboards };
1894
+ }
1895
+ const streamDeckDevices = collectStreamDeckDevices();
1896
+ if (streamDeckDevices.length > 0) {
1897
+ modules.streamDeck = { available: true, devices: streamDeckDevices };
1898
+ }
1899
+ // WiFi-WS ESP32 boards. Same data as /devices `esp32-wifi` — without this
1900
+ // the dashboards (macOS/iOS/Android) never learn a WiFi-only board exists,
1901
+ // since they read moduleHealth off state_update, not /devices.
1902
+ const wifiEsp32 = listWifiEsp32Devices();
1903
+ if (wifiEsp32.length > 0) {
1904
+ modules.esp32Wifi = {
1905
+ available: true,
1906
+ devices: wifiEsp32.map((d) => ({
1907
+ board: d.board,
1908
+ ip: d.ip ?? null,
1909
+ version: d.version ?? null,
1910
+ stale: d.stale,
1911
+ serialActive: d.serialActive,
1912
+ })),
1913
+ };
1914
+ }
1915
+ const tuiClients = core.wsServer.getTuiClients();
1916
+ if (tuiClients.length > 0) {
1917
+ modules.tuiDashboards = {
1918
+ available: true,
1919
+ devices: tuiClients.map((c) => ({ id: c.id, name: c.name, kind: 'tui' })),
1920
+ };
1921
+ }
1922
+ const ulanziPluginConnected = core.wsServer.getUlanziClientCount() > 0;
1923
+ if (ulanziPluginConnected) {
1924
+ modules.d200h = {
1925
+ connected: true,
1926
+ driver: 'ulanzi-plugin',
1927
+ };
1928
+ }
1929
+ return modules;
1930
+ };
544
1931
  core.setModuleHealthProvider(moduleHealthProvider);
1932
+ // iDotMatrix BLE is now driven by IDotMatrixModule (registered in
1933
+ // createDefaultModules): the module owns spawning the Python sync client,
1934
+ // zero-config auto-discovery, and teardown — so the daemon doesn't start it
1935
+ // directly here anymore (avoids double-spawn).
545
1936
  // Serial module state provider (heartbeat needs cached state)
546
- let lastStateEvent = null;
547
1937
  const serialModule = startedModules.find(m => m.name === 'serial');
548
1938
  if (serialModule) {
549
1939
  serialModule.setStateProvider(() => lastStateEvent);
@@ -554,20 +1944,62 @@ export async function startDaemon(opts) {
554
1944
  if (lastStateEvent)
555
1945
  events.push(lastStateEvent);
556
1946
  events.push(core.buildUsage());
557
- events.push({ type: 'display_state', displayOn: core.displayMonitor.isDisplayOn() });
1947
+ events.push(buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
1948
+ // Seed the last few timeline entries so a freshly (re)connected board's
1949
+ // ticker shows the real latest event instead of whatever its ring last
1950
+ // held. Kept to 6 entries — the whole line must stay well under the
1951
+ // small (4KB) serial RX buffers on non-InkDeck boards; prepareForSerial
1952
+ // byte-caps each entry's raw/detail at send time.
1953
+ const recent = core.bridgeTimeline.getHistory().slice(-6);
1954
+ if (recent.length > 0) {
1955
+ events.push({ type: 'timeline_history', entries: recent });
1956
+ }
558
1957
  // Sessions list (async enrichment runs synchronously from cache here)
559
1958
  core.broadcastSessionsList().catch(() => { });
560
1959
  return events;
561
1960
  });
1961
+ // Heartbeat re-sync: display_state is otherwise edge-triggered, and a board
1962
+ // that misses the wake edge stays dark until power-cycled.
1963
+ serialModule.setDisplayStateProvider(() => buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
1964
+ // Heartbeat re-sync: sessions_list is edge-triggered too, so a board that
1965
+ // reconnects across a daemon handoff during a quiet window would otherwise
1966
+ // stay on "no active sessions" until the next session change.
1967
+ serialModule.setSessionsListProvider(() => core.getLastSessionsListEvent());
562
1968
  // Include ESP32 serial connections in client count for polling guards
563
1969
  core.setExternalClientCountProvider(() => esp32ConnectionCount());
1970
+ }
1971
+ // WS-path display_state re-sync: the 5s serial heartbeat above only covers
1972
+ // USB-attached boards. WiFi boards (InkDeck) receive display_state edge-
1973
+ // triggered over the plugin WS — a missed wake edge would leave an e-ink
1974
+ // panel showing the sleep card forever. Re-broadcast at a slow cadence so
1975
+ // any board that missed the edge self-heals within 15s.
1976
+ {
1977
+ const displayResync = setInterval(() => {
1978
+ if (core.wsServer.getClientCount() > 0) {
1979
+ core.wsServer.broadcast(buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
1980
+ }
1981
+ }, 15_000);
1982
+ displayResync.unref?.();
1983
+ // WiFi ESP32 diagnostics: boards only announce device_info on connect unless
1984
+ // asked. Poll slowly so /devices can show uptime/reset changes and distinguish
1985
+ // a real reboot from a display/WS redraw.
1986
+ const wifiDeviceInfoPoll = setInterval(() => {
1987
+ if (core.wsServer.getClientCount() > 0) {
1988
+ core.wsServer.broadcast({ type: 'device_info_request' });
1989
+ }
1990
+ }, 30_000);
1991
+ wifiDeviceInfoPoll.unref?.();
564
1992
  // WiFi auto-provisioning for ESP32 (enables independent WiFi operation)
565
1993
  const wifiConfig = loadWifiConfig();
566
1994
  if (wifiConfig?.autoProvision) {
567
1995
  const lanIp = getLanIp();
568
1996
  onESP32Message((portPath, msg) => {
569
- if (msg.type === 'device_info' && !msg.wifiConnected) {
570
- sendWifiProvisionToAll({
1997
+ const shouldRefreshIps10Endpoint = msg.type === 'device_info' &&
1998
+ msg.board === 'ips_10' &&
1999
+ msg.wifiConnected &&
2000
+ !msg.wifiRadioParked;
2001
+ if (msg.type === 'device_info' && (!msg.wifiConnected || shouldRefreshIps10Endpoint)) {
2002
+ const sent = sendWifiProvisionToAll({
571
2003
  type: 'wifi_provision',
572
2004
  ssid: wifiConfig.ssid,
573
2005
  password: wifiConfig.password,
@@ -575,7 +2007,8 @@ export async function startDaemon(opts) {
575
2007
  bridgePort: port,
576
2008
  authToken: core.authToken,
577
2009
  });
578
- log(`[agentdeck] WiFi provision sent to ESP32 on ${portPath}`);
2010
+ if (sent > 0)
2011
+ log(`[agentdeck] WiFi provision sent to ${sent} ESP32 device(s) after ${portPath}`);
579
2012
  }
580
2013
  else if (msg.type === 'wifi_provision_ack') {
581
2014
  log(msg.success ? `[agentdeck] ESP32 WiFi connected: ${msg.ip} ✓` : `[agentdeck] ESP32 WiFi failed: ${msg.error || 'unknown'}`);
@@ -589,43 +2022,135 @@ export async function startDaemon(opts) {
589
2022
  // routes. `setApme` on core is gated against the `daemon` meta-session so
590
2023
  // register/deregister won't open a bogus run. Session bridges opening their
591
2024
  // own connection to the same sqlite file is safe under WAL mode.
592
- apme = await initApme();
2025
+ // emitTimeline: forward task hierarchy entries (task_start / task_end) into
2026
+ // the daemon's bridgeTimeline so downstream dashboards see task headers.
2027
+ const daemonProjectTimeline = isTimelineProjectionEnabled();
2028
+ apme = await initApme(undefined, {
2029
+ emitTimeline: (entry) => core.bridgeTimeline.addEntry(entry),
2030
+ projectSampleTimeline: daemonProjectTimeline,
2031
+ emitProjectedTimeline: (entry) => core.bridgeTimeline.addEntry(entry, { bypassSuppression: true }),
2032
+ });
593
2033
  if (apme) {
594
2034
  core.setApme(apme);
595
- log('[agentdeck] APME enabled — /apme/* routes active');
2035
+ if (daemonProjectTimeline) {
2036
+ core.bridgeTimeline.setSuppressLocalChatTool(true);
2037
+ log('[agentdeck] APME timeline projection ENABLED — chat/tool rows derive from SessionSample');
2038
+ }
2039
+ log(`[agentdeck] APME enabled — store=${apme.store.dbPath} routes=/apme/*`);
2040
+ // Fire-and-forget judge backend probe. Result is cached on
2041
+ // apme.runner.lastBackendProbe and surfaced on /health so users discover
2042
+ // misconfiguration (no MLX server running, missing API key, etc.) without
2043
+ // having to wait for the first eval to fail.
2044
+ void apme.runner.refreshBackendProbe(loadApmeConfig().judge).then(status => {
2045
+ if (status.status === 'ready') {
2046
+ log(`[agentdeck] APME judge ready: ${status.backend}${status.model ? ` (${status.model})` : ''}${status.latencyMs !== undefined ? ` ${status.latencyMs}ms` : ''}`);
2047
+ }
2048
+ else {
2049
+ log(`[agentdeck] APME judge ${status.backend} ${status.status} — ${status.reason ?? 'no reason'}. Deterministic layer (lint/build/test) keeps running.`);
2050
+ }
2051
+ });
2052
+ }
2053
+ else {
2054
+ // initApme() already logged the specific reason via logError. This second
2055
+ // line tells the user where to look + what's lost so the gap doesn't pass
2056
+ // for "everything's fine, just no /apme/ routes".
2057
+ log('[agentdeck] APME unavailable — no run/turn/task evals will be recorded for sessions on this daemon.');
2058
+ // Keep the timeline's task hierarchy alive without APME: emit fallback
2059
+ // task_start/task_end rows from the hook boundary signals so the device
2060
+ // timeline doesn't degrade to a flat chat/tool stream.
2061
+ fallbackTasks = new FallbackTaskTimeline((entry) => core.bridgeTimeline.addEntry(entry), { agentType: 'claude-code' });
596
2062
  }
597
2063
  // Register session
598
2064
  core.registerSession('daemon');
599
2065
  const passiveSessionObserver = new PassiveSessionObserver();
2066
+ // The observer scans in the background now (collect() returns the cache
2067
+ // immediately). When a scan lands fresh observations, push them out via
2068
+ // the debounced broadcast so clients don't wait for the next 10 s poll.
2069
+ passiveSessionObserver.onRefreshed = () => core.maybeBroadcastSessionsList();
600
2070
  // ===== Gateway adapter lifecycle =====
601
2071
  // (gatewayAdapter + gatewayConnecting declared earlier, before HTTP server)
602
2072
  // Inject OpenClaw virtual session only after Gateway authentication succeeds.
603
2073
  // Reachability alone is a topology signal, not proof that commands can route.
604
2074
  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;
2075
+ // Overlay hook-driven awaiting state onto observed (direct-`claude`)
2076
+ // sessions display-only (no requestId, respond-in-terminal UX). Done here
2077
+ // in the synchronous enricher (runs on every broadcast) rather than inside
2078
+ // the 5s-throttled observer, so a Notification arriving mid-window still
2079
+ // surfaces within one frame. Key = the Claude session UUID embedded in
2080
+ // `observed:claude:<uuid>`.
2081
+ const observed = applyAwaitingOverlayToObserved(passiveSessionObserver.collect(sessions))
2082
+ .map((s) => {
2083
+ // Steering feedback for observed Claude sessions: devices render
2084
+ // "stopping at next tool" / queued-directive badges from these.
2085
+ if (!s.id.startsWith('observed:claude:'))
2086
+ return s;
2087
+ const snap = steeringSnapshot(s.id.slice('observed:claude:'.length));
2088
+ if (!snap.stopRequested && snap.queuedDirectives === 0)
2089
+ return s;
2090
+ return {
2091
+ ...s,
2092
+ ...(snap.stopRequested ? { stopRequested: true } : {}),
2093
+ ...(snap.queuedDirectives > 0 ? { queuedDirectives: snap.queuedDirectives } : {}),
2094
+ };
2095
+ });
2096
+ // Derive per-session elapsed seconds from startedAt so NTP-less devices
2097
+ // (ESP32 IPS10 mosaic) render an elapsed value per cell without a wall clock.
2098
+ const now = Date.now();
2099
+ const enrichedSessions = [...sessions, ...observed].map((s) => {
2100
+ // On-demand review badge (REVIEW tile verdict / REVIEWING state) —
2101
+ // applies to every session type, managed included.
2102
+ const review = reviewSnapshot(s.id);
2103
+ const withReview = Object.keys(review).length > 0 ? { ...s, ...review } : s;
2104
+ if (withReview.elapsedSec != null || !withReview.startedAt)
2105
+ return withReview;
2106
+ const sec = Math.round((now - Date.parse(withReview.startedAt)) / 1000);
2107
+ return Number.isFinite(sec) && sec >= 0 ? { ...withReview, elapsedSec: sec } : withReview;
2108
+ });
2109
+ // SSOT: inject iff Gateway is authenticated (gatewayConnected). Reachability
2110
+ // / adapter-liveness alone must not materialize a session — that kept a
2111
+ // phantom OpenClaw alive on devices after it was effectively off. Shared
2112
+ // injector with bridge/src/index.ts; mirror of Swift buildSessionsListEvent.
611
2113
  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
- }];
2114
+ return injectOpenClawSession(enrichedSessions, {
2115
+ gatewayConnected: core.cachedGatewayConnected,
2116
+ state: snap.state,
2117
+ projectName: snap.projectName ?? 'OpenClaw',
2118
+ modelName: snap.modelName ?? undefined,
2119
+ controlMode: 'managed',
2120
+ });
622
2121
  });
2122
+ // OpenClaw-specific APME session id. Distinct from `core.sessionId`
2123
+ // (the daemon meta-session, which doesn't open a run) and rotates on
2124
+ // every connect/disconnect cycle so each Gateway lifetime gets its own
2125
+ // APME run with its own task hierarchy. Captured here so both the
2126
+ // `connectGatewayAdapter` open + `disconnectGatewayAdapter` close paths
2127
+ // can reference the same id.
2128
+ let openclawApmeSessionId = null;
623
2129
  function connectGatewayAdapter() {
624
2130
  if (gatewayAdapter || gatewayConnecting)
625
2131
  return;
626
2132
  gatewayConnecting = true;
627
2133
  log('[agentdeck] OpenClaw Gateway detected, connecting...');
628
2134
  const adapter = new OpenClawAdapter({ autoReconnect: false });
2135
+ // Bind APME — without this OpenClaw never reaches the collector and
2136
+ // every chat session collapses to a single session_end task. The
2137
+ // adapter's idle-gap timer + chat.send/final ingestion needs an active
2138
+ // run to attach turns and task boundaries to.
2139
+ if (apme) {
2140
+ openclawApmeSessionId = `openclaw-${randomUUID()}`;
2141
+ try {
2142
+ apme.collector.openRun({
2143
+ sessionId: openclawApmeSessionId,
2144
+ agentType: 'openclaw',
2145
+ projectName: 'openclaw',
2146
+ });
2147
+ adapter.setApmeSession(openclawApmeSessionId, process.cwd());
2148
+ }
2149
+ catch (err) {
2150
+ debug('APME', `openRun for OpenClaw failed: ${String(err)}`);
2151
+ openclawApmeSessionId = null;
2152
+ }
2153
+ }
629
2154
  adapter.on('event', (evt) => {
630
2155
  switch (evt.source) {
631
2156
  case 'hook':
@@ -636,6 +2161,14 @@ export async function startDaemon(opts) {
636
2161
  break;
637
2162
  case 'parser':
638
2163
  core.stateMachine.handleParserEvent(evt.event, evt.data);
2164
+ // The OpenClaw adapter emits the real model via a model_info parser
2165
+ // event, but it only ever updated the display StateMachine — the APME
2166
+ // run was never told, so every openclaw run persisted model_id=NULL.
2167
+ if (evt.event === 'model_info' && apme && openclawApmeSessionId) {
2168
+ const model = evt.data?.model;
2169
+ if (model)
2170
+ apme.collector.updateModel(openclawApmeSessionId, model);
2171
+ }
639
2172
  break;
640
2173
  case 'metadata':
641
2174
  if (evt.event === 'model_catalog') {
@@ -643,11 +2176,11 @@ export async function startDaemon(opts) {
643
2176
  if (models) {
644
2177
  core.cachedModelCatalog = models;
645
2178
  const snap = core.stateMachine.getSnapshot();
646
- const stateEvent = core.buildStateEvent({
2179
+ const stateEvent = attachFocusedSessionId(core.buildStateEvent({
647
2180
  agentType: 'openclaw',
648
2181
  agentCapabilities: OPENCLAW_CAPABILITIES,
649
2182
  snapshot: snap,
650
- });
2183
+ }));
651
2184
  lastStateEvent = stateEvent;
652
2185
  core.broadcast(stateEvent);
653
2186
  core.broadcastUsage();
@@ -668,12 +2201,18 @@ export async function startDaemon(opts) {
668
2201
  break;
669
2202
  case 'timeline':
670
2203
  if (evt.entry) {
2204
+ // This handler is wired exclusively to the OpenClaw Gateway adapter
2205
+ // (see agentType:'openclaw' at the model_catalog case above), but the
2206
+ // adapter emits bare timeline entries without agentType/projectName.
2207
+ // Stamp the OpenClaw origin so the attributor doesn't default them to
2208
+ // 'AgentDeck'/null. See enrichGatewayTimelineEntry.
2209
+ const enriched = enrichGatewayTimelineEntry(evt.entry);
671
2210
  if (evt.upsert)
672
- core.bridgeTimeline.upsertEntry(evt.entry);
2211
+ core.bridgeTimeline.upsertEntry(enriched);
673
2212
  else
674
- core.bridgeTimeline.addEntry(evt.entry);
675
- if (evt.entry.type === 'tool_request')
676
- bridgeLogStream.trackToolRequest(evt.entry.raw);
2213
+ core.bridgeTimeline.addEntry(enriched);
2214
+ if (enriched.type === 'tool_request')
2215
+ bridgeLogStream.trackToolRequest(enriched.raw);
677
2216
  }
678
2217
  break;
679
2218
  case 'connection': {
@@ -684,6 +2223,7 @@ export async function startDaemon(opts) {
684
2223
  if (evt.status === 'connected') {
685
2224
  core.cachedGatewayAvailable = true;
686
2225
  core.cachedGatewayConnected = true;
2226
+ core.cachedGatewayAuthStatus = 'connected';
687
2227
  bridgeLogStream.start();
688
2228
  log('[agentdeck] OpenClaw Gateway connected');
689
2229
  if (core.stateMachine.getSnapshot().state === 'disconnected') {
@@ -691,11 +2231,11 @@ export async function startDaemon(opts) {
691
2231
  }
692
2232
  // Force full state broadcast
693
2233
  const snap = core.stateMachine.getSnapshot();
694
- const gwStateEvent = core.buildStateEvent({
2234
+ const gwStateEvent = attachFocusedSessionId(core.buildStateEvent({
695
2235
  agentType: 'openclaw',
696
2236
  agentCapabilities: OPENCLAW_CAPABILITIES,
697
2237
  snapshot: snap,
698
- });
2238
+ }));
699
2239
  lastStateEvent = gwStateEvent;
700
2240
  core.wsServer.broadcast(gwStateEvent);
701
2241
  core.broadcastUsage();
@@ -703,8 +2243,10 @@ export async function startDaemon(opts) {
703
2243
  }
704
2244
  else {
705
2245
  core.cachedGatewayConnected = false;
2246
+ core.cachedGatewayAuthStatus = 'gateway_not_found';
706
2247
  bridgeLogStream.stop();
707
2248
  log('[agentdeck] OpenClaw Gateway disconnected');
2249
+ core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
708
2250
  core.broadcastSessionsList().catch(() => { });
709
2251
  }
710
2252
  break;
@@ -719,6 +2261,7 @@ export async function startDaemon(opts) {
719
2261
  log(`[agentdeck] Failed to connect to Gateway: ${err}`);
720
2262
  gatewayConnecting = false;
721
2263
  core.cachedGatewayConnected = false;
2264
+ core.cachedGatewayAuthStatus = 'gateway_not_found';
722
2265
  core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
723
2266
  });
724
2267
  }
@@ -729,10 +2272,25 @@ export async function startDaemon(opts) {
729
2272
  const wasAlive = gatewayAdapter.isAlive();
730
2273
  gatewayAdapter.shutdown().catch(() => { });
731
2274
  gatewayAdapter = null;
2275
+ // APME: close the OpenClaw run so the collector fires its
2276
+ // session_end boundary and the run becomes eligible for the layer-2
2277
+ // judge queue.
2278
+ if (apme && openclawApmeSessionId) {
2279
+ try {
2280
+ apme.collector.closeRun(openclawApmeSessionId);
2281
+ }
2282
+ catch (err) {
2283
+ debug('APME', `closeRun for OpenClaw failed: ${String(err)}`);
2284
+ }
2285
+ openclawApmeSessionId = null;
2286
+ }
732
2287
  core.cachedGatewayConnected = false;
2288
+ core.cachedGatewayAuthStatus = 'gateway_not_found';
733
2289
  core.cachedModelCatalog = null;
734
2290
  if (wasAlive)
735
2291
  core.stateMachine.handleHookEvent('SessionEnd', {});
2292
+ else
2293
+ core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
736
2294
  // Do NOT broadcast connection:disconnected — that would make WS clients
737
2295
  // think they lost their bridge connection. State change to 'daemon' agentType
738
2296
  // and updated sessions_list convey the gateway loss.
@@ -789,11 +2347,11 @@ export async function startDaemon(opts) {
789
2347
  // ===== State changed → broadcast =====
790
2348
  core.stateMachine.on('state_changed', (snapshot) => {
791
2349
  const gwAlive = gatewayAdapter?.isAlive() ?? false;
792
- const stateEvent = core.buildStateEvent({
2350
+ const stateEvent = attachFocusedSessionId(core.buildStateEvent({
793
2351
  agentType: gwAlive ? 'openclaw' : 'daemon',
794
2352
  agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
795
2353
  snapshot,
796
- });
2354
+ }));
797
2355
  lastStateEvent = stateEvent;
798
2356
  core.wsServer.broadcast(stateEvent);
799
2357
  core.maybeBroadcastSessionsList();
@@ -816,6 +2374,62 @@ export async function startDaemon(opts) {
816
2374
  // ===== Commands from WS clients =====
817
2375
  // ===== Internal WS: session push channel =====
818
2376
  core.wsServer.onRawMessage((msg, sender) => {
2377
+ // WiFi ESP32 boards announce device_info over WS on connect. Capture the
2378
+ // sender here so OTA can address the exact socket instead of broadcasting.
2379
+ if (msg.type === 'device_info') {
2380
+ core.wsServer.markEsp32Client(sender);
2381
+ registerWifiEsp32(msg, sender);
2382
+ return true;
2383
+ }
2384
+ touchWifiEsp32Socket(sender);
2385
+ if (msg.type === 'client_register'
2386
+ && msg.clientType === 'eink-device') {
2387
+ // XTeink X3/X4 (fork) volunteering its E-ink roster. Store per-socket so
2388
+ // the Downstream E-ink rail can render it; drops on socket close.
2389
+ const devices = Array.isArray(msg.devices)
2390
+ ? msg.devices : [];
2391
+ einkRegistrations.set(sender, { devices, updatedAt: Date.now() });
2392
+ debug('daemon', `client_register eink-device devices=${devices.length}`);
2393
+ // Push a fresh state so the rail appears within one frame instead of on
2394
+ // the next periodic tick.
2395
+ if (lastStateEvent && lastStateEvent.type === 'state_update') {
2396
+ lastStateEvent.moduleHealth = moduleHealthProvider();
2397
+ core.wsServer.broadcast(lastStateEvent);
2398
+ }
2399
+ return true;
2400
+ }
2401
+ if (msg.type === 'client_register'
2402
+ && msg.clientType === 'android-dashboard') {
2403
+ // Android dashboard app volunteering its identity (tablet / e-ink
2404
+ // launcher on Wi-Fi) so the topology can show an Android row even when
2405
+ // the device is not ADB-bridged; drops on socket close.
2406
+ const devices = Array.isArray(msg.devices)
2407
+ ? msg.devices : [];
2408
+ androidDashboardRegistrations.set(sender, { devices, updatedAt: Date.now() });
2409
+ debug('daemon', `client_register android-dashboard devices=${devices.length}`);
2410
+ if (lastStateEvent && lastStateEvent.type === 'state_update') {
2411
+ lastStateEvent.moduleHealth = moduleHealthProvider();
2412
+ core.wsServer.broadcast(lastStateEvent);
2413
+ }
2414
+ return true;
2415
+ }
2416
+ if (msg.type === 'client_register'
2417
+ && msg.clientType === 'streamdeck-plugin') {
2418
+ // Elgato plugin volunteering the physical Stream Decks it drives — the
2419
+ // topology's Stream Deck rows. Swift-daemon parity; drops on socket close.
2420
+ const devices = Array.isArray(msg.devices)
2421
+ ? msg.devices : [];
2422
+ streamDeckRegistrations.set(sender, { devices, updatedAt: Date.now() });
2423
+ debug('daemon', `client_register streamdeck-plugin devices=${devices.length}`);
2424
+ if (lastStateEvent && lastStateEvent.type === 'state_update') {
2425
+ lastStateEvent.moduleHealth = moduleHealthProvider();
2426
+ core.wsServer.broadcast(lastStateEvent);
2427
+ }
2428
+ return true;
2429
+ }
2430
+ if (handleEsp32OtaReply(msg)) {
2431
+ return true;
2432
+ }
819
2433
  if (msg.type === 'session_push_register') {
820
2434
  const { sessionId, port: sessionPort, agentType: at, projectName: pn } = msg;
821
2435
  debug('daemon', `session_push_register: ${sessionId} port=${sessionPort} agent=${at}`);
@@ -835,8 +2449,77 @@ export async function startDaemon(opts) {
835
2449
  }
836
2450
  return true; // consumed
837
2451
  }
2452
+ if (msg.type === 'deck_slot_map') {
2453
+ // Plugin pushed its keypad layout. Forward to other viewers (extra
2454
+ // plugin instance, dashboard) and re-broadcast sessions_list so slot
2455
+ // buttons populate immediately — without this they would stay "Empty"
2456
+ // until the next 10 s sessions polling tick after the plugin connect.
2457
+ core.wsServer.broadcastExcept(msg, sender);
2458
+ core.broadcastSessionsList().catch(() => { });
2459
+ return true; // consumed
2460
+ }
2461
+ if (msg.type === 'query_session_timeline') {
2462
+ // Reply to THIS requester only with the session's recent timeline so a
2463
+ // device that connected mid-session can fill its Detail view on demand
2464
+ // (the live timeline_event stream is forward-only). Useful for any
2465
+ // reconnecting surface, not just the XTeink X3.
2466
+ const sessionId = msg.sessionId;
2467
+ const since = msg.since;
2468
+ if (typeof sessionId === 'string' && sessionId) {
2469
+ const sinceMs = typeof since === 'number' ? since : undefined;
2470
+ let entries = core.bridgeTimeline.getHistoryForSession(sessionId, sinceMs);
2471
+ // Passively-observed sessions never push timeline rows (the relay only
2472
+ // covers managed + hook sessions), so the store comes back empty for
2473
+ // them. Reconstruct a recent-activity timeline from their transcript so
2474
+ // the device's Detail view isn't stuck on "No recent activity yet".
2475
+ if (entries.length === 0) {
2476
+ try {
2477
+ entries = transcriptTimelineForSession(sessionId, { since: sinceMs });
2478
+ }
2479
+ catch { /* read-only best effort */ }
2480
+ }
2481
+ const historyEvent = buildCappedTimelineHistory(entries, undefined, { sessionId });
2482
+ if (historyEvent) {
2483
+ try {
2484
+ sender.send(JSON.stringify(historyEvent));
2485
+ }
2486
+ catch { /* client disconnecting */ }
2487
+ }
2488
+ }
2489
+ return true; // consumed
2490
+ }
838
2491
  return false; // not consumed — pass to command handler
839
2492
  });
2493
+ // Steering commands for observed (hook-only, no PTY) Claude sessions —
2494
+ // shared by the session_command route and the bare-command fallback below.
2495
+ // Semantics differ from managed PTY on purpose:
2496
+ // interrupt/escape → soft STOP (deny at the next tool call)
2497
+ // send_prompt → queued, delivered when the current turn ends
2498
+ // respond/select_option → resolve an open device-approval gate
2499
+ function handleObservedClaudeCommand(uuid, command) {
2500
+ const type = typeof command?.type === 'string' ? command.type : '';
2501
+ if (type === 'interrupt' || type === 'escape') {
2502
+ requestStop(uuid);
2503
+ core.broadcastSessionsList().catch(() => { });
2504
+ return;
2505
+ }
2506
+ if (type === 'send_prompt' && typeof command.text === 'string') {
2507
+ if (queueDirective(uuid, command.text))
2508
+ core.broadcastSessionsList().catch(() => { });
2509
+ return;
2510
+ }
2511
+ if (type === 'respond' || type === 'select_option') {
2512
+ const ov = getAwaitingOverlay(uuid);
2513
+ if (ov?.requestId) {
2514
+ const allow = type === 'select_option'
2515
+ ? command.index === 0
2516
+ : command.value === 'y' || command.value === 'yes';
2517
+ resolvePending(ov.requestId, allow ? 'allow' : 'deny');
2518
+ }
2519
+ return;
2520
+ }
2521
+ debug('daemon', `observed steering: unsupported command ${type} for ${uuid}`);
2522
+ }
840
2523
  core.wsServer.onCommand((cmd) => {
841
2524
  debug('daemon', `cmd: ${cmd.type}`);
842
2525
  if (gatewayAdapter?.isAlive() && gatewayAdapter.handleCommand(cmd)) {
@@ -860,26 +2543,27 @@ export async function startDaemon(opts) {
860
2543
  return;
861
2544
  }
862
2545
  if (cmd.type === 'switch_agent') {
2546
+ userFocusedSessionId = null;
863
2547
  focusRelay.unfocus(); // Clear session focus on agent switch
864
2548
  const target = cmd.agent;
865
2549
  if (target === 'openclaw' && gatewayAdapter?.isAlive()) {
866
2550
  // Force broadcast OpenClaw state to all clients
867
2551
  const snap = core.stateMachine.getSnapshot();
868
- const gwStateEvent = core.buildStateEvent({
2552
+ const gwStateEvent = attachFocusedSessionId(core.buildStateEvent({
869
2553
  agentType: 'openclaw',
870
2554
  agentCapabilities: OPENCLAW_CAPABILITIES,
871
2555
  snapshot: snap,
872
- });
2556
+ }));
873
2557
  lastStateEvent = gwStateEvent;
874
2558
  core.wsServer.broadcast(gwStateEvent);
875
2559
  }
876
2560
  else if (target === 'claude-code') {
877
2561
  // Broadcast daemon/claude-code state — clients reconnect to session bridges independently
878
2562
  const snap = core.stateMachine.getSnapshot();
879
- const stateEvent = core.buildStateEvent({
2563
+ const stateEvent = attachFocusedSessionId(core.buildStateEvent({
880
2564
  agentType: 'daemon',
881
2565
  snapshot: snap,
882
- });
2566
+ }));
883
2567
  lastStateEvent = stateEvent;
884
2568
  core.wsServer.broadcast(stateEvent);
885
2569
  }
@@ -889,14 +2573,121 @@ export async function startDaemon(opts) {
889
2573
  const sessionId = cmd.sessionId;
890
2574
  if (!sessionId)
891
2575
  return;
2576
+ userFocusedSessionId = sessionId;
2577
+ broadcastFocusedState();
2578
+ if (sessionId === 'openclaw-gateway' && gatewayAdapter?.isAlive()) {
2579
+ focusRelay.unfocus();
2580
+ return;
2581
+ }
892
2582
  focusRelay.focus(sessionId);
893
2583
  return;
894
2584
  }
2585
+ if (cmd.type === 'clear_session_focus') {
2586
+ userFocusedSessionId = null;
2587
+ focusRelay.unfocus();
2588
+ broadcastFocusedState();
2589
+ return;
2590
+ }
2591
+ // Independent on-demand review (REVIEW deck button): judge the session's
2592
+ // working-tree delta with an independent model. Valid for every session
2593
+ // type — no agent control involved. Result: WS events + badge fields +
2594
+ // HTML report opened in the browser (this daemon's "popup" tier).
2595
+ if (cmd.type === 'review_run') {
2596
+ const sessionId = cmd.sessionId;
2597
+ if (!sessionId)
2598
+ return;
2599
+ const managed = listActiveSessions().find(s => s.id === sessionId);
2600
+ const observed = managed ? undefined
2601
+ : passiveSessionObserver.collect([]).find(s => s.id === sessionId);
2602
+ const target = (managed ?? observed);
2603
+ // cwd sources: observed rows carry it from the process scan; managed
2604
+ // registry rows don't — fall back to the session's APME run projectPath.
2605
+ const cwd = (typeof target?.cwd === 'string' && target.cwd) ? target.cwd
2606
+ : (apme ? (() => {
2607
+ const runId = apme.collector.getRunId(sessionId);
2608
+ return runId ? apme.store.getRun(runId)?.projectPath ?? undefined : undefined;
2609
+ })() : undefined);
2610
+ // Recent request↔response lines so the judge evaluates the diff
2611
+ // against what the user actually asked (not just code in isolation).
2612
+ const recentActivity = core.bridgeTimeline.getHistoryForSession(sessionId, undefined, 16)
2613
+ .filter((e) => e.type === 'chat_start' || e.type === 'chat_response')
2614
+ .map((e) => `${e.type === 'chat_start' ? 'USER' : 'AGENT'}: ${e.raw}`)
2615
+ .join('\n');
2616
+ void runSessionReview({
2617
+ sessionId,
2618
+ cwd,
2619
+ projectName: target?.projectName ?? cwd?.split('/').filter(Boolean).pop() ?? 'unknown',
2620
+ recentActivity,
2621
+ onEvent: (event) => {
2622
+ core.wsServer.broadcast(event);
2623
+ core.broadcastSessionsList().catch(() => { }); // badge refresh
2624
+ },
2625
+ recordEval: apme ? (record) => {
2626
+ // Record into the same eval store as the automatic pipeline,
2627
+ // flagged `manual_review`, on the session's active task (if any).
2628
+ const runId = apme.collector.getRunId(sessionId);
2629
+ const taskId = apme.collector.getActiveTaskId(sessionId);
2630
+ if (!runId || !taskId)
2631
+ return;
2632
+ apme.store.insertEvalForTask({
2633
+ runId, taskId, layer: 'manual_review', metric: 'risk',
2634
+ score: record.score, raw: record.raw,
2635
+ judgeModel: record.judgeModel, createdAt: Date.now(),
2636
+ });
2637
+ } : undefined,
2638
+ });
2639
+ core.broadcastSessionsList().catch(() => { }); // REVIEWING tile
2640
+ return;
2641
+ }
2642
+ // Device approval for a gated PreToolUse permission request (observed session).
2643
+ // Resolves the held hook HTTP response → Claude allows/denies the tool.
2644
+ if (cmd.type === 'permission_decision') {
2645
+ const { requestId, decision } = cmd;
2646
+ if (typeof requestId !== 'string' || (decision !== 'allow' && decision !== 'deny'))
2647
+ return;
2648
+ // OpenCode permission (requestId = "ocperm:<rawSid>:<permissionID>"):
2649
+ // route through the observer-plugin queue; the plugin resolves it via
2650
+ // POST /session/{id}/permissions/{permissionID}.
2651
+ if (requestId.startsWith('ocperm:')) {
2652
+ const rest = requestId.slice('ocperm:'.length);
2653
+ const sep = rest.indexOf(':');
2654
+ if (sep > 0) {
2655
+ enqueueOpenCodeCommand(rest.slice(0, sep), {
2656
+ type: 'permission_respond', permissionId: rest.slice(sep + 1), response: decision,
2657
+ });
2658
+ }
2659
+ return;
2660
+ }
2661
+ // resolvePending → onResolved clears the overlay + rebroadcasts (sessions_list
2662
+ // + focused state), so all surfaces drop the gate. No-op if already resolved.
2663
+ resolvePending(requestId, decision);
2664
+ return;
2665
+ }
895
2666
  // Session-scoped command: forward inner command to a specific session's bridge
896
2667
  if (cmd.type === 'session_command') {
897
2668
  const { sessionId, command } = cmd;
898
2669
  if (!sessionId || !command)
899
2670
  return;
2671
+ // Observed Claude sessions have no bridge/PTY — route to the hook-based
2672
+ // steering primitives instead (soft STOP, turn-end directive queue,
2673
+ // gate approval). This replaces the old silent drop.
2674
+ if (typeof sessionId === 'string' && sessionId.startsWith('observed:claude:')) {
2675
+ handleObservedClaudeCommand(sessionId.slice('observed:claude:'.length), command);
2676
+ return;
2677
+ }
2678
+ // Observed OpenCode sessions steer directly through the observer
2679
+ // plugin's command queue (immediate abort / prompt injection).
2680
+ if (typeof sessionId === 'string' && (sessionId.startsWith('opencode:') || sessionId.startsWith('observed:opencode:'))) {
2681
+ const rawSid = sessionId.replace(/^(?:observed:)?opencode:/, '');
2682
+ const type = typeof command?.type === 'string' ? command.type : '';
2683
+ if (type === 'interrupt' || type === 'escape') {
2684
+ enqueueOpenCodeCommand(rawSid, { type: 'interrupt' });
2685
+ }
2686
+ else if (type === 'send_prompt' && typeof command.text === 'string') {
2687
+ enqueueOpenCodeCommand(rawSid, { type: 'send_prompt', text: command.text });
2688
+ }
2689
+ return;
2690
+ }
900
2691
  const sessions = listActiveSessions();
901
2692
  const target = sessions.find(s => s.id === sessionId);
902
2693
  if (!target) {
@@ -904,15 +2695,40 @@ export async function startDaemon(opts) {
904
2695
  return;
905
2696
  }
906
2697
  // Focus the session first, then route the command
2698
+ userFocusedSessionId = sessionId;
2699
+ broadcastFocusedState();
907
2700
  focusRelay.focus(sessionId);
908
2701
  // Small delay to let focus take effect, then route
909
2702
  setTimeout(() => focusRelay.routeCommand(command), 100);
910
2703
  return;
911
2704
  }
2705
+ // Session-scoped option select from a multi-up panel (IPS10 D1 mosaic):
2706
+ // any awaiting cell can be answered, not just the focused one. Focus the
2707
+ // named session, then route a plain select_option to its bridge.
2708
+ if (cmd.type === 'select_option' && typeof cmd.sessionId === 'string') {
2709
+ const sessionId = cmd.sessionId;
2710
+ const target = listActiveSessions().find(s => s.id === sessionId);
2711
+ if (target) {
2712
+ userFocusedSessionId = sessionId;
2713
+ broadcastFocusedState();
2714
+ focusRelay.focus(sessionId);
2715
+ setTimeout(() => focusRelay.routeCommand({ type: 'select_option', index: cmd.index }), 100);
2716
+ return;
2717
+ }
2718
+ }
912
2719
  // Route interactive commands to focused session (if any)
913
2720
  if (focusRelay.getFocusedSessionId() && focusRelay.routeCommand(cmd)) {
914
2721
  return;
915
2722
  }
2723
+ // Bare-command fallback for an observed-focused session: older device
2724
+ // clients send unwrapped commands after focus_session. The focus relay
2725
+ // can't route them (no bridge), so map them to the steering primitives
2726
+ // instead of dropping them silently.
2727
+ if (userFocusedSessionId?.startsWith('observed:claude:')
2728
+ && ['interrupt', 'escape', 'send_prompt', 'respond', 'select_option'].includes(cmd.type)) {
2729
+ handleObservedClaudeCommand(userFocusedSessionId.slice('observed:claude:'.length), cmd);
2730
+ return;
2731
+ }
916
2732
  if (cmd.type === 'query_usage') {
917
2733
  fetchUsageRelayed(port).then((usage) => {
918
2734
  if (usage)
@@ -944,6 +2760,9 @@ export async function startDaemon(opts) {
944
2760
  });
945
2761
  }
946
2762
  });
2763
+ core.wsServer.onClientDisconnect((ws) => {
2764
+ unregisterWifiEsp32Socket(ws);
2765
+ });
947
2766
  // ===== Probes & polling =====
948
2767
  core.startOllamaProbe();
949
2768
  core.startMlxProbe();
@@ -962,7 +2781,39 @@ export async function startDaemon(opts) {
962
2781
  const { classifyRunSmart } = await import('./apme/classifier.js');
963
2782
  const { aggregateOverall } = await import('./apme/http.js');
964
2783
  // Broadcast eval results to all WS clients + timeline when runner completes
965
- apme.runner.onResult(({ runId, turnId }) => {
2784
+ apme.runner.onResult(({ runId, turnId, taskId, layer1Ran, layer2Ran, overall }) => {
2785
+ // Task-level eval: a group of turns between boundary signals
2786
+ // (TodoWrite all-completed / /clear / session_end). Distinct timeline
2787
+ // entry so users see "★ task 85%" rather than another run-level pulse.
2788
+ // The task's summary axis is the most user-readable signal and goes
2789
+ // into `detail` ahead of the per-axis breakdown.
2790
+ if (taskId) {
2791
+ const run = apme.store.getRun(runId);
2792
+ const task = apme.store.getTask(taskId);
2793
+ if (!run || !task)
2794
+ return;
2795
+ const taskEvals = apme.store.listEvalsForTask(taskId);
2796
+ const overallEval = taskEvals.find(e => e.metric === 'overall');
2797
+ if (!overallEval)
2798
+ return;
2799
+ const taskEvalEvent = {
2800
+ type: 'apme_eval',
2801
+ run: {
2802
+ runId: run.id, sessionId: run.sessionId, agentType: run.agentType, startedAt: run.startedAt,
2803
+ modelId: run.modelId ?? undefined, projectName: run.projectName ?? undefined,
2804
+ taskPrompt: run.taskPrompt ?? undefined, taskCategory: run.taskCategory ?? undefined,
2805
+ outcome: 'committed',
2806
+ compositeScore: overallEval.score,
2807
+ overallScore: overallEval.score,
2808
+ evals: taskEvals.map(e => ({
2809
+ layer: e.layer, metric: e.metric, score: e.score,
2810
+ judgeModel: e.judgeModel ?? undefined, createdAt: e.createdAt,
2811
+ })),
2812
+ },
2813
+ };
2814
+ core.broadcast(taskEvalEvent);
2815
+ return;
2816
+ }
966
2817
  // Turn-level eval: broadcast and add timeline entry with turn score
967
2818
  if (turnId) {
968
2819
  const run = apme.store.getRun(runId);
@@ -981,7 +2832,6 @@ export async function startDaemon(opts) {
981
2832
  });
982
2833
  }
983
2834
  catch { /* ignore */ }
984
- const pct = Math.round(overall.score * 100);
985
2835
  // WS broadcast — reuse apme_eval event for turn eval so dashboards pick it up
986
2836
  const turnEvalEvent = {
987
2837
  type: 'apme_eval',
@@ -999,30 +2849,6 @@ export async function startDaemon(opts) {
999
2849
  },
1000
2850
  };
1001
2851
  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
2852
  return;
1027
2853
  }
1028
2854
  const run = apme.store.getRun(runId);
@@ -1030,6 +2856,10 @@ export async function startDaemon(opts) {
1030
2856
  return;
1031
2857
  const evals = apme.store.listEvalsForRun(runId);
1032
2858
  const overallScore = aggregateOverall(evals);
2859
+ if (!layer1Ran && !layer2Ran && overall === undefined && evals.length === 0) {
2860
+ debug('APME', `skip run eval timeline for ${runId.slice(0, 8)} — no eval rows produced`);
2861
+ return;
2862
+ }
1033
2863
  // WS broadcast: apme_eval event (type already in protocol.ts)
1034
2864
  const evalEvent = {
1035
2865
  type: 'apme_eval',
@@ -1047,34 +2877,6 @@ export async function startDaemon(opts) {
1047
2877
  },
1048
2878
  };
1049
2879
  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
2880
  });
1079
2881
  const apmeEvalTimer = setInterval(() => {
1080
2882
  // 1. Enqueue unevaluated runs for deterministic + judge
@@ -1145,24 +2947,38 @@ export async function startDaemon(opts) {
1145
2947
  }
1146
2948
  });
1147
2949
  }, 10_000));
2950
+ // Backstop sweep for held PreToolUse approval responses whose per-entry timer
2951
+ // somehow didn't fire — resolves anything older than 60s to "ask" so a held
2952
+ // socket can't leak and Claude's own prompt isn't lost forever.
2953
+ const permissionSweepTimer = setInterval(() => { sweepStalePending(60_000); }, 30_000);
2954
+ permissionSweepTimer.unref?.();
1148
2955
  // ===== Shutdown =====
1149
2956
  core.onShutdown(async () => {
2957
+ clearInterval(permissionSweepTimer);
2958
+ drainAllPending();
1150
2959
  removeDaemonInfo();
1151
2960
  focusRelay.stop();
1152
2961
  timelineRelay.stop();
1153
2962
  voiceAssistant?.stop();
1154
2963
  voiceManager?.disconnectFromServer();
1155
2964
  bridgeLogStream.stop();
2965
+ // Flush synchronously — the process exits a few lines below and a pending
2966
+ // debounce timer would take the last turn's entries with it.
2967
+ core.bridgeTimeline.stopPersistence();
2968
+ // iDotMatrix BLE sync is stopped by IDotMatrixModule.stop() via stopModules below.
1156
2969
  await Promise.all([
1157
2970
  gatewayAdapter ? gatewayAdapter.shutdown().catch(() => { }) : Promise.resolve(),
1158
2971
  stopModules(startedModules)
1159
2972
  ]);
1160
2973
  gatewayAdapter = null;
1161
- httpServer.close(() => process.exit(0));
2974
+ httpServer.close(() => exitProcessNow(0));
1162
2975
  // Force exit if httpServer.close() hangs on CLOSE_WAIT connections
1163
- setTimeout(() => process.exit(0), 5000).unref();
2976
+ setTimeout(() => exitProcessNow(0), 5000).unref();
1164
2977
  });
1165
2978
  core.registerProcessHandlers('agentdeck');
2979
+ // Trigger initial state broadcast so display hardware modules (D200H, Pixoo, Serial) get populated
2980
+ broadcastFocusedState();
2981
+ core.broadcastUsage();
1166
2982
  log(`[agentdeck] Daemon running. Gateway probe active.`);
1167
2983
  }
1168
2984
  //# sourceMappingURL=daemon-server.js.map