@agentdeck/bridge 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (383) hide show
  1. package/assets/fonts/IBMPlexSans-Bold.ttf +0 -0
  2. package/assets/fonts/IBMPlexSans-Regular.ttf +0 -0
  3. package/assets/fonts/JetBrainsMono-Bold.ttf +0 -0
  4. package/assets/fonts/JetBrainsMono-Regular.ttf +0 -0
  5. package/assets/fonts/LICENSES.md +22 -0
  6. package/dist/adapters/claude-code.d.ts +6 -0
  7. package/dist/adapters/claude-code.d.ts.map +1 -1
  8. package/dist/adapters/claude-code.js +8 -0
  9. package/dist/adapters/claude-code.js.map +1 -1
  10. package/dist/adapters/openclaw.d.ts +24 -0
  11. package/dist/adapters/openclaw.d.ts.map +1 -1
  12. package/dist/adapters/openclaw.js +291 -35
  13. package/dist/adapters/openclaw.js.map +1 -1
  14. package/dist/adapters/opencode-adapter.d.ts +59 -0
  15. package/dist/adapters/opencode-adapter.d.ts.map +1 -1
  16. package/dist/adapters/opencode-adapter.js +297 -35
  17. package/dist/adapters/opencode-adapter.js.map +1 -1
  18. package/dist/adb-reverse.d.ts +1 -1
  19. package/dist/adb-reverse.js +1 -1
  20. package/dist/antigravity-local.d.ts +1 -0
  21. package/dist/antigravity-local.d.ts.map +1 -1
  22. package/dist/antigravity-local.js +33 -0
  23. package/dist/antigravity-local.js.map +1 -1
  24. package/dist/apme/adapters/claude-hook.d.ts.map +1 -1
  25. package/dist/apme/adapters/claude-hook.js +5 -0
  26. package/dist/apme/adapters/claude-hook.js.map +1 -1
  27. package/dist/apme/adapters/codex-hook.d.ts +17 -0
  28. package/dist/apme/adapters/codex-hook.d.ts.map +1 -0
  29. package/dist/apme/adapters/codex-hook.js +95 -0
  30. package/dist/apme/adapters/codex-hook.js.map +1 -0
  31. package/dist/apme/adapters/codex-turn-manager.d.ts +86 -0
  32. package/dist/apme/adapters/codex-turn-manager.d.ts.map +1 -0
  33. package/dist/apme/adapters/codex-turn-manager.js +496 -0
  34. package/dist/apme/adapters/codex-turn-manager.js.map +1 -0
  35. package/dist/apme/adapters/openclaw-hook.d.ts +99 -0
  36. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -0
  37. package/dist/apme/adapters/openclaw-hook.js +234 -0
  38. package/dist/apme/adapters/openclaw-hook.js.map +1 -0
  39. package/dist/apme/adapters/opencode-hook.d.ts +56 -0
  40. package/dist/apme/adapters/opencode-hook.d.ts.map +1 -0
  41. package/dist/apme/adapters/opencode-hook.js +182 -0
  42. package/dist/apme/adapters/opencode-hook.js.map +1 -0
  43. package/dist/apme/adapters/timeline.d.ts.map +1 -1
  44. package/dist/apme/adapters/timeline.js +6 -1
  45. package/dist/apme/adapters/timeline.js.map +1 -1
  46. package/dist/apme/antigravity-transcript.d.ts +18 -0
  47. package/dist/apme/antigravity-transcript.d.ts.map +1 -0
  48. package/dist/apme/antigravity-transcript.js +94 -0
  49. package/dist/apme/antigravity-transcript.js.map +1 -0
  50. package/dist/apme/classify-turn.d.ts +13 -0
  51. package/dist/apme/classify-turn.d.ts.map +1 -0
  52. package/dist/apme/classify-turn.js +49 -0
  53. package/dist/apme/classify-turn.js.map +1 -0
  54. package/dist/apme/claude-transcript-reader.d.ts +10 -0
  55. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  56. package/dist/apme/claude-transcript-reader.js +35 -0
  57. package/dist/apme/claude-transcript-reader.js.map +1 -1
  58. package/dist/apme/collector.d.ts +129 -6
  59. package/dist/apme/collector.d.ts.map +1 -1
  60. package/dist/apme/collector.js +470 -23
  61. package/dist/apme/collector.js.map +1 -1
  62. package/dist/apme/dashboard-html.d.ts.map +1 -1
  63. package/dist/apme/dashboard-html.js +87 -11
  64. package/dist/apme/dashboard-html.js.map +1 -1
  65. package/dist/apme/http.d.ts.map +1 -1
  66. package/dist/apme/http.js +43 -6
  67. package/dist/apme/http.js.map +1 -1
  68. package/dist/apme/index.d.ts +26 -5
  69. package/dist/apme/index.d.ts.map +1 -1
  70. package/dist/apme/index.js +135 -9
  71. package/dist/apme/index.js.map +1 -1
  72. package/dist/apme/pareto.d.ts +39 -0
  73. package/dist/apme/pareto.d.ts.map +1 -0
  74. package/dist/apme/pareto.js +59 -0
  75. package/dist/apme/pareto.js.map +1 -0
  76. package/dist/apme/recommend.d.ts +3 -0
  77. package/dist/apme/recommend.d.ts.map +1 -1
  78. package/dist/apme/recommend.js +43 -1
  79. package/dist/apme/recommend.js.map +1 -1
  80. package/dist/apme/runner.d.ts +92 -4
  81. package/dist/apme/runner.d.ts.map +1 -1
  82. package/dist/apme/runner.js +586 -70
  83. package/dist/apme/runner.js.map +1 -1
  84. package/dist/apme/sample-to-timeline.d.ts +26 -0
  85. package/dist/apme/sample-to-timeline.d.ts.map +1 -0
  86. package/dist/apme/sample-to-timeline.js +95 -0
  87. package/dist/apme/sample-to-timeline.js.map +1 -0
  88. package/dist/apme/scorers/index.d.ts +47 -0
  89. package/dist/apme/scorers/index.d.ts.map +1 -0
  90. package/dist/apme/scorers/index.js +96 -0
  91. package/dist/apme/scorers/index.js.map +1 -0
  92. package/dist/apme/settings.d.ts +14 -10
  93. package/dist/apme/settings.d.ts.map +1 -1
  94. package/dist/apme/settings.js +34 -8
  95. package/dist/apme/settings.js.map +1 -1
  96. package/dist/apme/store.d.ts +33 -1
  97. package/dist/apme/store.d.ts.map +1 -1
  98. package/dist/apme/store.js +334 -25
  99. package/dist/apme/store.js.map +1 -1
  100. package/dist/awaiting-overlay.d.ts +99 -0
  101. package/dist/awaiting-overlay.d.ts.map +1 -0
  102. package/dist/awaiting-overlay.js +148 -0
  103. package/dist/awaiting-overlay.js.map +1 -0
  104. package/dist/ble-sync-spawn.d.ts +63 -0
  105. package/dist/ble-sync-spawn.d.ts.map +1 -0
  106. package/dist/ble-sync-spawn.js +196 -0
  107. package/dist/ble-sync-spawn.js.map +1 -0
  108. package/dist/bridge-core.d.ts +24 -1
  109. package/dist/bridge-core.d.ts.map +1 -1
  110. package/dist/bridge-core.js +157 -24
  111. package/dist/bridge-core.js.map +1 -1
  112. package/dist/broadcast.d.ts +3 -0
  113. package/dist/broadcast.d.ts.map +1 -0
  114. package/dist/broadcast.js +122 -0
  115. package/dist/broadcast.js.map +1 -0
  116. package/dist/check-deps.d.ts.map +1 -1
  117. package/dist/check-deps.js +25 -9
  118. package/dist/check-deps.js.map +1 -1
  119. package/dist/cli.d.ts +15 -1
  120. package/dist/cli.d.ts.map +1 -1
  121. package/dist/cli.js +1021 -93
  122. package/dist/cli.js.map +1 -1
  123. package/dist/codex-auth.d.ts +21 -0
  124. package/dist/codex-auth.d.ts.map +1 -1
  125. package/dist/codex-auth.js +52 -1
  126. package/dist/codex-auth.js.map +1 -1
  127. package/dist/codex-output-parser.d.ts +8 -0
  128. package/dist/codex-output-parser.d.ts.map +1 -1
  129. package/dist/codex-output-parser.js +110 -25
  130. package/dist/codex-output-parser.js.map +1 -1
  131. package/dist/codex-rate-limits.d.ts +16 -0
  132. package/dist/codex-rate-limits.d.ts.map +1 -0
  133. package/dist/codex-rate-limits.js +197 -0
  134. package/dist/codex-rate-limits.js.map +1 -0
  135. package/dist/codex-rollout-response.d.ts +31 -0
  136. package/dist/codex-rollout-response.d.ts.map +1 -0
  137. package/dist/codex-rollout-response.js +135 -0
  138. package/dist/codex-rollout-response.js.map +1 -0
  139. package/dist/daemon-server.d.ts +85 -0
  140. package/dist/daemon-server.d.ts.map +1 -1
  141. package/dist/daemon-server.js +1627 -159
  142. package/dist/daemon-server.js.map +1 -1
  143. package/dist/daemon.js +15 -6
  144. package/dist/daemon.js.map +1 -1
  145. package/dist/display-dim.d.ts +9 -0
  146. package/dist/display-dim.d.ts.map +1 -0
  147. package/dist/display-dim.js +53 -0
  148. package/dist/display-dim.js.map +1 -0
  149. package/dist/display-monitor.d.ts +15 -2
  150. package/dist/display-monitor.d.ts.map +1 -1
  151. package/dist/display-monitor.js +104 -26
  152. package/dist/display-monitor.js.map +1 -1
  153. package/dist/esp32-serial.d.ts +160 -1
  154. package/dist/esp32-serial.d.ts.map +1 -1
  155. package/dist/esp32-serial.js +950 -153
  156. package/dist/esp32-serial.js.map +1 -1
  157. package/dist/fallback-task-timeline.d.ts +42 -0
  158. package/dist/fallback-task-timeline.d.ts.map +1 -0
  159. package/dist/fallback-task-timeline.js +112 -0
  160. package/dist/fallback-task-timeline.js.map +1 -0
  161. package/dist/foundation-models-helper.d.ts +11 -0
  162. package/dist/foundation-models-helper.d.ts.map +1 -0
  163. package/dist/foundation-models-helper.js +241 -0
  164. package/dist/foundation-models-helper.js.map +1 -0
  165. package/dist/hook-migration.js +1 -1
  166. package/dist/hook-migration.js.map +1 -1
  167. package/dist/hook-server.d.ts +2 -3
  168. package/dist/hook-server.d.ts.map +1 -1
  169. package/dist/hook-server.js +22 -10
  170. package/dist/hook-server.js.map +1 -1
  171. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts +33 -0
  172. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -0
  173. package/dist/idotmatrix/idotmatrix-daemon-sync.js +164 -0
  174. package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -0
  175. package/dist/idotmatrix/idotmatrix-discover.d.ts +18 -0
  176. package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -0
  177. package/dist/idotmatrix/idotmatrix-discover.js +85 -0
  178. package/dist/idotmatrix/idotmatrix-discover.js.map +1 -0
  179. package/dist/idotmatrix/idotmatrix-settings.d.ts +20 -0
  180. package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -0
  181. package/dist/idotmatrix/idotmatrix-settings.js +57 -0
  182. package/dist/idotmatrix/idotmatrix-settings.js.map +1 -0
  183. package/dist/index.d.ts.map +1 -1
  184. package/dist/index.js +215 -179
  185. package/dist/index.js.map +1 -1
  186. package/dist/log-stream.d.ts +15 -16
  187. package/dist/log-stream.d.ts.map +1 -1
  188. package/dist/log-stream.js +18 -96
  189. package/dist/log-stream.js.map +1 -1
  190. package/dist/logger.d.ts.map +1 -1
  191. package/dist/logger.js +12 -3
  192. package/dist/logger.js.map +1 -1
  193. package/dist/mdns.d.ts +17 -0
  194. package/dist/mdns.d.ts.map +1 -1
  195. package/dist/mdns.js +52 -18
  196. package/dist/mdns.js.map +1 -1
  197. package/dist/modules/adb-module.d.ts +1 -1
  198. package/dist/modules/adb-module.d.ts.map +1 -1
  199. package/dist/modules/adb-module.js +4 -3
  200. package/dist/modules/adb-module.js.map +1 -1
  201. package/dist/modules/broadcast-module.d.ts +17 -0
  202. package/dist/modules/broadcast-module.d.ts.map +1 -0
  203. package/dist/modules/broadcast-module.js +26 -0
  204. package/dist/modules/broadcast-module.js.map +1 -0
  205. package/dist/modules/idotmatrix-module.d.ts +9 -0
  206. package/dist/modules/idotmatrix-module.d.ts.map +1 -0
  207. package/dist/modules/idotmatrix-module.js +46 -0
  208. package/dist/modules/idotmatrix-module.js.map +1 -0
  209. package/dist/modules/index.d.ts +3 -1
  210. package/dist/modules/index.d.ts.map +1 -1
  211. package/dist/modules/index.js +9 -3
  212. package/dist/modules/index.js.map +1 -1
  213. package/dist/modules/pixoo-module.d.ts.map +1 -1
  214. package/dist/modules/pixoo-module.js +14 -4
  215. package/dist/modules/pixoo-module.js.map +1 -1
  216. package/dist/modules/serial-module.d.ts +4 -0
  217. package/dist/modules/serial-module.d.ts.map +1 -1
  218. package/dist/modules/serial-module.js +14 -12
  219. package/dist/modules/serial-module.js.map +1 -1
  220. package/dist/modules/timebox-module.d.ts +9 -0
  221. package/dist/modules/timebox-module.d.ts.map +1 -0
  222. package/dist/modules/timebox-module.js +46 -0
  223. package/dist/modules/timebox-module.js.map +1 -0
  224. package/dist/modules/types.d.ts +4 -2
  225. package/dist/modules/types.d.ts.map +1 -1
  226. package/dist/openclaw-session.d.ts +18 -0
  227. package/dist/openclaw-session.d.ts.map +1 -0
  228. package/dist/openclaw-session.js +34 -0
  229. package/dist/openclaw-session.js.map +1 -0
  230. package/dist/output-parser.d.ts +30 -0
  231. package/dist/output-parser.d.ts.map +1 -1
  232. package/dist/output-parser.js +125 -9
  233. package/dist/output-parser.js.map +1 -1
  234. package/dist/passive-observer.d.ts +26 -0
  235. package/dist/passive-observer.d.ts.map +1 -1
  236. package/dist/passive-observer.js +258 -49
  237. package/dist/passive-observer.js.map +1 -1
  238. package/dist/permission-resolver.d.ts +47 -0
  239. package/dist/permission-resolver.d.ts.map +1 -0
  240. package/dist/permission-resolver.js +103 -0
  241. package/dist/permission-resolver.js.map +1 -0
  242. package/dist/pixoo/micro-glyphs.d.ts +49 -0
  243. package/dist/pixoo/micro-glyphs.d.ts.map +1 -0
  244. package/dist/pixoo/micro-glyphs.js +276 -0
  245. package/dist/pixoo/micro-glyphs.js.map +1 -0
  246. package/dist/pixoo/pixoo-bridge.d.ts +2 -2
  247. package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
  248. package/dist/pixoo/pixoo-bridge.js +93 -25
  249. package/dist/pixoo/pixoo-bridge.js.map +1 -1
  250. package/dist/pixoo/pixoo-camera.d.ts +19 -3
  251. package/dist/pixoo/pixoo-camera.d.ts.map +1 -1
  252. package/dist/pixoo/pixoo-camera.js +62 -27
  253. package/dist/pixoo/pixoo-camera.js.map +1 -1
  254. package/dist/pixoo/pixoo-client.d.ts +13 -3
  255. package/dist/pixoo/pixoo-client.d.ts.map +1 -1
  256. package/dist/pixoo/pixoo-client.js +51 -22
  257. package/dist/pixoo/pixoo-client.js.map +1 -1
  258. package/dist/pixoo/pixoo-discover.d.ts +37 -0
  259. package/dist/pixoo/pixoo-discover.d.ts.map +1 -0
  260. package/dist/pixoo/pixoo-discover.js +105 -0
  261. package/dist/pixoo/pixoo-discover.js.map +1 -0
  262. package/dist/pixoo/pixoo-renderer.d.ts +8 -3
  263. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  264. package/dist/pixoo/pixoo-renderer.js +189 -60
  265. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  266. package/dist/pixoo/pixoo-settings.d.ts +6 -0
  267. package/dist/pixoo/pixoo-settings.d.ts.map +1 -1
  268. package/dist/pixoo/pixoo-settings.js +9 -0
  269. package/dist/pixoo/pixoo-settings.js.map +1 -1
  270. package/dist/pixoo/pixoo-sprites.d.ts +60 -11
  271. package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
  272. package/dist/pixoo/pixoo-sprites.js +512 -209
  273. package/dist/pixoo/pixoo-sprites.js.map +1 -1
  274. package/dist/pty-manager.d.ts.map +1 -1
  275. package/dist/pty-manager.js +8 -2
  276. package/dist/pty-manager.js.map +1 -1
  277. package/dist/session-activity.d.ts +14 -0
  278. package/dist/session-activity.d.ts.map +1 -0
  279. package/dist/session-activity.js +160 -0
  280. package/dist/session-activity.js.map +1 -0
  281. package/dist/session-aggregator.d.ts +17 -0
  282. package/dist/session-aggregator.d.ts.map +1 -1
  283. package/dist/session-aggregator.js +48 -9
  284. package/dist/session-aggregator.js.map +1 -1
  285. package/dist/session-registry.d.ts +71 -3
  286. package/dist/session-registry.d.ts.map +1 -1
  287. package/dist/session-registry.js +137 -8
  288. package/dist/session-registry.js.map +1 -1
  289. package/dist/session-timeline-relay.d.ts.map +1 -1
  290. package/dist/session-timeline-relay.js +13 -4
  291. package/dist/session-timeline-relay.js.map +1 -1
  292. package/dist/session-transcript-timeline.d.ts +49 -0
  293. package/dist/session-transcript-timeline.d.ts.map +1 -0
  294. package/dist/session-transcript-timeline.js +276 -0
  295. package/dist/session-transcript-timeline.js.map +1 -0
  296. package/dist/state-machine.d.ts +6 -1
  297. package/dist/state-machine.d.ts.map +1 -1
  298. package/dist/state-machine.js +86 -28
  299. package/dist/state-machine.js.map +1 -1
  300. package/dist/timebox/timebox-daemon-sync.d.ts +19 -0
  301. package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -0
  302. package/dist/timebox/timebox-daemon-sync.js +135 -0
  303. package/dist/timebox/timebox-daemon-sync.js.map +1 -0
  304. package/dist/timebox/timebox-discover.d.ts +19 -0
  305. package/dist/timebox/timebox-discover.d.ts.map +1 -0
  306. package/dist/timebox/timebox-discover.js +87 -0
  307. package/dist/timebox/timebox-discover.js.map +1 -0
  308. package/dist/timebox/timebox-settings.d.ts +32 -0
  309. package/dist/timebox/timebox-settings.d.ts.map +1 -0
  310. package/dist/timebox/timebox-settings.js +80 -0
  311. package/dist/timebox/timebox-settings.js.map +1 -0
  312. package/dist/timeline-store.d.ts +63 -3
  313. package/dist/timeline-store.d.ts.map +1 -1
  314. package/dist/timeline-store.js +292 -14
  315. package/dist/timeline-store.js.map +1 -1
  316. package/dist/timeline-summarizer.d.ts.map +1 -1
  317. package/dist/timeline-summarizer.js +29 -5
  318. package/dist/timeline-summarizer.js.map +1 -1
  319. package/dist/tui/ansi.d.ts +0 -4
  320. package/dist/tui/ansi.d.ts.map +1 -1
  321. package/dist/tui/ansi.js +14 -7
  322. package/dist/tui/ansi.js.map +1 -1
  323. package/dist/tui/dashboard.d.ts +1 -0
  324. package/dist/tui/dashboard.d.ts.map +1 -1
  325. package/dist/tui/dashboard.js +24 -7
  326. package/dist/tui/dashboard.js.map +1 -1
  327. package/dist/tui/renderer.d.ts +36 -0
  328. package/dist/tui/renderer.d.ts.map +1 -1
  329. package/dist/tui/renderer.js +303 -119
  330. package/dist/tui/renderer.js.map +1 -1
  331. package/dist/tui/terrarium.d.ts.map +1 -1
  332. package/dist/tui/terrarium.js +37 -49
  333. package/dist/tui/terrarium.js.map +1 -1
  334. package/dist/types.d.ts +2 -2
  335. package/dist/types.d.ts.map +1 -1
  336. package/dist/types.js +2 -2
  337. package/dist/types.js.map +1 -1
  338. package/dist/usage-api.d.ts.map +1 -1
  339. package/dist/usage-api.js +7 -1
  340. package/dist/usage-api.js.map +1 -1
  341. package/dist/usage-event.d.ts +10 -3
  342. package/dist/usage-event.d.ts.map +1 -1
  343. package/dist/usage-event.js +74 -12
  344. package/dist/usage-event.js.map +1 -1
  345. package/dist/utility-proxy.d.ts.map +1 -1
  346. package/dist/utility-proxy.js +8 -0
  347. package/dist/utility-proxy.js.map +1 -1
  348. package/dist/utils/project-name.d.ts +3 -0
  349. package/dist/utils/project-name.d.ts.map +1 -1
  350. package/dist/utils/project-name.js +44 -2
  351. package/dist/utils/project-name.js.map +1 -1
  352. package/dist/windows-service.d.ts +26 -0
  353. package/dist/windows-service.d.ts.map +1 -0
  354. package/dist/windows-service.js +145 -0
  355. package/dist/windows-service.js.map +1 -0
  356. package/dist/ws-server.d.ts +22 -1
  357. package/dist/ws-server.d.ts.map +1 -1
  358. package/dist/ws-server.js +89 -4
  359. package/dist/ws-server.js.map +1 -1
  360. package/fm-helper/AgentDeckFMHelper.swift +112 -0
  361. package/package.json +9 -3
  362. package/scripts/build-fm-helper.mjs +26 -0
  363. package/LICENSE +0 -21
  364. package/dist/apme/tuner.d.ts +0 -64
  365. package/dist/apme/tuner.d.ts.map +0 -1
  366. package/dist/apme/tuner.js +0 -297
  367. package/dist/apme/tuner.js.map +0 -1
  368. package/dist/d200h/hid-protocol.d.ts +0 -62
  369. package/dist/d200h/hid-protocol.d.ts.map +0 -1
  370. package/dist/d200h/hid-protocol.js +0 -124
  371. package/dist/d200h/hid-protocol.js.map +0 -1
  372. package/dist/d200h/image-renderer.d.ts +0 -36
  373. package/dist/d200h/image-renderer.d.ts.map +0 -1
  374. package/dist/d200h/image-renderer.js +0 -487
  375. package/dist/d200h/image-renderer.js.map +0 -1
  376. package/dist/modules/d200h-module.d.ts +0 -43
  377. package/dist/modules/d200h-module.d.ts.map +0 -1
  378. package/dist/modules/d200h-module.js +0 -360
  379. package/dist/modules/d200h-module.js.map +0 -1
  380. package/dist/modules/d200h-renderer.d.ts +0 -7
  381. package/dist/modules/d200h-renderer.d.ts.map +0 -1
  382. package/dist/modules/d200h-renderer.js +0 -321
  383. package/dist/modules/d200h-renderer.js.map +0 -1
@@ -11,46 +11,455 @@
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, isPermissionNotification, applyAwaitingOverlayToObserved } from './awaiting-overlay.js';
27
+ import { resolvePending, sweepStalePending, drainAllPending } from './permission-resolver.js';
22
28
  import { VoiceManager } from './voice.js';
23
29
  import { VoiceAssistantManager } from './voice-assistant.js';
24
- import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, writeDaemonInfo, removeDaemonInfo, readDaemonInfo, } from './session-registry.js';
30
+ import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, writeDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, } from './session-registry.js';
25
31
  import { fetchUsageFromApi, hasOAuthToken, resetConsecutiveFailures } from './usage-api.js';
26
32
  import { isLocalConnection, validateToken } from './auth.js';
27
33
  import { getLastFrame, renderPreviewFrame, onFrameRendered, offFrameRendered } from './pixoo/pixoo-bridge.js';
34
+ import { loadIDotMatrixDevices } from './idotmatrix/idotmatrix-settings.js';
28
35
  import { handlePixooWake } from './pixoo/pixoo-client.js';
29
36
  import { triggerMdnsRecovery } from './mdns.js';
30
37
  import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
31
38
  import { enableDebugLog, debug } from './logger.js';
32
- import { initApme } from './apme/index.js';
39
+ import { initApme, isTimelineProjectionEnabled, loadApmeConfig } from './apme/index.js';
40
+ import { FallbackTaskTimeline } from './fallback-task-timeline.js';
33
41
  import { handleApmeRequest } from './apme/http.js';
42
+ import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
43
+ import { transcriptTimelineForSession, lastAssistantTextFromTranscript } from './session-transcript-timeline.js';
44
+ import { lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
45
+ import { callFoundationModelsHelper } from './foundation-models-helper.js';
34
46
  import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
35
- import { esp32ConnectionCount, getESP32DeviceInfo, onESP32Message, sendWifiProvisionToAll, handleESP32Wake } from './esp32-serial.js';
47
+ import { esp32ConnectionCount, getESP32DeviceInfo, onESP32Message, sendWifiProvisionToAll, handleESP32Wake, getESP32Ports, getSerialConnectionStatus, getSerialLastError, getSerialReachableBoards } from './esp32-serial.js';
36
48
  import { loadWifiConfig } from './wifi-config.js';
37
- import { getConnectedAdbDevices, hasAdb } from './adb-reverse.js';
49
+ import { getConnectedAdbDevices, hasAdb, getAdbDeviceCount } from './adb-reverse.js';
38
50
  import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js';
39
- import { getLanIp } from '@agentdeck/shared';
40
- import { readFileSync } from 'fs';
51
+ import { loadTimeboxDevices } from './timebox/timebox-settings.js';
52
+ import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail } from '@agentdeck/shared';
53
+ import { injectOpenClawSession } from './openclaw-session.js';
54
+ import { readFileSync, statSync } from 'fs';
41
55
  import { join } from 'path';
42
56
  import { homedir } from 'os';
43
57
  import { OPENCLAW_CAPABILITIES, State, } from './types.js';
44
- function loadDaemonSettings() {
45
- try {
46
- return JSON.parse(readFileSync(join(homedir(), '.agentdeck', 'settings.json'), 'utf-8'));
58
+ function exitProcessNow(code = 0) {
59
+ if (code === 0) {
60
+ try {
61
+ process.kill(process.pid, 'SIGKILL');
62
+ return;
63
+ }
64
+ catch {
65
+ // fall through
66
+ }
67
+ }
68
+ process.exit(code);
69
+ }
70
+ const wifiEsp32Devices = new Map();
71
+ const wifiEsp32Sockets = new Map();
72
+ const otaWaiters = new Map();
73
+ function listWifiEsp32Devices() {
74
+ const now = Date.now();
75
+ const serialBoards = getSerialReachableBoards();
76
+ const out = [];
77
+ for (const [key, d] of wifiEsp32Devices) {
78
+ if (now - d.lastSeenMs > 60 * 60 * 1000) {
79
+ wifiEsp32Devices.delete(key);
80
+ wifiEsp32Sockets.delete(key);
81
+ continue;
82
+ }
83
+ // serialActive: this WiFi board is also live on USB serial, so its WiFi path
84
+ // is a hot standby — display events are driven over serial (single path).
85
+ const serialActive = isWifiTransportRedundant({ board: d.board, ip: d.ip }, serialBoards);
86
+ out.push({ ...d, stale: now - d.lastSeenMs > 90_000, serialActive });
87
+ }
88
+ return out;
89
+ }
90
+ function wifiEsp32Key(d) {
91
+ const board = typeof d.board === 'string' && d.board ? d.board : 'unknown';
92
+ const ip = typeof d.ip === 'string' && d.ip ? d.ip : 'no-ip';
93
+ return `${board}:${ip}`;
94
+ }
95
+ /**
96
+ * Single-path transport dedup. A physical ESP32 can be reachable over BOTH a
97
+ * USB serial connection and a WiFi WebSocket at once (e.g. inkdeck/ttgo/tc001
98
+ * plugged in for flashing while still joined to the AP). Serial is the more
99
+ * reliable, lower-latency path, so when a board is live on serial we drive it
100
+ * over serial only and suppress the redundant WiFi copy — no board receives the
101
+ * same event twice.
102
+ *
103
+ * Match is by board id, confirmed by the board's WiFi IP when both sides report
104
+ * one. If the serial side reports no IP (radio parked / pre-DHCP) the board id
105
+ * is the only shared key and we still prefer serial. Pure + exported for tests.
106
+ */
107
+ export function isWifiTransportRedundant(wifi, serialBoards) {
108
+ if (!wifi || !wifi.board || wifi.board === 'unknown')
109
+ return false;
110
+ for (const s of serialBoards) {
111
+ if (s.board !== wifi.board)
112
+ continue;
113
+ // Same board type on serial: dedup unless both report IPs that differ
114
+ // (two distinct physical units of the same board model).
115
+ if (!s.ip || !wifi.ip || s.ip === wifi.ip)
116
+ return true;
47
117
  }
48
- catch {
118
+ return false;
119
+ }
120
+ /** Reverse-lookup a WiFi ESP32 socket to its registered {board, ip} identity. */
121
+ function wifiEsp32IdentityForSocket(ws) {
122
+ for (const [key, registeredWs] of wifiEsp32Sockets) {
123
+ if (registeredWs !== ws)
124
+ continue;
125
+ const d = wifiEsp32Devices.get(key);
126
+ return d ? { board: d.board, ip: d.ip } : null;
127
+ }
128
+ return null;
129
+ }
130
+ /** True when this WiFi ESP32 socket's board is also live on USB serial, so its
131
+ * WiFi event copy should be suppressed in favour of the serial path. */
132
+ function isWifiEsp32RedundantWithSerial(ws) {
133
+ return isWifiTransportRedundant(wifiEsp32IdentityForSocket(ws), getSerialReachableBoards());
134
+ }
135
+ function registerWifiEsp32(d, ws) {
136
+ const key = wifiEsp32Key(d);
137
+ const previous = wifiEsp32Devices.get(key);
138
+ const uptimeSec = typeof d.uptimeSec === 'number' ? d.uptimeSec : undefined;
139
+ const resetReason = typeof d.resetReason === 'string' ? d.resetReason : undefined;
140
+ const resetReasonCode = typeof d.resetReasonCode === 'number' ? d.resetReasonCode : undefined;
141
+ wifiEsp32Devices.set(key, {
142
+ board: typeof d.board === 'string' ? d.board : 'unknown',
143
+ version: typeof d.version === 'string' ? d.version : undefined,
144
+ buildHash: typeof d.buildHash === 'string' ? d.buildHash : undefined,
145
+ buildEpoch: typeof d.buildEpoch === 'number' ? d.buildEpoch : undefined,
146
+ ip: typeof d.ip === 'string' ? d.ip : undefined,
147
+ protocolRevision: typeof d.protocolRevision === 'number' ? d.protocolRevision : undefined,
148
+ uptimeSec,
149
+ resetReason,
150
+ resetReasonCode,
151
+ otaSupported: typeof d.otaSupported === 'boolean' ? d.otaSupported : undefined,
152
+ otaSlotCount: typeof d.otaSlotCount === 'number' ? d.otaSlotCount : undefined,
153
+ otaSlotSize: typeof d.otaSlotSize === 'number' ? d.otaSlotSize : undefined,
154
+ otaFreeSketchSpace: typeof d.otaFreeSketchSpace === 'number' ? d.otaFreeSketchSpace : undefined,
155
+ otaReason: typeof d.otaReason === 'string' ? d.otaReason : undefined,
156
+ lastSeenMs: Date.now(),
157
+ });
158
+ wifiEsp32Sockets.set(key, ws);
159
+ if (previous?.uptimeSec != null && uptimeSec != null && uptimeSec + 10 < previous.uptimeSec) {
160
+ log(`[agentdeck] ESP32 reboot observed: ${key} uptime ${previous.uptimeSec}s -> ${uptimeSec}s reset=${resetReason ?? 'unknown'} code=${resetReasonCode ?? 'unknown'}`);
161
+ }
162
+ debug('daemon', `WiFi ESP32 registered: ${key} v${String(d.version ?? '')} build=${String(d.buildHash ?? '')} uptime=${String(uptimeSec ?? '')} reset=${String(resetReason ?? '')}`);
163
+ }
164
+ function touchWifiEsp32Socket(ws) {
165
+ const now = Date.now();
166
+ for (const [key, registeredWs] of wifiEsp32Sockets) {
167
+ if (registeredWs !== ws)
168
+ continue;
169
+ const device = wifiEsp32Devices.get(key);
170
+ if (device)
171
+ device.lastSeenMs = now;
172
+ return;
173
+ }
174
+ }
175
+ function unregisterWifiEsp32Socket(ws) {
176
+ for (const [key, registeredWs] of wifiEsp32Sockets) {
177
+ if (registeredWs !== ws)
178
+ continue;
179
+ wifiEsp32Sockets.delete(key);
180
+ // Keep the last device_info snapshot until normal age-out. A reconnect after
181
+ // a board reboot must compare the new uptime against the previous one; if we
182
+ // delete here every crash looks like a fresh device.
183
+ debug('daemon', `WiFi ESP32 disconnected: ${key}`);
184
+ }
185
+ }
186
+ function handleEsp32OtaReply(msg) {
187
+ if (msg.type !== 'esp32_ota_ack' && msg.type !== 'esp32_ota_error')
188
+ return false;
189
+ const otaId = typeof msg.otaId === 'string' ? msg.otaId : '';
190
+ const waiter = otaId ? otaWaiters.get(otaId) : undefined;
191
+ if (!waiter)
192
+ return true;
193
+ if (msg.type === 'esp32_ota_error') {
194
+ clearTimeout(waiter.timer);
195
+ otaWaiters.delete(otaId);
196
+ waiter.reject(new Error(typeof msg.error === 'string' ? msg.error : 'esp32_ota_error'));
197
+ return true;
198
+ }
199
+ const stage = typeof msg.stage === 'string' ? msg.stage : '';
200
+ const seq = typeof msg.seq === 'number' ? msg.seq : undefined;
201
+ if (stage !== waiter.stage)
202
+ return true;
203
+ if (waiter.seq != null && seq !== waiter.seq)
204
+ return true;
205
+ clearTimeout(waiter.timer);
206
+ otaWaiters.delete(otaId);
207
+ waiter.resolve(msg);
208
+ return true;
209
+ }
210
+ function waitForOtaAck(otaId, stage, seq, timeoutMs) {
211
+ return new Promise((resolve, reject) => {
212
+ const timer = setTimeout(() => {
213
+ otaWaiters.delete(otaId);
214
+ reject(new Error(`OTA ${stage}${seq != null ? ` #${seq}` : ''} timed out`));
215
+ }, timeoutMs);
216
+ otaWaiters.set(otaId, { stage, seq, resolve, reject, timer });
217
+ });
218
+ }
219
+ function findWifiOtaTarget(target) {
220
+ const matches = [];
221
+ for (const [key, device] of wifiEsp32Devices) {
222
+ const ws = wifiEsp32Sockets.get(key);
223
+ if (!ws || ws.readyState !== WebSocket.OPEN)
224
+ continue;
225
+ if (key === target || device.board === target || device.ip === target) {
226
+ matches.push({ key, device, ws });
227
+ }
228
+ }
229
+ if (matches.length === 0)
230
+ throw new Error(`No online WiFi ESP32 target matches "${target}"`);
231
+ if (matches.length > 1)
232
+ throw new Error(`Target "${target}" is ambiguous: ${matches.map(m => m.key).join(', ')}`);
233
+ return matches[0];
234
+ }
235
+ async function readJsonBody(req, maxBytes = 64 * 1024) {
236
+ const chunks = [];
237
+ let total = 0;
238
+ for await (const chunk of req) {
239
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
240
+ total += buf.length;
241
+ if (total > maxBytes)
242
+ throw new Error('request_body_too_large');
243
+ chunks.push(buf);
244
+ }
245
+ if (chunks.length === 0)
49
246
  return {};
247
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
248
+ }
249
+ // WiFi OTA ack timeouts. The transport is a WebSocket over TCP, so a chunk/ack
250
+ // is never lost while the connection is alive — a "timeout" means the board is
251
+ // slow (a classic ESP32 like TTGO/TC001 can stall past several seconds on a
252
+ // flash-sector erase or a brief WiFi-stack starvation) or the WiFi link briefly
253
+ // blipped and TCP is retransmitting. Both recover if we wait, so the timeouts
254
+ // are generous: a healthy board still acks in milliseconds, and these only cap
255
+ // how long we ride out a stall before declaring the transfer dead. Resending on
256
+ // the SAME live socket would be wrong — it desyncs the firmware's strict
257
+ // seq/offset cursor (handleOtaChunk → "unexpected_offset"); a timeout followed
258
+ // by a board RECONNECT is the one case where resend is correct (see
259
+ // performWifiEsp32Ota — the board never processed the lost chunk, and its otaRx
260
+ // cursor persisted across the reconnect). `let` (not const) so tests can shrink
261
+ // them via __setOtaTimeoutsForTest; production never mutates them.
262
+ let OTA_BEGIN_ACK_TIMEOUT_MS = 15_000; // erasing/preparing the target OTA slot
263
+ let OTA_CHUNK_ACK_TIMEOUT_MS = 30_000; // slow classic-ESP32 flash write / WiFi stall
264
+ let OTA_END_ACK_TIMEOUT_MS = 30_000; // whole-image MD5 verify + set-boot-partition
265
+ // How long to wait for a board to re-register a live WS after it drops its
266
+ // socket mid-OTA, and the cap on such reconnect-driven resends per transfer.
267
+ // Serial-backtrace diagnosis of the TTGO 2.5MB WiFi OTA showed the board does
268
+ // NOT reset/brownout/watchdog mid-transfer — it survives, prints "[WS]
269
+ // Disconnected", and re-registers under the SAME board:ip key with a NEW socket
270
+ // ~3-4s later (classic single-core ESP32 WiFi-flash coexistence briefly starves
271
+ // the TCP task → the connection drops, not the board). The old code captured the
272
+ // socket once and kept sending into the dead one, so the transfer stalled at the
273
+ // disconnect and failed 30s later on the chunk-ack timeout. Following the board
274
+ // to its reconnected socket lets the transfer resume where it stalled, because
275
+ // acks route by otaId (socket-independent) and the firmware keeps its otaRx
276
+ // cursor across the reconnect.
277
+ let OTA_RECONNECT_WAIT_MS = 20_000;
278
+ const OTA_MAX_RECONNECT_RESENDS = 12;
279
+ // ── Test-only seams for the WiFi-OTA reconnect-follow path ───────────────────
280
+ // The reconnect-follow bug is a socket-lifecycle race that hardware reproduces
281
+ // only probabilistically (a WiFi-flash coexistence drop). These exports let a
282
+ // deterministic unit test register/replace a board's socket mid-transfer and
283
+ // drive acks by otaId, with the ack timeouts shrunk so the drop→timeout→resend
284
+ // cycle runs in milliseconds. Not used by production code.
285
+ /** @internal */
286
+ export function __setOtaTimeoutsForTest(t) {
287
+ if (t.begin != null)
288
+ OTA_BEGIN_ACK_TIMEOUT_MS = t.begin;
289
+ if (t.chunk != null)
290
+ OTA_CHUNK_ACK_TIMEOUT_MS = t.chunk;
291
+ if (t.end != null)
292
+ OTA_END_ACK_TIMEOUT_MS = t.end;
293
+ if (t.reconnectWait != null)
294
+ OTA_RECONNECT_WAIT_MS = t.reconnectWait;
295
+ }
296
+ /** @internal */
297
+ export function __resetWifiEsp32OtaState() {
298
+ for (const [, w] of otaWaiters)
299
+ clearTimeout(w.timer);
300
+ otaWaiters.clear();
301
+ wifiEsp32Devices.clear();
302
+ wifiEsp32Sockets.clear();
303
+ OTA_BEGIN_ACK_TIMEOUT_MS = 15_000;
304
+ OTA_CHUNK_ACK_TIMEOUT_MS = 30_000;
305
+ OTA_END_ACK_TIMEOUT_MS = 30_000;
306
+ OTA_RECONNECT_WAIT_MS = 20_000;
307
+ }
308
+ /** @internal */
309
+ export const __wifiOtaTestApi = {
310
+ registerWifiEsp32,
311
+ unregisterWifiEsp32Socket,
312
+ handleEsp32OtaReply,
313
+ performWifiEsp32Ota,
314
+ };
315
+ async function performWifiEsp32Ota(core, target, firmwarePath) {
316
+ const { key, device } = findWifiOtaTarget(target);
317
+ if (device.otaSupported !== true) {
318
+ throw new Error(`Target ${key} does not report OTA support${device.otaReason ? ` (${device.otaReason})` : ''}`);
319
+ }
320
+ const firmware = readFileSync(firmwarePath);
321
+ if (device.otaSlotSize && firmware.length > device.otaSlotSize) {
322
+ throw new Error(`Firmware is ${firmware.length} bytes, OTA slot is ${device.otaSlotSize} bytes`);
50
323
  }
324
+ const otaId = randomUUID();
325
+ const md5 = createHash('md5').update(firmware).digest('hex');
326
+ // Always resolve the board's CURRENTLY-registered socket, never a captured
327
+ // reference — the board re-registers a fresh socket on a mid-OTA reconnect.
328
+ const liveSocket = () => {
329
+ const s = wifiEsp32Sockets.get(key);
330
+ return s && s.readyState === WebSocket.OPEN ? s : undefined;
331
+ };
332
+ // Wait up to ms for a live socket (rides out the ~3-4s reconnect gap).
333
+ const awaitLiveSocket = async (ms) => {
334
+ const deadline = Date.now() + ms;
335
+ let s = liveSocket();
336
+ while (!s && Date.now() < deadline) {
337
+ await new Promise((r) => setTimeout(r, 200));
338
+ s = liveSocket();
339
+ }
340
+ return s;
341
+ };
342
+ const sendVia = (sock, evt) => core.wsServer.sendTo(sock, evt);
343
+ let reconnectResends = 0;
344
+ // Send one OTA frame and wait for its ack, following the board to a fresh
345
+ // socket if it reconnects mid-flight. On an ack timeout, if a NEW live socket
346
+ // has appeared (the board reconnected), the frame was lost to the dead socket
347
+ // — resend the same seq on the new socket once. The firmware's otaRx cursor
348
+ // persists across the reconnect, so a resend of the not-yet-processed seq is
349
+ // accepted; a genuinely-gone board still fails after OTA_RECONNECT_WAIT_MS.
350
+ const sendAndAck = async (evt, stage, seq, timeoutMs) => {
351
+ let sock = await awaitLiveSocket(OTA_RECONNECT_WAIT_MS);
352
+ if (!sock)
353
+ throw new Error(`OTA ${stage}${seq != null ? ` #${seq}` : ''}: board ${key} offline (no live WS)`);
354
+ sendVia(sock, evt);
355
+ try {
356
+ await waitForOtaAck(otaId, stage, seq, timeoutMs);
357
+ }
358
+ catch (ackErr) {
359
+ const fresh = await awaitLiveSocket(OTA_RECONNECT_WAIT_MS);
360
+ if (fresh && fresh !== sock && reconnectResends < OTA_MAX_RECONNECT_RESENDS) {
361
+ reconnectResends++;
362
+ debug('daemon', `OTA ${key}: ${stage}${seq != null ? ` #${seq}` : ''} ack lost on dropped socket — resending on reconnected WS (resend ${reconnectResends})`);
363
+ sendVia(fresh, evt);
364
+ await waitForOtaAck(otaId, stage, seq, timeoutMs);
365
+ return;
366
+ }
367
+ throw ackErr;
368
+ }
369
+ };
370
+ try {
371
+ await sendAndAck({ type: 'esp32_ota_begin', otaId, size: firmware.length, md5 }, 'begin', undefined, OTA_BEGIN_ACK_TIMEOUT_MS);
372
+ const chunkSize = 1024;
373
+ let offset = 0;
374
+ let seq = 0;
375
+ while (offset < firmware.length) {
376
+ const chunk = firmware.subarray(offset, Math.min(offset + chunkSize, firmware.length));
377
+ await sendAndAck({
378
+ type: 'esp32_ota_chunk',
379
+ otaId,
380
+ seq,
381
+ offset,
382
+ data: chunk.toString('base64'),
383
+ }, 'chunk', seq, OTA_CHUNK_ACK_TIMEOUT_MS);
384
+ offset += chunk.length;
385
+ seq++;
386
+ }
387
+ await sendAndAck({ type: 'esp32_ota_end', otaId }, 'end', undefined, OTA_END_ACK_TIMEOUT_MS);
388
+ wifiEsp32Sockets.delete(key);
389
+ wifiEsp32Devices.delete(key);
390
+ return {
391
+ ok: true,
392
+ target: key,
393
+ board: device.board,
394
+ bytes: firmware.length,
395
+ chunks: seq,
396
+ reconnectResends,
397
+ md5,
398
+ };
399
+ }
400
+ catch (err) {
401
+ const sock = liveSocket();
402
+ if (sock) {
403
+ try {
404
+ sendVia(sock, { type: 'esp32_ota_abort', otaId });
405
+ }
406
+ catch { /* best effort */ }
407
+ }
408
+ throw err;
409
+ }
410
+ }
411
+ function loadDaemonSettings() {
412
+ // Newest settings.json across candidate data dirs — mirrors the
413
+ // daemon.json/sessions.json cross-dir discovery (and honors the
414
+ // AGENTDECK_DATA_DIR test override, which the old hardcoded
415
+ // ~/.agentdeck path ignored). The App Store sandbox container is
416
+ // intentionally NOT a candidate (TCC hang risk — see
417
+ // getCandidateDataDirs), so settings written by the sandboxed Swift
418
+ // app stay invisible here; that coexistence limit is documented in
419
+ // docs/appstore-feature-matrix.md.
420
+ let best = null;
421
+ for (const dir of getCandidateDataDirs()) {
422
+ try {
423
+ const path = join(dir, 'settings.json');
424
+ const mtime = statSync(path).mtimeMs;
425
+ if (best && best.mtime >= mtime)
426
+ continue;
427
+ best = { mtime, parsed: JSON.parse(readFileSync(path, 'utf-8')) };
428
+ }
429
+ catch {
430
+ // Missing or unreadable — skip this candidate.
431
+ }
432
+ }
433
+ return best?.parsed ?? {};
434
+ }
435
+ function latestTimelinePath() {
436
+ let best = null;
437
+ for (const dir of getCandidateDataDirs()) {
438
+ try {
439
+ const path = join(dir, 'timeline.json');
440
+ const mtime = statSync(path).mtimeMs;
441
+ if (!best || mtime > best.mtime)
442
+ best = { path, mtime };
443
+ }
444
+ catch {
445
+ // Missing or unreadable — skip this candidate.
446
+ }
447
+ }
448
+ return best?.path ?? null;
51
449
  }
450
+ // Observed-session attention history: the held PreToolUse device-approval gate
451
+ // was removed on 2026-06-27 and stays removed — PreToolUse fires even for tools
452
+ // Claude auto-approves, so gating on it produced false attention + a fabricated
453
+ // Allow/Deny that never matched Claude's real prompt. The *display-only*
454
+ // Notification overlay was restored on 2026-07-05: `notification_type:
455
+ // "permission_prompt"` fires only when a permission prompt is actually shown to
456
+ // the user, so it is a genuine awaiting signal. Observed sessions surface
457
+ // awaiting + question with NO requestId (respond-in-terminal UX); steering with
458
+ // real options still exists only on PTY-managed sessions (`agentdeck claude`).
52
459
  function log(msg) {
53
- process.stderr.write(msg + '\n');
460
+ // Timestamped like logger.ts — daemon stderr lands in a long-lived log file,
461
+ // and un-datable restart/incident lines repeatedly blocked root-cause work.
462
+ process.stderr.write(`${new Date().toISOString()} ${msg}\n`);
54
463
  }
55
464
  async function fetchUsageViaHttp(siblings) {
56
465
  for (const sibling of siblings) {
@@ -126,6 +535,60 @@ async function fetchUsageRelayed(selfPort) {
126
535
  debug('daemon', 'No siblings, using direct API');
127
536
  return fetchUsageFromApi();
128
537
  }
538
+ /**
539
+ * Stamp OpenClaw origin onto a timeline entry emitted by the Gateway adapter.
540
+ *
541
+ * The adapter emits bare entries (no agentType/projectName); without this the
542
+ * BridgeCore attributor falls back to the daemon's hardcoded projectName
543
+ * ('AgentDeck') and leaves agentType null, so OpenClaw cron activity gets
544
+ * mis-grouped under AgentDeck and never renders as OpenClaw. `?? ` fallbacks
545
+ * preserve any value the adapter did set, and the downstream attributor's own
546
+ * `?? ` keeps these in turn.
547
+ */
548
+ export function enrichGatewayTimelineEntry(entry) {
549
+ return {
550
+ ...entry,
551
+ agentType: entry.agentType ?? 'openclaw',
552
+ projectName: entry.projectName ?? 'OpenClaw',
553
+ };
554
+ }
555
+ /**
556
+ * Classify a `/hooks/<eventName>` POST for the observed-session pipeline.
557
+ *
558
+ * Claude hooks arrive as PascalCase names (already snake_cased by the
559
+ * caller's eventMap → `mapped`); Codex lifecycle hooks (installed into
560
+ * `~/.codex/config.toml`) arrive as `codex_*`; the AgentDeck OpenCode
561
+ * observer plugin posts `opencode_*`. All three share one pipeline —
562
+ * APME run/turn management, the chat_start row, and the stop-completion
563
+ * row — keyed off the returned agent-neutral `boundary`, so a direct
564
+ * `codex` or standalone `opencode` run gets the same prompt → response
565
+ * turn shape on the timeline as a direct `claude` run. (The Swift daemon
566
+ * has carried Codex parity since its observation pass —
567
+ * DaemonServer.swift appendCodexChatStart/End; this closes the same gap
568
+ * on the Node daemon.)
569
+ *
570
+ * Codex notify's `turn_complete` maps to `stop`: it closes a turn the
571
+ * same way, and the caller's open-turn guard collapses a stop +
572
+ * turn_complete pair into a single completion row.
573
+ *
574
+ * `antigravity_*` is accepted for forward-compatibility: Antigravity has
575
+ * no hook installer yet (the IDE exposes no lifecycle hook config; its
576
+ * sessions surface via PassiveSessionObserver transcript polling), but
577
+ * anything that starts POSTing these names gets full turn parity with no
578
+ * daemon change.
579
+ */
580
+ export function classifyObservedHookEvent(eventName, mapped) {
581
+ const prefixed = /^(codex|opencode|antigravity)_(session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification)$/
582
+ .exec(eventName);
583
+ if (!prefixed)
584
+ return { boundary: mapped, agentType: 'claude-code' };
585
+ return {
586
+ boundary: prefixed[2] === 'turn_complete' ? 'stop' : prefixed[2],
587
+ agentType: prefixed[1] === 'codex' ? 'codex-cli'
588
+ : prefixed[1] === 'opencode' ? 'opencode'
589
+ : 'antigravity',
590
+ };
591
+ }
129
592
  function buildNodeModuleHealth(startedModules) {
130
593
  const started = new Set(startedModules.map((m) => m.name));
131
594
  const modules = {};
@@ -140,10 +603,6 @@ function buildNodeModuleHealth(startedModules) {
140
603
  lastError: adbAvailable ? null : 'adb not found',
141
604
  };
142
605
  }
143
- const d200h = startedModules.find((m) => m.name === 'd200h');
144
- if (d200h?.statusSnapshot) {
145
- modules.d200h = d200h.statusSnapshot();
146
- }
147
606
  if (started.has('pixoo') || pixooDeviceCount() > 0) {
148
607
  const details = getPixooDeviceDetails();
149
608
  modules.pixoo = {
@@ -160,21 +619,72 @@ function buildNodeModuleHealth(startedModules) {
160
619
  })),
161
620
  };
162
621
  }
622
+ const timebox = startedModules.find((m) => m.name === 'timebox');
623
+ const configuredTimebox = loadTimeboxDevices();
624
+ if (timebox?.statusSnapshot) {
625
+ modules.timebox = timebox.statusSnapshot();
626
+ }
627
+ else if (configuredTimebox.length > 0) {
628
+ modules.timebox = {
629
+ configuredDeviceCount: configuredTimebox.length,
630
+ devices: configuredTimebox.map((d) => ({
631
+ address: d.address,
632
+ name: d.name ?? 'Timebox Mini',
633
+ brightness: d.brightness ?? 100,
634
+ })),
635
+ };
636
+ }
637
+ const idotmatrix = startedModules.find((m) => m.name === 'idotmatrix');
638
+ const configuredIDotMatrix = loadIDotMatrixDevices();
639
+ if (idotmatrix?.statusSnapshot) {
640
+ modules.idotmatrix = idotmatrix.statusSnapshot();
641
+ }
642
+ else if (configuredIDotMatrix.length > 0) {
643
+ modules.idotmatrix = {
644
+ configuredDeviceCount: configuredIDotMatrix.length,
645
+ devices: configuredIDotMatrix.map((d) => ({
646
+ address: d.address,
647
+ name: d.name ?? 'iDotMatrix',
648
+ brightness: d.brightness ?? 100,
649
+ })),
650
+ };
651
+ }
163
652
  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
- },
653
+ const connectionStatus = getSerialConnectionStatus();
654
+ const connections = connectionStatus.map((status) => ({
655
+ port: status.port,
656
+ connected: status.connected,
657
+ transportOpen: status.transportOpen,
658
+ deviceInfo: status.board ? {
659
+ board: status.board,
660
+ version: status.version,
661
+ buildHash: status.buildHash,
662
+ buildEpoch: status.buildEpoch,
663
+ wifiConfigured: status.wifiConfigured,
664
+ wifiConnected: status.wifiConnected,
665
+ wifiRadioParked: status.wifiRadioParked,
666
+ uptimeSec: status.uptimeSec,
667
+ otaSupported: status.otaSupported,
668
+ otaSlotCount: status.otaSlotCount,
669
+ otaSlotSize: status.otaSlotSize,
670
+ otaFreeSketchSpace: status.otaFreeSketchSpace,
671
+ otaReason: status.otaReason,
672
+ timelineCount: status.timelineCount,
673
+ sessionCount: status.sessionCount,
674
+ usageFiveH: status.usageFiveH,
675
+ processingCount: status.processingCount,
676
+ } : null,
677
+ lastReadAt: status.lastReadAt,
678
+ lastWriteAt: status.lastWriteAt,
679
+ lastReadSecondsAgo: status.lastReadSecondsAgo,
680
+ lastWriteSecondsAgo: status.lastWriteSecondsAgo,
681
+ stale: status.stale,
173
682
  }));
174
683
  modules.serial = {
175
- connectedPorts: connections.map((c) => c.port),
684
+ connectedPorts: connections.filter((c) => c.connected).map((c) => c.port),
176
685
  connections,
177
- lastError: null,
686
+ lastError: getSerialLastError(),
687
+ connectionCount: connections.filter((c) => c.connected).length,
178
688
  };
179
689
  }
180
690
  return modules;
@@ -192,13 +702,44 @@ export async function startDaemon(opts) {
192
702
  // 1. Check daemon.json and sessions.json for existing daemon
193
703
  const existingInfo = readDaemonInfo();
194
704
  if (existingInfo) {
195
- log(`[agentdeck] Daemon already running on port ${existingInfo.port} (PID ${existingInfo.pid}).`);
196
- process.exit(0);
705
+ const probePort = existingInfo.httpPort ?? existingInfo.port;
706
+ const health = await probeDaemonHealth(probePort);
707
+ if (health?.mode === 'daemon') {
708
+ if (health.isSwift) {
709
+ log(`[agentdeck] Swift daemon detected on port ${probePort}. Requesting shutdown to take over...`);
710
+ await requestDaemonShutdown(probePort);
711
+ await waitForDaemonExit(probePort);
712
+ removeDaemonInfo();
713
+ }
714
+ else {
715
+ log(`[agentdeck] Daemon already running on port ${existingInfo.port} (PID ${existingInfo.pid}).`);
716
+ process.exit(0);
717
+ }
718
+ }
719
+ else {
720
+ log(`[agentdeck] Ignoring stale daemon entry on port ${existingInfo.port} (PID ${existingInfo.pid}; /health did not respond).`);
721
+ removeDaemonInfo();
722
+ }
197
723
  }
198
724
  const existingSession = findExistingDaemon();
199
725
  if (existingSession) {
200
- log(`[agentdeck] Daemon already running on port ${existingSession.port} (PID ${existingSession.pid}).`);
201
- process.exit(0);
726
+ const health = await probeDaemonHealth(existingSession.port);
727
+ if (health?.mode === 'daemon') {
728
+ if (health.isSwift) {
729
+ log(`[agentdeck] Swift daemon detected on port ${existingSession.port}. Requesting shutdown to take over...`);
730
+ await requestDaemonShutdown(existingSession.port);
731
+ await waitForDaemonExit(existingSession.port);
732
+ removeDaemonSession(existingSession);
733
+ }
734
+ else {
735
+ log(`[agentdeck] Daemon already running on port ${existingSession.port} (PID ${existingSession.pid}).`);
736
+ process.exit(0);
737
+ }
738
+ }
739
+ else {
740
+ log(`[agentdeck] Ignoring stale daemon session on port ${existingSession.port} (PID ${existingSession.pid}; /health did not respond).`);
741
+ removeDaemonSession(existingSession);
742
+ }
202
743
  }
203
744
  // 2. Determine port — try default first, fallback if occupied by non-daemon
204
745
  const requestedPort = opts.port ?? DAEMON_DEFAULT_PORT;
@@ -208,24 +749,109 @@ export async function startDaemon(opts) {
208
749
  const health = await probeDaemonHealth(requestedPort);
209
750
  if (health) {
210
751
  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);
752
+ if (health.isSwift) {
753
+ log(`[agentdeck] Swift daemon detected on port ${requestedPort} via /health. Requesting shutdown to take over...`);
754
+ await requestDaemonShutdown(requestedPort);
755
+ await waitForDaemonExit(requestedPort);
756
+ }
757
+ else {
758
+ // Daemon alive but not in our registry — race condition or stale state
759
+ log(`[agentdeck] Daemon already running on port ${requestedPort} (detected via /health).`);
760
+ process.exit(0);
761
+ }
762
+ }
763
+ else {
764
+ // Port occupied by non-daemon (e.g. session bridge) — find alternative
765
+ log(`[agentdeck] Port ${requestedPort} occupied (${health.mode ?? 'unknown'}), finding alternative...`);
766
+ port = await findAvailablePort();
214
767
  }
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();
768
+ }
769
+ }
770
+ // 3. Cross-implementation sweep of the daemon port window. File discovery
771
+ // has two blind spots that the checks above never cover: the App Store
772
+ // Swift daemon writes daemon.json into its private sandbox container (this
773
+ // process cannot read it), and transient 9120 contention can leave a daemon
774
+ // sitting on a fallback port (9121+). Missing it here means two live
775
+ // daemons: double mDNS advertising, duplicate Gateway/timeline relay, and
776
+ // adb-reverse flapping. Swift occupants are evicted (same policy as above);
777
+ // a live Node daemon wins and we exit.
778
+ const strayDaemons = await scanDaemonPortWindow(new Set([requestedPort]));
779
+ for (const stray of strayDaemons) {
780
+ if (stray.health.isSwift) {
781
+ log(`[agentdeck] Swift daemon detected on fallback port ${stray.port}. Requesting shutdown to take over...`);
782
+ await requestDaemonShutdown(stray.port);
783
+ await waitForDaemonExit(stray.port);
784
+ }
785
+ else if (shouldConcedePortToOccupant(stray.health, process.pid)) {
786
+ log(`[agentdeck] Daemon already running on port ${stray.port} (detected via port scan).`);
787
+ process.exit(0);
218
788
  }
219
789
  }
220
790
  log(`[agentdeck] Starting daemon on port ${port}...`);
221
791
  // ===== APME (lazy — may be null if better-sqlite3 isn't installed) =====
222
792
  let apme = null;
793
+ // Fallback task-row emitter, created only when initApme() returns null —
794
+ // keeps task_start/task_end rows on the timeline without the collector.
795
+ let fallbackTasks = null;
223
796
  // Declare early — HTTP /health handler references this in its closure.
224
797
  // Must be declared before the HTTP server so it's initialized (not in TDZ)
225
798
  // when the first /health request arrives.
226
799
  let gatewayAdapter = null;
227
800
  let gatewayConnecting = false;
228
801
  let moduleHealthProvider = () => ({});
802
+ // Wi-Fi WebSocket e-ink panels (XTeink X3/X4 CrossPoint fork) self-register
803
+ // their device roster via `client_register{clientType:"eink-device"}` — the
804
+ // same volunteer model as the Stream Deck plugin. This handler lived ONLY in
805
+ // the Swift daemon, so when the dashboard talked to the Node daemon (the
806
+ // common case) these devices never surfaced on the Downstream E-ink rail.
807
+ // Keyed by socket; a registration drops the moment its socket closes
808
+ // (filtered lazily in `collectEinkDevices` on readyState), so no separate
809
+ // TTL sweep is needed.
810
+ const einkRegistrations = new Map();
811
+ const collectEinkDevices = () => {
812
+ const out = [];
813
+ for (const [ws, reg] of einkRegistrations) {
814
+ if (ws.readyState !== WebSocket.OPEN) {
815
+ einkRegistrations.delete(ws);
816
+ continue;
817
+ }
818
+ for (const d of reg.devices)
819
+ out.push(d);
820
+ }
821
+ return out;
822
+ };
823
+ // Android dashboard apps (tablet / e-ink launcher) over Wi-Fi WS —
824
+ // `client_register{clientType:"android-dashboard"}`, same volunteer model.
825
+ const androidDashboardRegistrations = new Map();
826
+ const collectAndroidDashboards = () => {
827
+ const out = [];
828
+ for (const [ws, reg] of androidDashboardRegistrations) {
829
+ if (ws.readyState !== WebSocket.OPEN) {
830
+ androidDashboardRegistrations.delete(ws);
831
+ continue;
832
+ }
833
+ for (const d of reg.devices)
834
+ out.push(d);
835
+ }
836
+ return out;
837
+ };
838
+ // Elgato Stream Deck plugin — `client_register{clientType:"streamdeck-plugin"}`
839
+ // with the physical device roster. The Swift daemon has handled this since the
840
+ // Stream Deck topology row shipped; the Node daemon silently dropped it, so on
841
+ // the external-CLI tier the dashboard never showed a Stream Deck row at all.
842
+ const streamDeckRegistrations = new Map();
843
+ const collectStreamDeckDevices = () => {
844
+ const out = [];
845
+ for (const [ws, reg] of streamDeckRegistrations) {
846
+ if (ws.readyState !== WebSocket.OPEN) {
847
+ streamDeckRegistrations.delete(ws);
848
+ continue;
849
+ }
850
+ for (const d of reg.devices)
851
+ out.push(d);
852
+ }
853
+ return out;
854
+ };
229
855
  // ===== HTTP server =====
230
856
  const httpServer = createServer((req, res) => {
231
857
  // APME routes: auth-gated (task prompts, project paths, hook payloads are sensitive).
@@ -249,7 +875,8 @@ export async function startDaemon(opts) {
249
875
  });
250
876
  return;
251
877
  }
252
- const pathname = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname;
878
+ const parsedUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
879
+ const pathname = parsedUrl.pathname;
253
880
  // Health check is public (no auth) — used by iOS/Android for pairing token discovery
254
881
  if (req.method === 'GET' && pathname === '/health') {
255
882
  const snap = core.stateMachine.getSnapshot();
@@ -257,9 +884,16 @@ export async function startDaemon(opts) {
257
884
  res.end(JSON.stringify({
258
885
  status: 'ok', mode: 'daemon', state: snap.state,
259
886
  gateway: gatewayAdapter?.isAlive() ? 'connected' : 'disconnected',
260
- uptime: process.uptime(), port,
887
+ uptime: process.uptime(), port, pid: process.pid,
261
888
  pairingToken: core.authToken,
262
889
  modules: moduleHealthProvider(),
890
+ apme: apme
891
+ ? {
892
+ enabled: true,
893
+ dbPath: apme.store.dbPath,
894
+ judgeBackend: apme.runner.lastBackendProbe ?? { status: 'unknown', backend: loadApmeConfig().judge.backend },
895
+ }
896
+ : { enabled: false, error: apme === null ? 'see startup logs (initApme returned null)' : 'unknown' },
263
897
  }));
264
898
  return;
265
899
  }
@@ -282,7 +916,76 @@ export async function startDaemon(opts) {
282
916
  }
283
917
  if (req.method === 'GET' && pathname === '/devices') {
284
918
  res.writeHead(200, { 'Content-Type': 'application/json' });
285
- res.end(JSON.stringify({ devices: [], modules: moduleHealthProvider() }));
919
+ // Direct-HID D200H control was retired; the D200H is driven exclusively by
920
+ // the Ulanzi Studio plugin over WebSocket. Report it connected iff that
921
+ // plugin is attached.
922
+ const ulanziPluginConnected = core.wsServer.getUlanziClientCount() > 0;
923
+ res.end(JSON.stringify({
924
+ devices: [
925
+ { type: 'websocket', count: core.wsServer.getClientCount() },
926
+ { type: 'tui', devices: core.wsServer.getTuiClients() },
927
+ { type: 'esp32', count: esp32ConnectionCount(), ports: getESP32Ports(), devices: getESP32DeviceInfo() },
928
+ { type: 'esp32-wifi', devices: listWifiEsp32Devices() },
929
+ { type: 'pixoo', details: getPixooDeviceDetails() },
930
+ { type: 'timebox', devices: loadTimeboxDevices() },
931
+ { type: 'idotmatrix', devices: loadIDotMatrixDevices() },
932
+ { type: 'adb', count: getAdbDeviceCount() },
933
+ {
934
+ type: 'd200h',
935
+ connected: ulanziPluginConnected,
936
+ ulanziPluginConnected,
937
+ },
938
+ ],
939
+ modules: moduleHealthProvider(),
940
+ }));
941
+ return;
942
+ }
943
+ if (req.method === 'POST' && pathname === '/esp32/ota') {
944
+ (async () => {
945
+ const body = await readJsonBody(req);
946
+ const target = typeof body.target === 'string' ? body.target : '';
947
+ const firmwarePath = typeof body.firmwarePath === 'string' ? body.firmwarePath : '';
948
+ if (!target || !firmwarePath) {
949
+ throw new Error('target and firmwarePath are required');
950
+ }
951
+ return performWifiEsp32Ota(core, target, firmwarePath);
952
+ })().then((result) => {
953
+ res.writeHead(200, { 'Content-Type': 'application/json' });
954
+ res.end(JSON.stringify(result));
955
+ }).catch((err) => {
956
+ res.writeHead(500, { 'Content-Type': 'application/json' });
957
+ res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
958
+ });
959
+ return;
960
+ }
961
+ if (req.method === 'GET' && pathname === '/display-state') {
962
+ res.writeHead(200, {
963
+ 'Content-Type': 'application/json',
964
+ 'Cache-Control': 'no-store',
965
+ 'Access-Control-Allow-Origin': '*',
966
+ });
967
+ res.end(JSON.stringify(buildDisplayStateEvent(core.displayMonitor.isDisplayOn())));
968
+ return;
969
+ }
970
+ if (req.method === 'GET' && pathname === '/diag') {
971
+ const snap = core.stateMachine.getSnapshot();
972
+ res.writeHead(200, { 'Content-Type': 'application/json' });
973
+ res.end(JSON.stringify({
974
+ timestamp: new Date().toISOString(),
975
+ sessionInfo: {
976
+ state: snap.state,
977
+ permissionMode: snap.permissionMode,
978
+ suggestedPrompt: snap.suggestedPrompt,
979
+ lastValidSuggestedPrompt: core.stateMachine.getLastValidSuggestedPrompt(),
980
+ projectName: snap.projectName,
981
+ modelName: snap.modelName,
982
+ billingType: snap.billingType,
983
+ },
984
+ wsClients: core.wsServer.getClientCount(),
985
+ recentJournal: [],
986
+ ptyTail: '',
987
+ journalDir: join(homedir(), '.agentdeck'),
988
+ }));
286
989
  return;
287
990
  }
288
991
  if (req.method === 'GET' && pathname === '/pixoo/stream') {
@@ -319,8 +1022,14 @@ export async function startDaemon(opts) {
319
1022
  return;
320
1023
  }
321
1024
  if (req.method === 'GET' && pathname === '/pixoo/frame') {
322
- const rgb = getLastFrame() ?? renderPreviewFrame();
323
- const bmp = rgbToBmp(rgb, 64, 64);
1025
+ const sizeParam = parsedUrl.searchParams.get('size');
1026
+ const size = sizeParam === '11' ? 11 : sizeParam === '32' ? 32 : 64;
1027
+ const layout = parsedUrl.searchParams.get('layout') === 'micro' ? 'micro' : 'standard';
1028
+ // The frame cache holds the standard terrarium, so render micro fresh.
1029
+ const rgb = layout === 'micro'
1030
+ ? renderPreviewFrame(size, 'micro')
1031
+ : (getLastFrame(size) ?? renderPreviewFrame(size));
1032
+ const bmp = rgbToBmp(rgb, size, size);
324
1033
  res.writeHead(200, {
325
1034
  'Content-Type': 'image/bmp',
326
1035
  'Cache-Control': 'no-store',
@@ -362,6 +1071,13 @@ export async function startDaemon(opts) {
362
1071
  Notification: 'notification',
363
1072
  };
364
1073
  const mapped = eventMap[eventName] ?? eventName;
1074
+ // Agent-prefixed lifecycle hooks (codex_* / opencode_*) reuse the
1075
+ // same observed-session pipeline as Claude's PascalCase hooks —
1076
+ // `boundary` is the agent-neutral semantic every block below keys
1077
+ // off. State-machine calls stay Claude-only (`mapped`): observed
1078
+ // codex/opencode *state* is owned by the passive observer's turn
1079
+ // semantics, not these hooks.
1080
+ const { boundary, agentType: hookAgentType } = classifyObservedHookEvent(eventName, mapped);
365
1081
  // State machine
366
1082
  if (mapped === 'session_start')
367
1083
  core.stateMachine.handleHookEvent('SessionStart', json);
@@ -377,36 +1093,370 @@ export async function startDaemon(opts) {
377
1093
  else if (mapped === 'tool_end') {
378
1094
  core.stateMachine.handleHookEvent('PostToolUse', json);
379
1095
  }
380
- // APME collector
1096
+ // Per-session awaiting overlay (observed/direct-`claude` sessions),
1097
+ // display-only. Claude emits Notification with `notification_type:
1098
+ // "permission_prompt"` only when a permission prompt is actually shown
1099
+ // to the user (auto-approved tools fire PreToolUse but never this), so
1100
+ // it is a genuine "waiting for explicit response" signal — unlike the
1101
+ // removed PreToolUse gate. No requestId is ever set: surfaces render
1102
+ // awaiting + question with the respond-in-terminal fallback, never a
1103
+ // fabricated Allow/Deny. Keyed by Claude's own session_id and merged
1104
+ // into the observed-session list at enrich time (awaiting-overlay.ts).
1105
+ const claudeSid = typeof json.session_id === 'string' ? json.session_id : undefined;
1106
+ if (claudeSid) {
1107
+ if (mapped === 'notification') {
1108
+ const message = typeof json.message === 'string' ? json.message : '';
1109
+ const notificationType = typeof json.notification_type === 'string' ? json.notification_type : undefined;
1110
+ if (isPermissionNotification(notificationType, message)) {
1111
+ setAwaitingOverlay(claudeSid, message);
1112
+ // Broadcast immediately rather than waiting for the 2s debounce
1113
+ // or the 5s observer tick, so the prompt surfaces within one frame.
1114
+ core.broadcastSessionsList().catch(() => { });
1115
+ }
1116
+ }
1117
+ else if (mapped === 'tool_start' || mapped === 'tool_end' ||
1118
+ mapped === 'user_prompt_submit' || mapped === 'stop' ||
1119
+ mapped === 'session_start' || mapped === 'session_end') {
1120
+ // Any subsequent hook means the prompt was answered — drop the
1121
+ // overlay. Only rebroadcast if there was actually one to clear
1122
+ // (direct-`claude` sessions fire tool hooks constantly).
1123
+ if (clearAwaitingOverlay(claudeSid)) {
1124
+ core.broadcastSessionsList().catch(() => { });
1125
+ }
1126
+ }
1127
+ }
1128
+ // Observed-session activity log. Managed sessions relay their timeline
1129
+ // from the session bridge (AGENTDECK_PORT routes their hooks there),
1130
+ // but direct `claude`/`codex` runs only reach the daemon through these
1131
+ // hooks — without emitting here the daemon timeline (device ticker,
1132
+ // Android/Apple lists) goes silent whenever no managed session runs.
1133
+ //
1134
+ // Task GROUPING is fully hook-driven and TTY-independent: the boundary
1135
+ // state machine keys only off `user_prompt_submit` / `session_end` /
1136
+ // `/clear`, so it never depends on parsing the agent's terminal output
1137
+ // and stays stable across agent CLI UI changes. Per-tool rows are still
1138
+ // suppressed here (they drowned the glance surfaces); the `task_start` /
1139
+ // `task_end` headers + taskId-tagged `chat_start` rows now carry the
1140
+ // work-unit structure.
1141
+ //
1142
+ // Real per-session id shared by the collector, fallback, and the
1143
+ // chat_start row. Concurrent direct `claude`/`codex` runs must NOT
1144
+ // collapse onto one bucket: a shared id interleaves their turns into a
1145
+ // single task and lets one session's `session_end` tear down another's
1146
+ // run mapping. Falls back to a stable id only when a hook omits it.
1147
+ const hookSid = typeof json.session_id === 'string' && json.session_id
1148
+ ? json.session_id : 'daemon-hook';
1149
+ // Claude hooks carry `cwd` (the worktree dir), not project_name/path —
1150
+ // capture it so APME runs are attributable to a specific worktree.
1151
+ const hookCwd = (typeof json.cwd === 'string' ? json.cwd
1152
+ : (typeof json.project_path === 'string' ? json.project_path : '')) || '';
1153
+ const hookProject = (typeof json.project_name === 'string' && json.project_name)
1154
+ ? json.project_name
1155
+ : (hookCwd ? hookCwd.split('/').filter(Boolean).pop() : undefined);
1156
+ const hookMessage = json.message;
1157
+ // Prompt shapes: Claude `{prompt}` / `{message:{content}}`; some Codex
1158
+ // builds send `{user_prompt}` (same fallback chain as codex-hook.ts).
1159
+ const hookPromptText = typeof json.prompt === 'string' ? json.prompt
1160
+ : (typeof hookMessage?.content === 'string' ? hookMessage.content
1161
+ : (typeof json.user_prompt === 'string' ? json.user_prompt : ''));
1162
+ const isClearBoundary = boundary === 'user_prompt_submit' && /^\s*\/clear\s*$/i.test(hookPromptText);
1163
+ // ── APME collector (task/turn segmentation) ──
381
1164
  if (apme) {
382
- // Use a stable "hook session" for the daemon — hooks from direct `claude` runs
383
- // don't have AGENTDECK_PORT, so they all come here.
384
- const hookSessionId = 'daemon-hook';
385
- if (mapped === 'session_start') {
386
- // Extract prompt source from message.content (Claude v2.1+) or prompt field
387
- apme.collector.openRun({
388
- sessionId: hookSessionId,
389
- agentType: 'claude-code',
390
- projectName: json.project_name ?? undefined,
391
- projectPath: json.project_path ?? undefined,
392
- });
1165
+ if (isClearBoundary) {
1166
+ // `/clear` closes the active task + run and opens a fresh run, so
1167
+ // the next prompt starts a new task 0 — the work-unit boundary.
1168
+ apme.collector.splitRun(hookSid, hookCwd || undefined);
1169
+ }
1170
+ else {
1171
+ // Lazy openRun: open a run when a turn-STARTING hook arrives with no
1172
+ // active run — either a normal `session_start`, or (crucially) the
1173
+ // first `user_prompt_submit` when `session_start` was missed/late.
1174
+ // Without this, `ingestHook` no-ops for want of a run, which is
1175
+ // exactly why direct-`claude` timelines showed zero task headers
1176
+ // (every follow-up looked like a new top-level chat). Gating on
1177
+ // start/prompt avoids two hazards: a stray post-`session_end` tool /
1178
+ // stop hook won't spawn a phantom run, and a late `session_start`
1179
+ // arriving after a lazy open won't double-open (the run already
1180
+ // exists → skip).
1181
+ if (!apme.collector.getRunId(hookSid)
1182
+ && (boundary === 'session_start' || boundary === 'user_prompt_submit')) {
1183
+ apme.collector.openRun({
1184
+ sessionId: hookSid,
1185
+ agentType: hookAgentType,
1186
+ projectName: hookProject,
1187
+ projectPath: hookCwd || undefined,
1188
+ });
1189
+ }
1190
+ // Feed the agent-neutral boundary name: the collector's
1191
+ // normalizeHookEventName only understands the Claude-shaped
1192
+ // vocabulary (user_prompt_submit / tool_start / …), so raw
1193
+ // codex_* / opencode_* names would silently skip turn management.
1194
+ apme.collector.ingestHook(hookSid, boundary, json);
1195
+ }
1196
+ // Direct `claude` runs reach the daemon only via these hooks, which
1197
+ // never carry the model — so every such run persisted model_id=NULL.
1198
+ // Recover it from the transcript Claude writes. Must run before
1199
+ // closeRun tears down the session→run mapping.
1200
+ if (boundary === 'stop' || boundary === 'session_end') {
1201
+ const tp = json.transcript_path;
1202
+ if (typeof tp === 'string' && tp) {
1203
+ const model = readModelFromTranscript(tp);
1204
+ if (model)
1205
+ apme.collector.updateModel(hookSid, model);
1206
+ }
1207
+ }
1208
+ if (boundary === 'session_end') {
1209
+ apme.collector.closeRun(hookSid);
1210
+ }
1211
+ }
1212
+ else if (fallbackTasks) {
1213
+ // APME is down — keep the timeline's task hierarchy alive from the
1214
+ // same boundary hooks (open on first prompt, close on /clear +
1215
+ // session_end), keyed by the real session id.
1216
+ fallbackTasks.ingestHook(hookSid, boundary, json);
1217
+ }
1218
+ // Timeline `chat_start`, emitted AFTER the collector so the active task
1219
+ // id is known — tag the row with it so the prompt nests under the
1220
+ // (possibly deferred) task_start header instead of reading as its own
1221
+ // top-level task. `/clear` is a boundary command, not a turn: no row.
1222
+ if (boundary === 'user_prompt_submit' && !isClearBoundary
1223
+ && hookPromptText.trim()
1224
+ // Codex's OTel turnStarted pseudo-prompt is a turn marker, not a
1225
+ // user prompt (same filter as the Swift daemon's chat_start path).
1226
+ && hookPromptText.trim() !== 'Codex turn started') {
1227
+ const taskId = (apme
1228
+ ? apme.collector.getActiveTaskId(hookSid)
1229
+ : fallbackTasks?.getActiveTaskId(hookSid)) ?? undefined;
1230
+ const promptText = stripUnsafeText(hookPromptText.trim());
1231
+ const now = Date.now();
1232
+ core.bridgeTimeline.addEntry({
1233
+ ts: now, type: 'chat_start',
1234
+ raw: promptText.slice(0, 160),
1235
+ // Long prompts keep their body reachable via the detail pane —
1236
+ // the 160-char raw alone read as clipped on the dashboards.
1237
+ ...(promptText.length > 160 ? { detail: promptText.slice(0, 1000) } : {}),
1238
+ sessionId: hookSid,
1239
+ agentType: hookAgentType,
1240
+ startedAt: now,
1241
+ ...(taskId ? { taskId } : {}),
1242
+ ...(hookProject ? { projectName: hookProject } : {}),
1243
+ });
1244
+ }
1245
+ // Timeline turn completion. Managed sessions relay chat_response from
1246
+ // their session bridge, but hook-observed (direct `claude` / `codex` /
1247
+ // standalone `opencode`) sessions only reach the daemon here — without
1248
+ // a completion row their chat_start rows spin as "running" forever on
1249
+ // every dashboard surface. Claude's Stop carries `transcript_path`
1250
+ // (the transcript tail's last assistant text is the turn's response —
1251
+ // same source the model reader uses above); Codex/OpenCode carry the
1252
+ // response inline (`last_assistant_message` / `response` / `output` /
1253
+ // `result`, the Swift daemon's field chain). Only emit while a turn is
1254
+ // actually open (a chat_start newer than the session's last
1255
+ // completion) so a duplicate/late Stop or an interrupted turn doesn't
1256
+ // re-emit the previous response.
1257
+ if (boundary === 'stop') {
1258
+ const rows = core.bridgeTimeline.getHistoryForSession(hookSid, undefined, 24);
1259
+ let lastStart;
1260
+ let lastResponse;
1261
+ let lastCompletionTs = 0;
1262
+ for (const row of rows) {
1263
+ if (row.type === 'chat_start')
1264
+ lastStart = row;
1265
+ else if (row.type === 'chat_response' || row.type === 'chat_end') {
1266
+ if (row.type === 'chat_response')
1267
+ lastResponse = row;
1268
+ lastCompletionTs = Math.max(lastCompletionTs, row.ts);
1269
+ }
1270
+ }
1271
+ const turnOpen = Boolean(lastStart && lastStart.ts > lastCompletionTs);
1272
+ const tp = typeof json.transcript_path === 'string' ? json.transcript_path : '';
1273
+ const inlineResponse = [json.last_assistant_message, json.response, json.output, json.result]
1274
+ .find((v) => typeof v === 'string' && v.trim().length > 0) ?? '';
1275
+ // Codex's stop payload rarely carries the text inline; its rollout
1276
+ // JSONL (agent_message / task_complete records) is the
1277
+ // authoritative source — the Codex counterpart of Claude's
1278
+ // transcript tail. Read it whenever a completion row might be
1279
+ // emitted (open turn, or a response-only close below).
1280
+ const rolloutResponse = !tp && !inlineResponse && hookAgentType === 'codex-cli'
1281
+ ? lastAgentMessageFromCodexRollout(hookSid)
1282
+ : '';
1283
+ const responseText = tp
1284
+ ? stripUnsafeText(lastAssistantTextFromTranscript(tp))
1285
+ : stripUnsafeText(inlineResponse || rolloutResponse);
1286
+ const respRaw = responseText.length > 0
1287
+ ? cleanRawText(responseText.length > 200 ? responseText.slice(0, 197) + '...' : responseText)
1288
+ : '';
1289
+ if (turnOpen && lastStart) {
1290
+ const now = Date.now();
1291
+ const taskId = (apme
1292
+ ? apme.collector.getActiveTaskId(hookSid)
1293
+ : fallbackTasks?.getActiveTaskId(hookSid)) ?? lastStart.taskId ?? undefined;
1294
+ const base = {
1295
+ sessionId: hookSid,
1296
+ agentType: hookAgentType,
1297
+ startedAt: lastStart.startedAt ?? lastStart.ts,
1298
+ endedAt: now,
1299
+ ...(taskId ? { taskId } : {}),
1300
+ ...(hookProject ? { projectName: hookProject } : {}),
1301
+ };
1302
+ if (responseText.length > 0) {
1303
+ core.bridgeTimeline.addEntry({
1304
+ ts: now, type: 'chat_response',
1305
+ raw: respRaw,
1306
+ detail: prepareMarkdownDetail(responseText.slice(0, 3000)) || undefined,
1307
+ ...base,
1308
+ });
1309
+ }
1310
+ else {
1311
+ // No readable response (interrupted / tool-only turn) — still
1312
+ // close the row so the dashboards stop the spinner.
1313
+ const durS = Math.max(0, Math.round((now - (lastStart.startedAt ?? lastStart.ts)) / 1000));
1314
+ core.bridgeTimeline.addEntry({
1315
+ ts: now, type: 'chat_end',
1316
+ raw: `Completed · ${durS}s`,
1317
+ summaryKind: 'none',
1318
+ ...base,
1319
+ });
1320
+ }
393
1321
  }
394
- apme.collector.ingestHook(hookSessionId, mapped, json);
395
- if (mapped === 'session_end') {
396
- apme.collector.closeRun(hookSessionId);
1322
+ else if (respRaw && lastResponse?.raw !== respRaw) {
1323
+ // Response-only close (Swift daemon parity): the turn's prompt
1324
+ // hook was missed — notify-only Codex configs emit just
1325
+ // turn_complete, and an OpenCode prompt can surface empty — so
1326
+ // there is no open chat_start to anchor on, but the response
1327
+ // text is still the turn's result and worth a row. The
1328
+ // raw-equality guard keeps a duplicate/late stop from
1329
+ // re-emitting the same response.
1330
+ const now = Date.now();
1331
+ const taskId = (apme
1332
+ ? apme.collector.getActiveTaskId(hookSid)
1333
+ : fallbackTasks?.getActiveTaskId(hookSid)) ?? undefined;
1334
+ core.bridgeTimeline.addEntry({
1335
+ ts: now, type: 'chat_response',
1336
+ raw: respRaw,
1337
+ detail: prepareMarkdownDetail(responseText.slice(0, 3000)) || undefined,
1338
+ sessionId: hookSid,
1339
+ agentType: hookAgentType,
1340
+ endedAt: now,
1341
+ ...(taskId ? { taskId } : {}),
1342
+ ...(hookProject ? { projectName: hookProject } : {}),
1343
+ });
397
1344
  }
398
1345
  }
1346
+ // ── Response ──
1347
+ // PreToolUse returns an empty body → Claude's normal permission flow
1348
+ // runs untouched (zero added latency, no daemon-side gate). Every other
1349
+ // event acks immediately.
1350
+ if (eventName === 'PreToolUse') {
1351
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1352
+ res.end('');
1353
+ }
1354
+ else {
1355
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1356
+ res.end(JSON.stringify({ received: true }));
1357
+ }
399
1358
  });
400
- res.writeHead(200, { 'Content-Type': 'application/json' });
401
- res.end(JSON.stringify({ received: true }));
402
1359
  return;
403
1360
  }
404
1361
  if (req.method === 'POST' && pathname === '/shutdown') {
405
1362
  res.writeHead(200, { 'Content-Type': 'application/json' });
406
1363
  res.end(JSON.stringify({ status: 'shutting_down' }));
1364
+ const hardExitTimer = setTimeout(() => {
1365
+ log('[agentdeck] Shutdown route timeout — forcing exit.');
1366
+ exitProcessNow(0);
1367
+ }, 5000);
407
1368
  core.shutdown();
408
1369
  return;
409
1370
  }
1371
+ // Manual task-close endpoint — drives `closeTaskExternal` on the APME
1372
+ // collector. Used by the CLI (`agentdeck task done` / `task cancel`)
1373
+ // and the macOS detail-pane "Mark task complete" button. Body:
1374
+ // { sessionId: string, signal?: 'manual', outcome?: 'success'|'fail'|'abandoned' }
1375
+ // sessionId defaults to the active session derived from the daemon's
1376
+ // registry; supplying it explicitly is the contract the CLI uses.
1377
+ if (req.method === 'POST' && pathname === '/task/close') {
1378
+ if (!apme) {
1379
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1380
+ res.end(JSON.stringify({ error: 'APME not initialized' }));
1381
+ return;
1382
+ }
1383
+ let body = '';
1384
+ req.on('data', (chunk) => { body += chunk; if (body.length > 4096)
1385
+ req.destroy(); });
1386
+ req.on('end', () => {
1387
+ try {
1388
+ const parsed = body ? JSON.parse(body) : {};
1389
+ const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId
1390
+ ? parsed.sessionId
1391
+ : (openclawApmeSessionId ?? '');
1392
+ if (!sessionId) {
1393
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1394
+ res.end(JSON.stringify({ error: 'sessionId required (no active session to default to)' }));
1395
+ return;
1396
+ }
1397
+ const signalRaw = typeof parsed.signal === 'string' ? parsed.signal : 'manual';
1398
+ const outcomeRaw = typeof parsed.outcome === 'string' ? parsed.outcome : undefined;
1399
+ const outcome = (outcomeRaw === 'success' || outcomeRaw === 'fail' || outcomeRaw === 'partial' || outcomeRaw === 'abandoned')
1400
+ ? outcomeRaw
1401
+ : undefined;
1402
+ // closeTaskExternal accepts TaskBoundarySignal (open union with
1403
+ // string fallback). Narrow only the well-known signals; pass
1404
+ // unknown strings through as-is (the runner labels them
1405
+ // generically).
1406
+ const apmeRef = apme;
1407
+ if (!apmeRef) {
1408
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1409
+ res.end(JSON.stringify({ error: 'APME not initialized' }));
1410
+ return;
1411
+ }
1412
+ const closed = apmeRef.collector.closeTaskExternal(sessionId, signalRaw, outcome);
1413
+ res.writeHead(closed ? 200 : 404, { 'Content-Type': 'application/json' });
1414
+ res.end(JSON.stringify({ closed, sessionId, signal: signalRaw, outcome: outcome ?? null }));
1415
+ }
1416
+ catch (err) {
1417
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1418
+ res.end(JSON.stringify({ error: `bad body: ${String(err)}` }));
1419
+ }
1420
+ });
1421
+ return;
1422
+ }
1423
+ // On-device Apple Intelligence (FoundationModels) text generation via the
1424
+ // daemon-managed *persistent* helper. Keeping it warm in the daemon avoids
1425
+ // the ~7s per-process cold start callers would otherwise pay each time
1426
+ // (e.g. wtcp branch naming). localhost only; lazily spawns the helper.
1427
+ if (req.method === 'POST' && pathname === '/generate') {
1428
+ let body = '';
1429
+ req.on('data', (chunk) => { body += chunk; if (body.length > 16384)
1430
+ req.destroy(); });
1431
+ req.on('end', () => {
1432
+ let prompt = '', instructions;
1433
+ try {
1434
+ const parsed = body ? JSON.parse(body) : {};
1435
+ prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
1436
+ instructions = typeof parsed.instructions === 'string' ? parsed.instructions : undefined;
1437
+ }
1438
+ catch {
1439
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1440
+ res.end(JSON.stringify({ error: 'bad JSON' }));
1441
+ return;
1442
+ }
1443
+ if (!prompt) {
1444
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1445
+ res.end(JSON.stringify({ error: 'prompt required' }));
1446
+ return;
1447
+ }
1448
+ callFoundationModelsHelper(prompt, instructions)
1449
+ .then((text) => {
1450
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1451
+ res.end(JSON.stringify({ text }));
1452
+ })
1453
+ .catch((err) => {
1454
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1455
+ res.end(JSON.stringify({ error: `generate failed: ${String(err)}` }));
1456
+ });
1457
+ });
1458
+ return;
1459
+ }
410
1460
  res.writeHead(404, { 'Content-Type': 'application/json' });
411
1461
  res.end(JSON.stringify({ error: 'Not found' }));
412
1462
  });
@@ -428,10 +1478,30 @@ export async function startDaemon(opts) {
428
1478
  });
429
1479
  httpServer.listen(port, '0.0.0.0', () => resolve());
430
1480
  }).catch(async (err) => {
431
- // Handle race condition: port became unavailable after probe
1481
+ // Handle race condition: port became unavailable after our pre-bind probe.
1482
+ // This is the concurrent-start case (e.g. two logon-trigger fires landing
1483
+ // within ~1s): both processes pass the singleton guard because neither has
1484
+ // bound yet, then exactly one wins the OS bind and the rest get EADDRINUSE.
432
1485
  if (err.message.startsWith('EADDRINUSE:') && port === requestedPort) {
1486
+ // Re-probe the occupant. If ANOTHER daemon grabbed the port, we lost the
1487
+ // race — exit instead of falling back to a new port, or we'd leave two
1488
+ // daemons running (and clobber daemon.json to point at the wrong one).
1489
+ // Only fall back when a *non-daemon* (e.g. a session bridge) holds it.
1490
+ const occupant = await probeDaemonHealth(requestedPort);
1491
+ // Harden against a forged/stale `mode:'daemon'` response squatting the
1492
+ // port: concede (exit) only to a verified live distinct daemon. A claim
1493
+ // backed by a dead/own PID is treated as stale → fall through to a fresh
1494
+ // port and keep running. See `shouldConcedePortToOccupant`.
1495
+ if (shouldConcedePortToOccupant(occupant, process.pid)) {
1496
+ const who = typeof occupant?.pid === 'number' ? `PID ${occupant.pid}` : 'a daemon';
1497
+ log(`[agentdeck] Lost startup race for port ${requestedPort} (${who} already serving). Exiting.`);
1498
+ process.exit(0);
1499
+ }
1500
+ if (occupant?.mode === 'daemon') {
1501
+ log(`[agentdeck] Port ${requestedPort} reports a daemon but PID ${occupant.pid} is not a live distinct process; treating as stale and falling back.`);
1502
+ }
433
1503
  port = await findAvailablePort();
434
- log(`[agentdeck] Port ${requestedPort} grabbed, retrying on ${port}...`);
1504
+ log(`[agentdeck] Port ${requestedPort} grabbed by ${occupant?.mode ?? 'a non-daemon'}, retrying on ${port}...`);
435
1505
  await new Promise((resolve, reject) => {
436
1506
  httpServer.on('error', (e) => reject(e));
437
1507
  httpServer.listen(port, '0.0.0.0', () => resolve());
@@ -448,11 +1518,66 @@ export async function startDaemon(opts) {
448
1518
  port,
449
1519
  projectName: 'AgentDeck',
450
1520
  httpServer,
1521
+ isDaemon: true,
1522
+ });
1523
+ const esp32WifiEvents = new Set([
1524
+ ...SERIAL_FORWARDED_EVENTS,
1525
+ 'esp32_ota_begin',
1526
+ 'esp32_ota_chunk',
1527
+ 'esp32_ota_end',
1528
+ 'esp32_ota_abort',
1529
+ 'device_info_request',
1530
+ ]);
1531
+ core.wsServer.setEventTransformer((event, client) => {
1532
+ if (!core.wsServer.isEsp32Client(client))
1533
+ return event;
1534
+ if (!esp32WifiEvents.has(event.type))
1535
+ return null;
1536
+ // Single-path guard: only the duplicated *display* payloads are deduped.
1537
+ // When the same board is live on USB serial, serial drives it and the WiFi
1538
+ // copy is redundant — drop it here. OTA control (esp32_ota_*, WiFi-only) and
1539
+ // device_info_request keep flowing so the standby WiFi socket stays live and
1540
+ // its lastSeen fresh. When serial disconnects, this flips false on the next
1541
+ // event and WiFi resumes automatically — no state migration needed.
1542
+ if (SERIAL_FORWARDED_EVENTS.has(event.type) && isWifiEsp32RedundantWithSerial(client))
1543
+ return null;
1544
+ return prepareForSerial(event);
451
1545
  });
452
1546
  // Timeline
453
1547
  const bridgeLogStream = new BridgeLogStream();
454
1548
  core.wireTimeline(bridgeLogStream);
1549
+ const timelinePath = latestTimelinePath();
1550
+ if (timelinePath) {
1551
+ const loaded = core.bridgeTimeline.loadPersistedFile(timelinePath);
1552
+ if (loaded > 0)
1553
+ log(`[agentdeck] Loaded ${loaded} persisted timeline entries`);
1554
+ // Close task_start rows orphaned by a previous daemon killed mid-task —
1555
+ // clients would otherwise spin their in-flight marker forever.
1556
+ const reaped = core.bridgeTimeline.reapOrphanTaskStarts();
1557
+ if (reaped > 0)
1558
+ log(`[agentdeck] Closed ${reaped} orphaned task_start rows as interrupted`);
1559
+ }
455
1560
  core.wireDisplayMonitor();
1561
+ let lastStateEvent = null;
1562
+ let userFocusedSessionId = null;
1563
+ const attachFocusedSessionId = (event) => {
1564
+ if (event.type !== 'state_update')
1565
+ return event;
1566
+ return {
1567
+ ...event,
1568
+ focusedSessionId: userFocusedSessionId ?? '',
1569
+ };
1570
+ };
1571
+ const broadcastFocusedState = () => {
1572
+ const gwAlive = gatewayAdapter?.isAlive() ?? false;
1573
+ const stateEvent = attachFocusedSessionId(core.buildStateEvent({
1574
+ agentType: gwAlive ? 'openclaw' : 'daemon',
1575
+ agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
1576
+ snapshot: core.stateMachine.getSnapshot(),
1577
+ }));
1578
+ lastStateEvent = stateEvent;
1579
+ core.wsServer.broadcast(stateEvent);
1580
+ };
456
1581
  // System wake recovery — re-publish mDNS, reconnect devices, refresh usage
457
1582
  core.onSystemWake(() => {
458
1583
  log('[daemon] System wake detected — recovering devices');
@@ -491,11 +1616,11 @@ export async function startDaemon(opts) {
491
1616
  core.cachedModelCatalog = merged;
492
1617
  debug('daemon', `Model catalog merged from sibling: ${merged.length} models total`);
493
1618
  const snap = core.stateMachine.getSnapshot();
494
- const stateEvent = core.buildStateEvent({
1619
+ const stateEvent = attachFocusedSessionId(core.buildStateEvent({
495
1620
  agentType: gatewayAdapter?.isAlive() ? 'openclaw' : 'daemon',
496
1621
  agentCapabilities: gatewayAdapter?.isAlive() ? OPENCLAW_CAPABILITIES : undefined,
497
1622
  snapshot: snap,
498
- });
1623
+ }));
499
1624
  lastStateEvent = stateEvent;
500
1625
  core.broadcast(stateEvent);
501
1626
  core.broadcastUsage();
@@ -506,13 +1631,18 @@ export async function startDaemon(opts) {
506
1631
  const focusRelay = new SessionFocusRelay();
507
1632
  focusRelay.setEventHandler((evt) => {
508
1633
  if (evt.type === 'state_update') {
1634
+ const focusedId = focusRelay.getFocusedSessionId();
1635
+ if (focusedId)
1636
+ userFocusedSessionId = focusedId;
509
1637
  // Merge daemon metadata into the session's state_update
510
1638
  const merged = {
511
1639
  ...evt,
512
- sessionId: focusRelay.getFocusedSessionId(),
1640
+ sessionId: focusedId,
1641
+ focusedSessionId: userFocusedSessionId ?? '',
513
1642
  modelCatalog: evt.modelCatalog ?? core.cachedModelCatalog,
514
1643
  gatewayAvailable: core.cachedGatewayAvailable,
515
1644
  gatewayConnected: core.cachedGatewayConnected,
1645
+ gatewayAuthStatus: core.cachedGatewayAuthStatus,
516
1646
  ollamaStatus: core.cachedOllamaStatus,
517
1647
  gatewayHasError: evt.gatewayHasError ?? core.cachedGatewayHasError,
518
1648
  moduleHealth: moduleHealthProvider(),
@@ -539,11 +1669,68 @@ export async function startDaemon(opts) {
539
1669
  });
540
1670
  // mDNS + device modules
541
1671
  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);
1672
+ // AGENTDECK_DAEMON_NO_SERIAL=1 leaves the daemon fully functional over WiFi
1673
+ // (mDNS, WiFi WS, WiFi OTA) but never opens the USB serial ports. This frees
1674
+ // a board's /dev/cu.* for an exclusive external serial capture (e.g. reading a
1675
+ // panic backtrace during a WiFi OTA) that otherwise fights the daemon's serial
1676
+ // reader for bytes. Diagnostic gate only; normal daemons keep serial on.
1677
+ const serialMode = process.env.AGENTDECK_DAEMON_NO_SERIAL === '1' ? false : 'auto';
1678
+ // Note: the D200H has no device module — direct-HID control was retired. It is
1679
+ // driven exclusively by the Ulanzi Studio plugin (`ulanzi-plugin`) over WebSocket;
1680
+ // the daemon never opens it over HID. Its health is reported via the Ulanzi client
1681
+ // count in moduleHealthProvider below.
1682
+ 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 });
1683
+ moduleHealthProvider = () => {
1684
+ const modules = buildNodeModuleHealth(startedModules);
1685
+ const einkDevices = collectEinkDevices();
1686
+ if (einkDevices.length > 0) {
1687
+ modules.einkDevices = { available: true, devices: einkDevices };
1688
+ }
1689
+ const androidDashboards = collectAndroidDashboards();
1690
+ if (androidDashboards.length > 0) {
1691
+ modules.androidDashboards = { available: true, devices: androidDashboards };
1692
+ }
1693
+ const streamDeckDevices = collectStreamDeckDevices();
1694
+ if (streamDeckDevices.length > 0) {
1695
+ modules.streamDeck = { available: true, devices: streamDeckDevices };
1696
+ }
1697
+ // WiFi-WS ESP32 boards. Same data as /devices `esp32-wifi` — without this
1698
+ // the dashboards (macOS/iOS/Android) never learn a WiFi-only board exists,
1699
+ // since they read moduleHealth off state_update, not /devices.
1700
+ const wifiEsp32 = listWifiEsp32Devices();
1701
+ if (wifiEsp32.length > 0) {
1702
+ modules.esp32Wifi = {
1703
+ available: true,
1704
+ devices: wifiEsp32.map((d) => ({
1705
+ board: d.board,
1706
+ ip: d.ip ?? null,
1707
+ version: d.version ?? null,
1708
+ stale: d.stale,
1709
+ serialActive: d.serialActive,
1710
+ })),
1711
+ };
1712
+ }
1713
+ const tuiClients = core.wsServer.getTuiClients();
1714
+ if (tuiClients.length > 0) {
1715
+ modules.tuiDashboards = {
1716
+ available: true,
1717
+ devices: tuiClients.map((c) => ({ id: c.id, name: c.name, kind: 'tui' })),
1718
+ };
1719
+ }
1720
+ const ulanziPluginConnected = core.wsServer.getUlanziClientCount() > 0;
1721
+ modules.d200h = {
1722
+ connected: ulanziPluginConnected,
1723
+ externalOwner: ulanziPluginConnected,
1724
+ managerOpened: ulanziPluginConnected,
1725
+ };
1726
+ return modules;
1727
+ };
544
1728
  core.setModuleHealthProvider(moduleHealthProvider);
1729
+ // iDotMatrix BLE is now driven by IDotMatrixModule (registered in
1730
+ // createDefaultModules): the module owns spawning the Python sync client,
1731
+ // zero-config auto-discovery, and teardown — so the daemon doesn't start it
1732
+ // directly here anymore (avoids double-spawn).
545
1733
  // Serial module state provider (heartbeat needs cached state)
546
- let lastStateEvent = null;
547
1734
  const serialModule = startedModules.find(m => m.name === 'serial');
548
1735
  if (serialModule) {
549
1736
  serialModule.setStateProvider(() => lastStateEvent);
@@ -554,20 +1741,61 @@ export async function startDaemon(opts) {
554
1741
  if (lastStateEvent)
555
1742
  events.push(lastStateEvent);
556
1743
  events.push(core.buildUsage());
557
- events.push({ type: 'display_state', displayOn: core.displayMonitor.isDisplayOn() });
1744
+ events.push(buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
1745
+ // Seed the last few timeline entries so a freshly (re)connected board's
1746
+ // ticker shows the real latest event instead of whatever its ring last
1747
+ // held. Kept to 3 entries — the whole line must stay well under the
1748
+ // small serial RX buffers on non-InkDeck boards.
1749
+ const recent = core.bridgeTimeline.getHistory().slice(-3);
1750
+ if (recent.length > 0) {
1751
+ events.push({ type: 'timeline_history', entries: recent });
1752
+ }
558
1753
  // Sessions list (async enrichment runs synchronously from cache here)
559
1754
  core.broadcastSessionsList().catch(() => { });
560
1755
  return events;
561
1756
  });
1757
+ // Heartbeat re-sync: display_state is otherwise edge-triggered, and a board
1758
+ // that misses the wake edge stays dark until power-cycled.
1759
+ serialModule.setDisplayStateProvider(() => buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
1760
+ // Heartbeat re-sync: sessions_list is edge-triggered too, so a board that
1761
+ // reconnects across a daemon handoff during a quiet window would otherwise
1762
+ // stay on "no active sessions" until the next session change.
1763
+ serialModule.setSessionsListProvider(() => core.getLastSessionsListEvent());
562
1764
  // Include ESP32 serial connections in client count for polling guards
563
1765
  core.setExternalClientCountProvider(() => esp32ConnectionCount());
1766
+ }
1767
+ // WS-path display_state re-sync: the 5s serial heartbeat above only covers
1768
+ // USB-attached boards. WiFi boards (InkDeck) receive display_state edge-
1769
+ // triggered over the plugin WS — a missed wake edge would leave an e-ink
1770
+ // panel showing the sleep card forever. Re-broadcast at a slow cadence so
1771
+ // any board that missed the edge self-heals within 15s.
1772
+ {
1773
+ const displayResync = setInterval(() => {
1774
+ if (core.wsServer.getClientCount() > 0) {
1775
+ core.wsServer.broadcast(buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
1776
+ }
1777
+ }, 15_000);
1778
+ displayResync.unref?.();
1779
+ // WiFi ESP32 diagnostics: boards only announce device_info on connect unless
1780
+ // asked. Poll slowly so /devices can show uptime/reset changes and distinguish
1781
+ // a real reboot from a display/WS redraw.
1782
+ const wifiDeviceInfoPoll = setInterval(() => {
1783
+ if (core.wsServer.getClientCount() > 0) {
1784
+ core.wsServer.broadcast({ type: 'device_info_request' });
1785
+ }
1786
+ }, 30_000);
1787
+ wifiDeviceInfoPoll.unref?.();
564
1788
  // WiFi auto-provisioning for ESP32 (enables independent WiFi operation)
565
1789
  const wifiConfig = loadWifiConfig();
566
1790
  if (wifiConfig?.autoProvision) {
567
1791
  const lanIp = getLanIp();
568
1792
  onESP32Message((portPath, msg) => {
569
- if (msg.type === 'device_info' && !msg.wifiConnected) {
570
- sendWifiProvisionToAll({
1793
+ const shouldRefreshIps10Endpoint = msg.type === 'device_info' &&
1794
+ msg.board === 'ips_10' &&
1795
+ msg.wifiConnected &&
1796
+ !msg.wifiRadioParked;
1797
+ if (msg.type === 'device_info' && (!msg.wifiConnected || shouldRefreshIps10Endpoint)) {
1798
+ const sent = sendWifiProvisionToAll({
571
1799
  type: 'wifi_provision',
572
1800
  ssid: wifiConfig.ssid,
573
1801
  password: wifiConfig.password,
@@ -575,7 +1803,8 @@ export async function startDaemon(opts) {
575
1803
  bridgePort: port,
576
1804
  authToken: core.authToken,
577
1805
  });
578
- log(`[agentdeck] WiFi provision sent to ESP32 on ${portPath}`);
1806
+ if (sent > 0)
1807
+ log(`[agentdeck] WiFi provision sent to ${sent} ESP32 device(s) after ${portPath}`);
579
1808
  }
580
1809
  else if (msg.type === 'wifi_provision_ack') {
581
1810
  log(msg.success ? `[agentdeck] ESP32 WiFi connected: ${msg.ip} ✓` : `[agentdeck] ESP32 WiFi failed: ${msg.error || 'unknown'}`);
@@ -589,43 +1818,117 @@ export async function startDaemon(opts) {
589
1818
  // routes. `setApme` on core is gated against the `daemon` meta-session so
590
1819
  // register/deregister won't open a bogus run. Session bridges opening their
591
1820
  // own connection to the same sqlite file is safe under WAL mode.
592
- apme = await initApme();
1821
+ // emitTimeline: forward task hierarchy entries (task_start / task_end) into
1822
+ // the daemon's bridgeTimeline so downstream dashboards see task headers.
1823
+ const daemonProjectTimeline = isTimelineProjectionEnabled();
1824
+ apme = await initApme(undefined, {
1825
+ emitTimeline: (entry) => core.bridgeTimeline.addEntry(entry),
1826
+ projectSampleTimeline: daemonProjectTimeline,
1827
+ emitProjectedTimeline: (entry) => core.bridgeTimeline.addEntry(entry, { bypassSuppression: true }),
1828
+ });
593
1829
  if (apme) {
594
1830
  core.setApme(apme);
595
- log('[agentdeck] APME enabled — /apme/* routes active');
1831
+ if (daemonProjectTimeline) {
1832
+ core.bridgeTimeline.setSuppressLocalChatTool(true);
1833
+ log('[agentdeck] APME timeline projection ENABLED — chat/tool rows derive from SessionSample');
1834
+ }
1835
+ log(`[agentdeck] APME enabled — store=${apme.store.dbPath} routes=/apme/*`);
1836
+ // Fire-and-forget judge backend probe. Result is cached on
1837
+ // apme.runner.lastBackendProbe and surfaced on /health so users discover
1838
+ // misconfiguration (no MLX server running, missing API key, etc.) without
1839
+ // having to wait for the first eval to fail.
1840
+ void apme.runner.refreshBackendProbe(loadApmeConfig().judge).then(status => {
1841
+ if (status.status === 'ready') {
1842
+ log(`[agentdeck] APME judge ready: ${status.backend}${status.model ? ` (${status.model})` : ''}${status.latencyMs !== undefined ? ` ${status.latencyMs}ms` : ''}`);
1843
+ }
1844
+ else {
1845
+ log(`[agentdeck] APME judge ${status.backend} ${status.status} — ${status.reason ?? 'no reason'}. Deterministic layer (lint/build/test) keeps running.`);
1846
+ }
1847
+ });
1848
+ }
1849
+ else {
1850
+ // initApme() already logged the specific reason via logError. This second
1851
+ // line tells the user where to look + what's lost so the gap doesn't pass
1852
+ // for "everything's fine, just no /apme/ routes".
1853
+ log('[agentdeck] APME unavailable — no run/turn/task evals will be recorded for sessions on this daemon.');
1854
+ // Keep the timeline's task hierarchy alive without APME: emit fallback
1855
+ // task_start/task_end rows from the hook boundary signals so the device
1856
+ // timeline doesn't degrade to a flat chat/tool stream.
1857
+ fallbackTasks = new FallbackTaskTimeline((entry) => core.bridgeTimeline.addEntry(entry), { agentType: 'claude-code' });
596
1858
  }
597
1859
  // Register session
598
1860
  core.registerSession('daemon');
599
1861
  const passiveSessionObserver = new PassiveSessionObserver();
1862
+ // The observer scans in the background now (collect() returns the cache
1863
+ // immediately). When a scan lands fresh observations, push them out via
1864
+ // the debounced broadcast so clients don't wait for the next 10 s poll.
1865
+ passiveSessionObserver.onRefreshed = () => core.maybeBroadcastSessionsList();
600
1866
  // ===== Gateway adapter lifecycle =====
601
1867
  // (gatewayAdapter + gatewayConnecting declared earlier, before HTTP server)
602
1868
  // Inject OpenClaw virtual session only after Gateway authentication succeeds.
603
1869
  // Reachability alone is a topology signal, not proof that commands can route.
604
1870
  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;
1871
+ // Overlay hook-driven awaiting state onto observed (direct-`claude`)
1872
+ // sessions display-only (no requestId, respond-in-terminal UX). Done here
1873
+ // in the synchronous enricher (runs on every broadcast) rather than inside
1874
+ // the 5s-throttled observer, so a Notification arriving mid-window still
1875
+ // surfaces within one frame. Key = the Claude session UUID embedded in
1876
+ // `observed:claude:<uuid>`.
1877
+ const observed = applyAwaitingOverlayToObserved(passiveSessionObserver.collect(sessions));
1878
+ // Derive per-session elapsed seconds from startedAt so NTP-less devices
1879
+ // (ESP32 IPS10 mosaic) render an elapsed value per cell without a wall clock.
1880
+ const now = Date.now();
1881
+ const enrichedSessions = [...sessions, ...observed].map((s) => {
1882
+ if (s.elapsedSec != null || !s.startedAt)
1883
+ return s;
1884
+ const sec = Math.round((now - Date.parse(s.startedAt)) / 1000);
1885
+ return Number.isFinite(sec) && sec >= 0 ? { ...s, elapsedSec: sec } : s;
1886
+ });
1887
+ // SSOT: inject iff Gateway is authenticated (gatewayConnected). Reachability
1888
+ // / adapter-liveness alone must not materialize a session — that kept a
1889
+ // phantom OpenClaw alive on devices after it was effectively off. Shared
1890
+ // injector with bridge/src/index.ts; mirror of Swift buildSessionsListEvent.
611
1891
  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
- }];
1892
+ return injectOpenClawSession(enrichedSessions, {
1893
+ gatewayConnected: core.cachedGatewayConnected,
1894
+ state: snap.state,
1895
+ projectName: snap.projectName ?? 'OpenClaw',
1896
+ modelName: snap.modelName ?? undefined,
1897
+ controlMode: 'managed',
1898
+ });
622
1899
  });
1900
+ // OpenClaw-specific APME session id. Distinct from `core.sessionId`
1901
+ // (the daemon meta-session, which doesn't open a run) and rotates on
1902
+ // every connect/disconnect cycle so each Gateway lifetime gets its own
1903
+ // APME run with its own task hierarchy. Captured here so both the
1904
+ // `connectGatewayAdapter` open + `disconnectGatewayAdapter` close paths
1905
+ // can reference the same id.
1906
+ let openclawApmeSessionId = null;
623
1907
  function connectGatewayAdapter() {
624
1908
  if (gatewayAdapter || gatewayConnecting)
625
1909
  return;
626
1910
  gatewayConnecting = true;
627
1911
  log('[agentdeck] OpenClaw Gateway detected, connecting...');
628
1912
  const adapter = new OpenClawAdapter({ autoReconnect: false });
1913
+ // Bind APME — without this OpenClaw never reaches the collector and
1914
+ // every chat session collapses to a single session_end task. The
1915
+ // adapter's idle-gap timer + chat.send/final ingestion needs an active
1916
+ // run to attach turns and task boundaries to.
1917
+ if (apme) {
1918
+ openclawApmeSessionId = `openclaw-${randomUUID()}`;
1919
+ try {
1920
+ apme.collector.openRun({
1921
+ sessionId: openclawApmeSessionId,
1922
+ agentType: 'openclaw',
1923
+ projectName: 'openclaw',
1924
+ });
1925
+ adapter.setApmeSession(openclawApmeSessionId, process.cwd());
1926
+ }
1927
+ catch (err) {
1928
+ debug('APME', `openRun for OpenClaw failed: ${String(err)}`);
1929
+ openclawApmeSessionId = null;
1930
+ }
1931
+ }
629
1932
  adapter.on('event', (evt) => {
630
1933
  switch (evt.source) {
631
1934
  case 'hook':
@@ -636,6 +1939,14 @@ export async function startDaemon(opts) {
636
1939
  break;
637
1940
  case 'parser':
638
1941
  core.stateMachine.handleParserEvent(evt.event, evt.data);
1942
+ // The OpenClaw adapter emits the real model via a model_info parser
1943
+ // event, but it only ever updated the display StateMachine — the APME
1944
+ // run was never told, so every openclaw run persisted model_id=NULL.
1945
+ if (evt.event === 'model_info' && apme && openclawApmeSessionId) {
1946
+ const model = evt.data?.model;
1947
+ if (model)
1948
+ apme.collector.updateModel(openclawApmeSessionId, model);
1949
+ }
639
1950
  break;
640
1951
  case 'metadata':
641
1952
  if (evt.event === 'model_catalog') {
@@ -643,11 +1954,11 @@ export async function startDaemon(opts) {
643
1954
  if (models) {
644
1955
  core.cachedModelCatalog = models;
645
1956
  const snap = core.stateMachine.getSnapshot();
646
- const stateEvent = core.buildStateEvent({
1957
+ const stateEvent = attachFocusedSessionId(core.buildStateEvent({
647
1958
  agentType: 'openclaw',
648
1959
  agentCapabilities: OPENCLAW_CAPABILITIES,
649
1960
  snapshot: snap,
650
- });
1961
+ }));
651
1962
  lastStateEvent = stateEvent;
652
1963
  core.broadcast(stateEvent);
653
1964
  core.broadcastUsage();
@@ -668,12 +1979,18 @@ export async function startDaemon(opts) {
668
1979
  break;
669
1980
  case 'timeline':
670
1981
  if (evt.entry) {
1982
+ // This handler is wired exclusively to the OpenClaw Gateway adapter
1983
+ // (see agentType:'openclaw' at the model_catalog case above), but the
1984
+ // adapter emits bare timeline entries without agentType/projectName.
1985
+ // Stamp the OpenClaw origin so the attributor doesn't default them to
1986
+ // 'AgentDeck'/null. See enrichGatewayTimelineEntry.
1987
+ const enriched = enrichGatewayTimelineEntry(evt.entry);
671
1988
  if (evt.upsert)
672
- core.bridgeTimeline.upsertEntry(evt.entry);
1989
+ core.bridgeTimeline.upsertEntry(enriched);
673
1990
  else
674
- core.bridgeTimeline.addEntry(evt.entry);
675
- if (evt.entry.type === 'tool_request')
676
- bridgeLogStream.trackToolRequest(evt.entry.raw);
1991
+ core.bridgeTimeline.addEntry(enriched);
1992
+ if (enriched.type === 'tool_request')
1993
+ bridgeLogStream.trackToolRequest(enriched.raw);
677
1994
  }
678
1995
  break;
679
1996
  case 'connection': {
@@ -684,6 +2001,7 @@ export async function startDaemon(opts) {
684
2001
  if (evt.status === 'connected') {
685
2002
  core.cachedGatewayAvailable = true;
686
2003
  core.cachedGatewayConnected = true;
2004
+ core.cachedGatewayAuthStatus = 'connected';
687
2005
  bridgeLogStream.start();
688
2006
  log('[agentdeck] OpenClaw Gateway connected');
689
2007
  if (core.stateMachine.getSnapshot().state === 'disconnected') {
@@ -691,11 +2009,11 @@ export async function startDaemon(opts) {
691
2009
  }
692
2010
  // Force full state broadcast
693
2011
  const snap = core.stateMachine.getSnapshot();
694
- const gwStateEvent = core.buildStateEvent({
2012
+ const gwStateEvent = attachFocusedSessionId(core.buildStateEvent({
695
2013
  agentType: 'openclaw',
696
2014
  agentCapabilities: OPENCLAW_CAPABILITIES,
697
2015
  snapshot: snap,
698
- });
2016
+ }));
699
2017
  lastStateEvent = gwStateEvent;
700
2018
  core.wsServer.broadcast(gwStateEvent);
701
2019
  core.broadcastUsage();
@@ -703,8 +2021,10 @@ export async function startDaemon(opts) {
703
2021
  }
704
2022
  else {
705
2023
  core.cachedGatewayConnected = false;
2024
+ core.cachedGatewayAuthStatus = 'gateway_not_found';
706
2025
  bridgeLogStream.stop();
707
2026
  log('[agentdeck] OpenClaw Gateway disconnected');
2027
+ core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
708
2028
  core.broadcastSessionsList().catch(() => { });
709
2029
  }
710
2030
  break;
@@ -719,6 +2039,7 @@ export async function startDaemon(opts) {
719
2039
  log(`[agentdeck] Failed to connect to Gateway: ${err}`);
720
2040
  gatewayConnecting = false;
721
2041
  core.cachedGatewayConnected = false;
2042
+ core.cachedGatewayAuthStatus = 'gateway_not_found';
722
2043
  core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
723
2044
  });
724
2045
  }
@@ -729,10 +2050,25 @@ export async function startDaemon(opts) {
729
2050
  const wasAlive = gatewayAdapter.isAlive();
730
2051
  gatewayAdapter.shutdown().catch(() => { });
731
2052
  gatewayAdapter = null;
2053
+ // APME: close the OpenClaw run so the collector fires its
2054
+ // session_end boundary and the run becomes eligible for the layer-2
2055
+ // judge queue.
2056
+ if (apme && openclawApmeSessionId) {
2057
+ try {
2058
+ apme.collector.closeRun(openclawApmeSessionId);
2059
+ }
2060
+ catch (err) {
2061
+ debug('APME', `closeRun for OpenClaw failed: ${String(err)}`);
2062
+ }
2063
+ openclawApmeSessionId = null;
2064
+ }
732
2065
  core.cachedGatewayConnected = false;
2066
+ core.cachedGatewayAuthStatus = 'gateway_not_found';
733
2067
  core.cachedModelCatalog = null;
734
2068
  if (wasAlive)
735
2069
  core.stateMachine.handleHookEvent('SessionEnd', {});
2070
+ else
2071
+ core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
736
2072
  // Do NOT broadcast connection:disconnected — that would make WS clients
737
2073
  // think they lost their bridge connection. State change to 'daemon' agentType
738
2074
  // and updated sessions_list convey the gateway loss.
@@ -789,11 +2125,11 @@ export async function startDaemon(opts) {
789
2125
  // ===== State changed → broadcast =====
790
2126
  core.stateMachine.on('state_changed', (snapshot) => {
791
2127
  const gwAlive = gatewayAdapter?.isAlive() ?? false;
792
- const stateEvent = core.buildStateEvent({
2128
+ const stateEvent = attachFocusedSessionId(core.buildStateEvent({
793
2129
  agentType: gwAlive ? 'openclaw' : 'daemon',
794
2130
  agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
795
2131
  snapshot,
796
- });
2132
+ }));
797
2133
  lastStateEvent = stateEvent;
798
2134
  core.wsServer.broadcast(stateEvent);
799
2135
  core.maybeBroadcastSessionsList();
@@ -816,6 +2152,62 @@ export async function startDaemon(opts) {
816
2152
  // ===== Commands from WS clients =====
817
2153
  // ===== Internal WS: session push channel =====
818
2154
  core.wsServer.onRawMessage((msg, sender) => {
2155
+ // WiFi ESP32 boards announce device_info over WS on connect. Capture the
2156
+ // sender here so OTA can address the exact socket instead of broadcasting.
2157
+ if (msg.type === 'device_info') {
2158
+ core.wsServer.markEsp32Client(sender);
2159
+ registerWifiEsp32(msg, sender);
2160
+ return true;
2161
+ }
2162
+ touchWifiEsp32Socket(sender);
2163
+ if (msg.type === 'client_register'
2164
+ && msg.clientType === 'eink-device') {
2165
+ // XTeink X3/X4 (fork) volunteering its E-ink roster. Store per-socket so
2166
+ // the Downstream E-ink rail can render it; drops on socket close.
2167
+ const devices = Array.isArray(msg.devices)
2168
+ ? msg.devices : [];
2169
+ einkRegistrations.set(sender, { devices, updatedAt: Date.now() });
2170
+ debug('daemon', `client_register eink-device devices=${devices.length}`);
2171
+ // Push a fresh state so the rail appears within one frame instead of on
2172
+ // the next periodic tick.
2173
+ if (lastStateEvent && lastStateEvent.type === 'state_update') {
2174
+ lastStateEvent.moduleHealth = moduleHealthProvider();
2175
+ core.wsServer.broadcast(lastStateEvent);
2176
+ }
2177
+ return true;
2178
+ }
2179
+ if (msg.type === 'client_register'
2180
+ && msg.clientType === 'android-dashboard') {
2181
+ // Android dashboard app volunteering its identity (tablet / e-ink
2182
+ // launcher on Wi-Fi) so the topology can show an Android row even when
2183
+ // the device is not ADB-bridged; drops on socket close.
2184
+ const devices = Array.isArray(msg.devices)
2185
+ ? msg.devices : [];
2186
+ androidDashboardRegistrations.set(sender, { devices, updatedAt: Date.now() });
2187
+ debug('daemon', `client_register android-dashboard devices=${devices.length}`);
2188
+ if (lastStateEvent && lastStateEvent.type === 'state_update') {
2189
+ lastStateEvent.moduleHealth = moduleHealthProvider();
2190
+ core.wsServer.broadcast(lastStateEvent);
2191
+ }
2192
+ return true;
2193
+ }
2194
+ if (msg.type === 'client_register'
2195
+ && msg.clientType === 'streamdeck-plugin') {
2196
+ // Elgato plugin volunteering the physical Stream Decks it drives — the
2197
+ // topology's Stream Deck rows. Swift-daemon parity; drops on socket close.
2198
+ const devices = Array.isArray(msg.devices)
2199
+ ? msg.devices : [];
2200
+ streamDeckRegistrations.set(sender, { devices, updatedAt: Date.now() });
2201
+ debug('daemon', `client_register streamdeck-plugin devices=${devices.length}`);
2202
+ if (lastStateEvent && lastStateEvent.type === 'state_update') {
2203
+ lastStateEvent.moduleHealth = moduleHealthProvider();
2204
+ core.wsServer.broadcast(lastStateEvent);
2205
+ }
2206
+ return true;
2207
+ }
2208
+ if (handleEsp32OtaReply(msg)) {
2209
+ return true;
2210
+ }
819
2211
  if (msg.type === 'session_push_register') {
820
2212
  const { sessionId, port: sessionPort, agentType: at, projectName: pn } = msg;
821
2213
  debug('daemon', `session_push_register: ${sessionId} port=${sessionPort} agent=${at}`);
@@ -835,6 +2227,45 @@ export async function startDaemon(opts) {
835
2227
  }
836
2228
  return true; // consumed
837
2229
  }
2230
+ if (msg.type === 'deck_slot_map') {
2231
+ // Plugin pushed its keypad layout. Forward to other viewers (extra
2232
+ // plugin instance, dashboard) and re-broadcast sessions_list so slot
2233
+ // buttons populate immediately — without this they would stay "Empty"
2234
+ // until the next 10 s sessions polling tick after the plugin connect.
2235
+ core.wsServer.broadcastExcept(msg, sender);
2236
+ core.broadcastSessionsList().catch(() => { });
2237
+ return true; // consumed
2238
+ }
2239
+ if (msg.type === 'query_session_timeline') {
2240
+ // Reply to THIS requester only with the session's recent timeline so a
2241
+ // device that connected mid-session can fill its Detail view on demand
2242
+ // (the live timeline_event stream is forward-only). Useful for any
2243
+ // reconnecting surface, not just the XTeink X3.
2244
+ const sessionId = msg.sessionId;
2245
+ const since = msg.since;
2246
+ if (typeof sessionId === 'string' && sessionId) {
2247
+ const sinceMs = typeof since === 'number' ? since : undefined;
2248
+ let entries = core.bridgeTimeline.getHistoryForSession(sessionId, sinceMs);
2249
+ // Passively-observed sessions never push timeline rows (the relay only
2250
+ // covers managed + hook sessions), so the store comes back empty for
2251
+ // them. Reconstruct a recent-activity timeline from their transcript so
2252
+ // the device's Detail view isn't stuck on "No recent activity yet".
2253
+ if (entries.length === 0) {
2254
+ try {
2255
+ entries = transcriptTimelineForSession(sessionId, { since: sinceMs });
2256
+ }
2257
+ catch { /* read-only best effort */ }
2258
+ }
2259
+ const historyEvent = buildCappedTimelineHistory(entries, undefined, { sessionId });
2260
+ if (historyEvent) {
2261
+ try {
2262
+ sender.send(JSON.stringify(historyEvent));
2263
+ }
2264
+ catch { /* client disconnecting */ }
2265
+ }
2266
+ }
2267
+ return true; // consumed
2268
+ }
838
2269
  return false; // not consumed — pass to command handler
839
2270
  });
840
2271
  core.wsServer.onCommand((cmd) => {
@@ -860,26 +2291,27 @@ export async function startDaemon(opts) {
860
2291
  return;
861
2292
  }
862
2293
  if (cmd.type === 'switch_agent') {
2294
+ userFocusedSessionId = null;
863
2295
  focusRelay.unfocus(); // Clear session focus on agent switch
864
2296
  const target = cmd.agent;
865
2297
  if (target === 'openclaw' && gatewayAdapter?.isAlive()) {
866
2298
  // Force broadcast OpenClaw state to all clients
867
2299
  const snap = core.stateMachine.getSnapshot();
868
- const gwStateEvent = core.buildStateEvent({
2300
+ const gwStateEvent = attachFocusedSessionId(core.buildStateEvent({
869
2301
  agentType: 'openclaw',
870
2302
  agentCapabilities: OPENCLAW_CAPABILITIES,
871
2303
  snapshot: snap,
872
- });
2304
+ }));
873
2305
  lastStateEvent = gwStateEvent;
874
2306
  core.wsServer.broadcast(gwStateEvent);
875
2307
  }
876
2308
  else if (target === 'claude-code') {
877
2309
  // Broadcast daemon/claude-code state — clients reconnect to session bridges independently
878
2310
  const snap = core.stateMachine.getSnapshot();
879
- const stateEvent = core.buildStateEvent({
2311
+ const stateEvent = attachFocusedSessionId(core.buildStateEvent({
880
2312
  agentType: 'daemon',
881
2313
  snapshot: snap,
882
- });
2314
+ }));
883
2315
  lastStateEvent = stateEvent;
884
2316
  core.wsServer.broadcast(stateEvent);
885
2317
  }
@@ -889,9 +2321,32 @@ export async function startDaemon(opts) {
889
2321
  const sessionId = cmd.sessionId;
890
2322
  if (!sessionId)
891
2323
  return;
2324
+ userFocusedSessionId = sessionId;
2325
+ broadcastFocusedState();
2326
+ if (sessionId === 'openclaw-gateway' && gatewayAdapter?.isAlive()) {
2327
+ focusRelay.unfocus();
2328
+ return;
2329
+ }
892
2330
  focusRelay.focus(sessionId);
893
2331
  return;
894
2332
  }
2333
+ if (cmd.type === 'clear_session_focus') {
2334
+ userFocusedSessionId = null;
2335
+ focusRelay.unfocus();
2336
+ broadcastFocusedState();
2337
+ return;
2338
+ }
2339
+ // Device approval for a gated PreToolUse permission request (observed session).
2340
+ // Resolves the held hook HTTP response → Claude allows/denies the tool.
2341
+ if (cmd.type === 'permission_decision') {
2342
+ const { requestId, decision } = cmd;
2343
+ if (typeof requestId !== 'string' || (decision !== 'allow' && decision !== 'deny'))
2344
+ return;
2345
+ // resolvePending → onResolved clears the overlay + rebroadcasts (sessions_list
2346
+ // + focused state), so all surfaces drop the gate. No-op if already resolved.
2347
+ resolvePending(requestId, decision);
2348
+ return;
2349
+ }
895
2350
  // Session-scoped command: forward inner command to a specific session's bridge
896
2351
  if (cmd.type === 'session_command') {
897
2352
  const { sessionId, command } = cmd;
@@ -904,11 +2359,27 @@ export async function startDaemon(opts) {
904
2359
  return;
905
2360
  }
906
2361
  // Focus the session first, then route the command
2362
+ userFocusedSessionId = sessionId;
2363
+ broadcastFocusedState();
907
2364
  focusRelay.focus(sessionId);
908
2365
  // Small delay to let focus take effect, then route
909
2366
  setTimeout(() => focusRelay.routeCommand(command), 100);
910
2367
  return;
911
2368
  }
2369
+ // Session-scoped option select from a multi-up panel (IPS10 D1 mosaic):
2370
+ // any awaiting cell can be answered, not just the focused one. Focus the
2371
+ // named session, then route a plain select_option to its bridge.
2372
+ if (cmd.type === 'select_option' && typeof cmd.sessionId === 'string') {
2373
+ const sessionId = cmd.sessionId;
2374
+ const target = listActiveSessions().find(s => s.id === sessionId);
2375
+ if (target) {
2376
+ userFocusedSessionId = sessionId;
2377
+ broadcastFocusedState();
2378
+ focusRelay.focus(sessionId);
2379
+ setTimeout(() => focusRelay.routeCommand({ type: 'select_option', index: cmd.index }), 100);
2380
+ return;
2381
+ }
2382
+ }
912
2383
  // Route interactive commands to focused session (if any)
913
2384
  if (focusRelay.getFocusedSessionId() && focusRelay.routeCommand(cmd)) {
914
2385
  return;
@@ -944,6 +2415,9 @@ export async function startDaemon(opts) {
944
2415
  });
945
2416
  }
946
2417
  });
2418
+ core.wsServer.onClientDisconnect((ws) => {
2419
+ unregisterWifiEsp32Socket(ws);
2420
+ });
947
2421
  // ===== Probes & polling =====
948
2422
  core.startOllamaProbe();
949
2423
  core.startMlxProbe();
@@ -962,7 +2436,39 @@ export async function startDaemon(opts) {
962
2436
  const { classifyRunSmart } = await import('./apme/classifier.js');
963
2437
  const { aggregateOverall } = await import('./apme/http.js');
964
2438
  // Broadcast eval results to all WS clients + timeline when runner completes
965
- apme.runner.onResult(({ runId, turnId }) => {
2439
+ apme.runner.onResult(({ runId, turnId, taskId, layer1Ran, layer2Ran, overall }) => {
2440
+ // Task-level eval: a group of turns between boundary signals
2441
+ // (TodoWrite all-completed / /clear / session_end). Distinct timeline
2442
+ // entry so users see "★ task 85%" rather than another run-level pulse.
2443
+ // The task's summary axis is the most user-readable signal and goes
2444
+ // into `detail` ahead of the per-axis breakdown.
2445
+ if (taskId) {
2446
+ const run = apme.store.getRun(runId);
2447
+ const task = apme.store.getTask(taskId);
2448
+ if (!run || !task)
2449
+ return;
2450
+ const taskEvals = apme.store.listEvalsForTask(taskId);
2451
+ const overallEval = taskEvals.find(e => e.metric === 'overall');
2452
+ if (!overallEval)
2453
+ return;
2454
+ const taskEvalEvent = {
2455
+ type: 'apme_eval',
2456
+ run: {
2457
+ runId: run.id, sessionId: run.sessionId, agentType: run.agentType, startedAt: run.startedAt,
2458
+ modelId: run.modelId ?? undefined, projectName: run.projectName ?? undefined,
2459
+ taskPrompt: run.taskPrompt ?? undefined, taskCategory: run.taskCategory ?? undefined,
2460
+ outcome: 'committed',
2461
+ compositeScore: overallEval.score,
2462
+ overallScore: overallEval.score,
2463
+ evals: taskEvals.map(e => ({
2464
+ layer: e.layer, metric: e.metric, score: e.score,
2465
+ judgeModel: e.judgeModel ?? undefined, createdAt: e.createdAt,
2466
+ })),
2467
+ },
2468
+ };
2469
+ core.broadcast(taskEvalEvent);
2470
+ return;
2471
+ }
966
2472
  // Turn-level eval: broadcast and add timeline entry with turn score
967
2473
  if (turnId) {
968
2474
  const run = apme.store.getRun(runId);
@@ -981,7 +2487,6 @@ export async function startDaemon(opts) {
981
2487
  });
982
2488
  }
983
2489
  catch { /* ignore */ }
984
- const pct = Math.round(overall.score * 100);
985
2490
  // WS broadcast — reuse apme_eval event for turn eval so dashboards pick it up
986
2491
  const turnEvalEvent = {
987
2492
  type: 'apme_eval',
@@ -999,30 +2504,6 @@ export async function startDaemon(opts) {
999
2504
  },
1000
2505
  };
1001
2506
  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
2507
  return;
1027
2508
  }
1028
2509
  const run = apme.store.getRun(runId);
@@ -1030,6 +2511,10 @@ export async function startDaemon(opts) {
1030
2511
  return;
1031
2512
  const evals = apme.store.listEvalsForRun(runId);
1032
2513
  const overallScore = aggregateOverall(evals);
2514
+ if (!layer1Ran && !layer2Ran && overall === undefined && evals.length === 0) {
2515
+ debug('APME', `skip run eval timeline for ${runId.slice(0, 8)} — no eval rows produced`);
2516
+ return;
2517
+ }
1033
2518
  // WS broadcast: apme_eval event (type already in protocol.ts)
1034
2519
  const evalEvent = {
1035
2520
  type: 'apme_eval',
@@ -1047,34 +2532,6 @@ export async function startDaemon(opts) {
1047
2532
  },
1048
2533
  };
1049
2534
  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
2535
  });
1079
2536
  const apmeEvalTimer = setInterval(() => {
1080
2537
  // 1. Enqueue unevaluated runs for deterministic + judge
@@ -1145,24 +2602,35 @@ export async function startDaemon(opts) {
1145
2602
  }
1146
2603
  });
1147
2604
  }, 10_000));
2605
+ // Backstop sweep for held PreToolUse approval responses whose per-entry timer
2606
+ // somehow didn't fire — resolves anything older than 60s to "ask" so a held
2607
+ // socket can't leak and Claude's own prompt isn't lost forever.
2608
+ const permissionSweepTimer = setInterval(() => { sweepStalePending(60_000); }, 30_000);
2609
+ permissionSweepTimer.unref?.();
1148
2610
  // ===== Shutdown =====
1149
2611
  core.onShutdown(async () => {
2612
+ clearInterval(permissionSweepTimer);
2613
+ drainAllPending();
1150
2614
  removeDaemonInfo();
1151
2615
  focusRelay.stop();
1152
2616
  timelineRelay.stop();
1153
2617
  voiceAssistant?.stop();
1154
2618
  voiceManager?.disconnectFromServer();
1155
2619
  bridgeLogStream.stop();
2620
+ // iDotMatrix BLE sync is stopped by IDotMatrixModule.stop() via stopModules below.
1156
2621
  await Promise.all([
1157
2622
  gatewayAdapter ? gatewayAdapter.shutdown().catch(() => { }) : Promise.resolve(),
1158
2623
  stopModules(startedModules)
1159
2624
  ]);
1160
2625
  gatewayAdapter = null;
1161
- httpServer.close(() => process.exit(0));
2626
+ httpServer.close(() => exitProcessNow(0));
1162
2627
  // Force exit if httpServer.close() hangs on CLOSE_WAIT connections
1163
- setTimeout(() => process.exit(0), 5000).unref();
2628
+ setTimeout(() => exitProcessNow(0), 5000).unref();
1164
2629
  });
1165
2630
  core.registerProcessHandlers('agentdeck');
2631
+ // Trigger initial state broadcast so display hardware modules (D200H, Pixoo, Serial) get populated
2632
+ broadcastFocusedState();
2633
+ core.broadcastUsage();
1166
2634
  log(`[agentdeck] Daemon running. Gateway probe active.`);
1167
2635
  }
1168
2636
  //# sourceMappingURL=daemon-server.js.map