@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
@@ -10,33 +10,113 @@
10
10
  *
11
11
  * ESP32 side reads lines starting with '{' and passes to Protocol::parseMessage().
12
12
  */
13
- import { exec } from 'child_process';
14
- import { createWriteStream, createReadStream } from 'fs';
13
+ import { exec, execFile } from 'child_process';
14
+ import { openSync, close, read, write, readFileSync, writeFileSync, mkdirSync, constants as fsConstants, } from 'fs';
15
+ import { homedir } from 'os';
16
+ import { dirname, join } from 'path';
15
17
  import { SERIAL_FORWARDED_EVENTS } from '@agentdeck/shared/protocol';
16
18
  import { formatResetTime } from '@agentdeck/shared';
17
19
  import { debug } from './logger.js';
18
20
  /** @internal Exported for testing only */
19
21
  export const ESP32_PORT_PATTERNS = [
20
- /\/dev\/cu\.usbserial-\d+/, // CP210x / CH340 (cu.usbserial-XXXX)
21
- /\/dev\/cu\.wchusbserial\d+/, // CH340 (cu.wchusbserialXXXX — 86 Box)
22
- /\/dev\/cu\.usbmodem\d+/, // Native USB JTAG (IPS 3.5", Round AMOLED)
23
- /\/dev\/ttyUSB\d+/, // Linux CH340
24
- /\/dev\/ttyACM\d+/, // Linux native USB
22
+ /\/dev\/cu\.usbserial-\w+/, // CP210x / CH340 (cu.usbserial-XXXX)
23
+ /\/dev\/cu\.wchusbserial\w+/, // CH340 (cu.wchusbserialXXXX — 86 Box)
24
+ /\/dev\/cu\.usbmodem\w+/, // Native USB JTAG (IPS 3.5", Round AMOLED)
25
+ /\/dev\/ttyUSB\w+/, // Linux CH340
26
+ /\/dev\/ttyACM\w+/, // Linux native USB
25
27
  ];
26
28
  /** @internal Exported for testing only */
27
29
  export const EXCLUDE_PATTERNS = [
28
30
  /Bluetooth/i,
29
31
  /WLAN/i,
30
32
  ];
33
+ /** No-traffic threshold. ESP32 firmware should answer device_info/heartbeat JSON. */
34
+ const STALE_THRESHOLD_MS = 60000; // 60 seconds
35
+ const INITIAL_READ_TIMEOUT_MS = 20000;
36
+ const DEVICE_INFO_RETRY_MS = 5000;
37
+ const DEVICE_INFO_READ_RETRY_MS = 5000;
38
+ const DEVICE_INFO_MAX_REQUESTS = 10;
39
+ // A serial port that opens fine but never speaks the AgentDeck protocol (e.g. a
40
+ // TRMNL e-ink panel, or any third-party USB-serial device) would otherwise be
41
+ // reopened every poll — and opening a port toggles DTR/RTS, which resets an
42
+ // ESP32. After this many failed identification cycles we denylist the port for a
43
+ // cooldown so we stop holding/resetting foreign hardware.
44
+ const FOREIGN_MAX_PROBE_FAILURES = 3;
45
+ const FOREIGN_DENYLIST_COOLDOWN_MS = 10 * 60_000; // 10 minutes, then re-probe once
46
+ const FOREIGN_CDC_GRACE_MS = 90_000; // a held-open CDC port gets this long to identify
47
+ // An *identified* CDC port (live or cache-restored board) that reads nothing for
48
+ // this long since (re)connect is half-open: the board reset/re-enumerated and its
49
+ // RX pipe is dead, but heartbeat writes keep succeeding so the write-death reaper
50
+ // never fires. Larger than FOREIGN_CDC_GRACE_MS so an unidentified foreign port is
51
+ // always caught by the foreign clause first.
52
+ const CDC_SILENT_READ_TIMEOUT_MS = 120_000;
53
+ const SERIAL_OPEN_PROBE_TIMEOUT_MS = 1500;
54
+ const SERIAL_WRITE_INTERVAL_MS = 120;
55
+ const SERIAL_MAX_QUEUE = 24;
56
+ const SERIAL_KEEPALIVE_JSON = JSON.stringify({ type: 'serial_keepalive' });
57
+ const SERIAL_OPEN_PROBE_SCRIPT = `
58
+ const fs = require('fs');
59
+ const port = process.argv[1];
60
+ let fd = -1;
61
+ try {
62
+ fd = fs.openSync(port, fs.constants.O_RDWR | (fs.constants.O_NOCTTY || 0) | (fs.constants.O_NONBLOCK || 0));
63
+ fs.closeSync(fd);
64
+ process.exit(0);
65
+ } catch (err) {
66
+ if (fd >= 0) {
67
+ try { fs.closeSync(fd); } catch {}
68
+ }
69
+ console.error(err && err.message ? err.message : String(err));
70
+ process.exit(1);
71
+ }
72
+ `;
31
73
  let connections = [];
32
74
  let pollTimer = null;
33
75
  let heartbeatTimer = null;
76
+ let pollInProgress = false;
77
+ const openingPorts = new Set();
78
+ const lastKnownDeviceInfoByPort = new Map();
79
+ const lastErrorByPort = new Map();
80
+ // Foreign (non-AgentDeck) serial ports: failure count + denylist-until timestamp.
81
+ const foreignProbeFailures = new Map();
82
+ const foreignDenylistUntil = new Map();
83
+ let deviceInfoCacheLoaded = false;
34
84
  let stateProvider = null;
35
85
  let usageProvider = null;
86
+ let displayStateProvider = null;
87
+ let sessionsListProvider = null;
36
88
  let initialStateProvider = null;
37
89
  let messageHandler = null;
38
90
  // Events to forward — shared constant from @agentdeck/shared
39
91
  const FORWARDED_EVENTS = SERIAL_FORWARDED_EVENTS;
92
+ function deviceInfoCachePath() {
93
+ return join(process.env.AGENTDECK_DATA_DIR || join(homedir(), '.agentdeck'), 'esp32-device-cache.json');
94
+ }
95
+ function loadDeviceInfoCache() {
96
+ if (deviceInfoCacheLoaded)
97
+ return;
98
+ deviceInfoCacheLoaded = true;
99
+ try {
100
+ const parsed = JSON.parse(readFileSync(deviceInfoCachePath(), 'utf-8'));
101
+ for (const [port, info] of Object.entries(parsed)) {
102
+ if (info?.board)
103
+ lastKnownDeviceInfoByPort.set(port, info);
104
+ }
105
+ }
106
+ catch {
107
+ // Missing cache is normal.
108
+ }
109
+ }
110
+ function persistDeviceInfoCache() {
111
+ try {
112
+ const path = deviceInfoCachePath();
113
+ mkdirSync(dirname(path), { recursive: true });
114
+ writeFileSync(path, JSON.stringify(Object.fromEntries(lastKnownDeviceInfoByPort), null, 2), 'utf-8');
115
+ }
116
+ catch (err) {
117
+ debug('ESP32', `Failed to persist device cache: ${String(err)}`);
118
+ }
119
+ }
40
120
  // ESP32 without WiFi/NTP can't parse ISO dates — shared formatResetTime
41
121
  // handles undefined and pre-formatted strings.
42
122
  /**
@@ -45,28 +125,208 @@ const FORWARDED_EVENTS = SERIAL_FORWARDED_EVENTS;
45
125
  * - Strip fields the ESP32 firmware doesn't parse (reduce size for small RX buffers)
46
126
  */
47
127
  /** @internal Exported for testing only */
48
- export function prepareForSerial(event) {
128
+ // Firmware (handleSessionsList) only stores the first N sessions; both sides
129
+ // must agree on the same cap. Keep in sync with esp32/src/net/protocol.cpp
130
+ // (sessions[10] / MOSAIC_MAX / min(...,10)).
131
+ export const SERIAL_SESSIONS_CAP = 10;
132
+ /**
133
+ * Pick which sessions survive the serial session cap.
134
+ *
135
+ * A naive `slice(0, cap)` lets a single agent type (e.g. several idle Claude Code
136
+ * sessions) fill every slot and starve other agents — a running Codex/OpenClaw
137
+ * session that lands past the cap never reaches the firmware's creature renderer.
138
+ *
139
+ * Instead we round-robin across agent types so each present type claims at least
140
+ * one slot before any type takes a second. Within a type, live + active
141
+ * (non-idle) sessions are preferred so a processing session is never dropped in
142
+ * favour of an idle sibling. Order within a type is otherwise preserved (stable).
143
+ */
144
+ export function roundRobinByAgentType(sessions, cap) {
145
+ if (sessions.length <= cap)
146
+ return sessions;
147
+ const groups = new Map();
148
+ const order = [];
149
+ for (const s of sessions) {
150
+ const key = typeof s?.agentType === 'string' ? s.agentType : '';
151
+ let bucket = groups.get(key);
152
+ if (!bucket) {
153
+ bucket = [];
154
+ groups.set(key, bucket);
155
+ order.push(key);
156
+ }
157
+ bucket.push(s);
158
+ }
159
+ // Within each type: alive first, then active (non-idle) first. Stable for ties.
160
+ const rank = (s) => {
161
+ const alive = s?.alive ? 1 : 0;
162
+ const state = typeof s?.state === 'string' ? s.state : '';
163
+ const active = state && state !== 'idle' ? 1 : 0;
164
+ return alive * 2 + active;
165
+ };
166
+ for (const key of order) {
167
+ groups.get(key).sort((a, b) => rank(b) - rank(a));
168
+ }
169
+ const result = [];
170
+ let progressed = true;
171
+ while (result.length < cap && progressed) {
172
+ progressed = false;
173
+ for (const key of order) {
174
+ const bucket = groups.get(key);
175
+ if (bucket.length === 0)
176
+ continue;
177
+ result.push(bucket.shift());
178
+ progressed = true;
179
+ if (result.length >= cap)
180
+ break;
181
+ }
182
+ }
183
+ return result;
184
+ }
185
+ export function prepareForSerial(event, _conn) {
49
186
  const e = event;
50
187
  if (event.type === 'usage_update') {
51
- // Pre-format ISO dates + strip unused fields
52
- const { ollamaStatus, tokenStatus, extraUsageEnabled, extraUsageMonthlyLimit, extraUsageUsedCredits, extraUsageUtilization, costSpent, costLimit, sessionPercent, resetTime, resetDate, ...keep } = e;
188
+ // WHITELIST of the fields the firmware actually parses (protocol.cpp
189
+ // handleUsageUpdate). The old blocklist approach silently leaked every
190
+ // NEW usage field onto the wire — modelCatalog alone (3.2KB) pushed the
191
+ // line past the firmware's 4096-byte line buffer, so boards discarded
192
+ // EVERY usage_update and their gauges froze on stale values.
193
+ const cx = e.codexRateLimits
194
+ ? {
195
+ primary: e.codexRateLimits.primary
196
+ ? { usedPercent: e.codexRateLimits.primary.usedPercent, resetsAt: formatResetTime(e.codexRateLimits.primary.resetsAt) }
197
+ : undefined,
198
+ secondary: e.codexRateLimits.secondary
199
+ ? { usedPercent: e.codexRateLimits.secondary.usedPercent, resetsAt: formatResetTime(e.codexRateLimits.secondary.resetsAt) }
200
+ : undefined,
201
+ }
202
+ : undefined;
203
+ // Subscriptions carry an ISO `until`; a serial device has no reliable
204
+ // clock, so pre-format to a short "~M/D" the panel can render as-is.
205
+ const subs = Array.isArray(e.subscriptions)
206
+ ? e.subscriptions.map((sub) => ({
207
+ name: limitString(sub.name, 27),
208
+ until: formatShortDate(sub.until),
209
+ })).filter((sub) => sub.name)
210
+ : undefined;
211
+ const ag = e.antigravityStatus
212
+ ? { planName: limitString(e.antigravityStatus.planName, 23), availableCredits: e.antigravityStatus.availableCredits }
213
+ : undefined;
53
214
  return {
54
- ...keep,
55
- fiveHourResetsAt: formatResetTime(keep.fiveHourResetsAt),
56
- sevenDayResetsAt: formatResetTime(keep.sevenDayResetsAt),
215
+ type: 'usage_update',
216
+ fiveHourPercent: e.fiveHourPercent,
217
+ sevenDayPercent: e.sevenDayPercent,
218
+ fiveHourResetsAt: formatResetTime(e.fiveHourResetsAt),
219
+ sevenDayResetsAt: formatResetTime(e.sevenDayResetsAt),
220
+ inputTokens: e.inputTokens,
221
+ outputTokens: e.outputTokens,
222
+ toolCalls: e.toolCalls,
223
+ sessionDurationSec: e.sessionDurationSec,
224
+ estimatedCostUsd: e.estimatedCostUsd,
225
+ usageStale: e.usageStale,
226
+ ...(cx ? { codexRateLimits: cx } : {}),
227
+ ...(subs ? { subscriptions: subs } : {}),
228
+ ...(ag ? { antigravityStatus: ag } : {}),
57
229
  };
58
230
  }
59
231
  if (event.type === 'state_update') {
60
- // Strip agentCapabilities + other fields ESP32 doesn't parse
61
- const { agentCapabilities, billingType, remoteUrl, diff, context, selectedOptionIndex, ...keep } = e;
62
- return keep;
232
+ return {
233
+ type: 'state_update',
234
+ state: e.state,
235
+ permissionMode: e.permissionMode,
236
+ agentType: limitString(e.agentType, 15),
237
+ currentTool: limitString(e.currentTool, 39),
238
+ toolInput: limitString(e.toolInput, 79),
239
+ projectName: limitString(e.projectName, 39),
240
+ modelName: limitString(e.modelName, 31),
241
+ effortLevel: limitString(e.effortLevel, 7),
242
+ promptType: limitString(e.promptType, 19),
243
+ question: limitString(e.question, 199),
244
+ options: sanitizeOptions(e.options),
245
+ gatewayAvailable: Boolean(e.gatewayAvailable),
246
+ gatewayConnected: Boolean(e.gatewayConnected),
247
+ gatewayHasError: Boolean(e.gatewayHasError),
248
+ };
249
+ }
250
+ if (event.type === 'timeline_event' || event.type === 'timeline_history') {
251
+ // Panels have no local timezone — attach host-local "HH:MM" so the
252
+ // InkDeck ticker (and future e-ink timelines) shows wall-clock time.
253
+ const stamp = (entry) => {
254
+ if (!entry || !Number.isFinite(entry.ts))
255
+ return entry;
256
+ const d = new Date(entry.ts);
257
+ return {
258
+ ...entry,
259
+ // Bound to the firmware's TimelineEntry buffers (raw[120]/detail[200])
260
+ // so a history seed with long chat bodies can't balloon the line.
261
+ raw: limitString(entry.raw, 119) ?? '',
262
+ detail: limitString(entry.detail, 199),
263
+ localHm: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
264
+ };
265
+ };
266
+ if (event.type === 'timeline_event')
267
+ return { ...e, entry: stamp(e.entry) };
268
+ return { ...e, entries: Array.isArray(e.entries) ? e.entries.map(stamp) : e.entries };
269
+ }
270
+ if (event.type === 'sessions_list') {
271
+ const raw = Array.isArray(e.sessions) ? e.sessions : [];
272
+ return {
273
+ type: 'sessions_list',
274
+ sessions: roundRobinByAgentType(raw, SERIAL_SESSIONS_CAP).map((s) => ({
275
+ id: limitString(s.id, 31),
276
+ projectName: limitString(s.projectName, 39),
277
+ modelName: limitString(s.modelName, 31),
278
+ agentType: limitString(s.agentType, 15),
279
+ state: limitString(s.state, 19),
280
+ port: Number.isFinite(s.port) ? s.port : 0,
281
+ alive: Boolean(s.alive),
282
+ // Per-session detail for the IPS10 D1 mosaic (cells show tool/elapsed,
283
+ // and awaiting cells render the real option buttons Claude reported).
284
+ currentTool: limitString(s.currentTool, 39),
285
+ promptType: limitString(s.promptType, 19),
286
+ question: limitString(s.question, 159),
287
+ elapsedSec: Number.isFinite(s.elapsedSec) ? Math.round(s.elapsedSec) : undefined,
288
+ // Shared activity one-liner — the glanceable "what is it doing" line
289
+ // (InkDeck session cards render it; other boards ignore it).
290
+ activity: limitString(s.activity, 79),
291
+ options: sanitizeOptions(s.options),
292
+ })),
293
+ };
63
294
  }
64
295
  return event;
65
296
  }
297
+ function limitString(value, max) {
298
+ if (typeof value !== 'string')
299
+ return undefined;
300
+ return value.length > max ? value.slice(0, max) : value;
301
+ }
302
+ /** ISO date → short "~M/D" for clock-less serial panels ('' when absent/invalid). */
303
+ function formatShortDate(iso) {
304
+ if (typeof iso !== 'string' || !iso)
305
+ return '';
306
+ const d = new Date(iso);
307
+ if (isNaN(d.getTime()))
308
+ return '';
309
+ return `~${d.getMonth() + 1}/${d.getDate()}`;
310
+ }
311
+ function sanitizeOptions(options) {
312
+ if (!Array.isArray(options))
313
+ return undefined;
314
+ return options.slice(0, 8).map((o, index) => ({
315
+ label: limitString(o?.label, 79) ?? '',
316
+ shortcut: limitString(o?.shortcut, 39),
317
+ index: Number.isFinite(o?.index) ? o.index : index,
318
+ recommended: Boolean(o?.recommended),
319
+ selected: Boolean(o?.selected),
320
+ }));
321
+ }
66
322
  /** Run a shell command with timeout, escalating to SIGKILL if SIGTERM fails. */
67
323
  function execWithKill(cmd, timeoutMs = 3000) {
68
324
  return new Promise((resolve, reject) => {
325
+ let settled = false;
69
326
  const child = exec(cmd, { encoding: 'utf-8', timeout: timeoutMs }, (err, stdout) => {
327
+ if (settled)
328
+ return;
329
+ settled = true;
70
330
  if (err) {
71
331
  reject(err);
72
332
  }
@@ -74,17 +334,94 @@ function execWithKill(cmd, timeoutMs = 3000) {
74
334
  resolve(stdout);
75
335
  }
76
336
  });
77
- // When exec timeout fires, it sends SIGTERM. But stty stuck in kernel I/O
78
- // ignores SIGTERM. Schedule a SIGKILL as escalation.
337
+ // Serial ioctl can enter uninterruptible kernel I/O on broken CDC ports.
338
+ // On timeout, unblock our promise immediately instead of waiting for exit.
79
339
  const killTimer = setTimeout(() => {
340
+ if (settled)
341
+ return;
342
+ settled = true;
80
343
  try {
81
344
  child.kill('SIGKILL');
82
345
  }
83
346
  catch { /* already dead */ }
347
+ reject(new Error(`command timed out: ${cmd}`));
84
348
  }, timeoutMs + 1000);
85
349
  child.on('exit', () => clearTimeout(killTimer));
86
350
  });
87
351
  }
352
+ function execFileWithKill(file, args, timeoutMs) {
353
+ return new Promise((resolve, reject) => {
354
+ let settled = false;
355
+ let killTimer = null;
356
+ const child = execFile(file, args, { encoding: 'utf-8', maxBuffer: 64 * 1024 }, (err, stdout, stderr) => {
357
+ if (settled)
358
+ return;
359
+ settled = true;
360
+ if (killTimer)
361
+ clearTimeout(killTimer);
362
+ if (err) {
363
+ const message = String(stderr || '').trim() || err.message;
364
+ reject(new Error(message));
365
+ }
366
+ else {
367
+ resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
368
+ }
369
+ });
370
+ killTimer = setTimeout(() => {
371
+ if (settled)
372
+ return;
373
+ settled = true;
374
+ try {
375
+ child.kill('SIGKILL');
376
+ }
377
+ catch { /* already dead */ }
378
+ reject(new Error(`timed out after ${timeoutMs}ms`));
379
+ }, timeoutMs);
380
+ child.on('exit', () => {
381
+ if (killTimer)
382
+ clearTimeout(killTimer);
383
+ });
384
+ });
385
+ }
386
+ async function probePortOpenable(port) {
387
+ try {
388
+ await execFileWithKill(process.execPath, ['-e', SERIAL_OPEN_PROBE_SCRIPT, port], SERIAL_OPEN_PROBE_TIMEOUT_MS);
389
+ }
390
+ catch (err) {
391
+ const message = err instanceof Error ? err.message : String(err);
392
+ throw new Error(`open probe failed: ${message}`);
393
+ }
394
+ }
395
+ async function openFdWithProbe(port) {
396
+ // Isolate potentially wedged macOS CDC/serial open calls in a short-lived
397
+ // process. Promise.race around fs.open() in this daemon can leave late file
398
+ // descriptors owned by the daemon after timeout, permanently blocking polls.
399
+ await probePortOpenable(port);
400
+ return openSync(port, fsConstants.O_RDWR | fsConstants.O_NOCTTY | fsConstants.O_NONBLOCK);
401
+ }
402
+ function readFromFd(fd, buf) {
403
+ return new Promise((resolve, reject) => {
404
+ read(fd, buf, 0, buf.length, null, (err, bytesRead) => {
405
+ if (err)
406
+ reject(err);
407
+ else
408
+ resolve({ bytesRead });
409
+ });
410
+ });
411
+ }
412
+ function writeToFd(fd, data) {
413
+ return new Promise((resolve, reject) => {
414
+ write(fd, data, (err) => {
415
+ if (err)
416
+ reject(err);
417
+ else
418
+ resolve();
419
+ });
420
+ });
421
+ }
422
+ function closeFd(fd) {
423
+ close(fd, () => { });
424
+ }
88
425
  async function detectESP32Ports() {
89
426
  try {
90
427
  const platform = process.platform;
@@ -104,12 +441,57 @@ async function detectESP32Ports() {
104
441
  if (EXCLUDE_PATTERNS.some(p => p.test(port)))
105
442
  return false;
106
443
  return ESP32_PORT_PATTERNS.some(p => p.test(port));
107
- });
444
+ }).sort(compareSerialPorts);
108
445
  }
109
- catch {
446
+ catch (err) {
447
+ lastErrorByPort.set('detect', `port detection failed: ${err instanceof Error ? err.message : String(err)}`);
110
448
  return [];
111
449
  }
112
450
  }
451
+ function compareSerialPorts(a, b) {
452
+ const aNative = /usbmodem|ttyACM/.test(a);
453
+ const bNative = /usbmodem|ttyACM/.test(b);
454
+ if (aNative !== bNative)
455
+ return aNative ? 1 : -1;
456
+ return a.localeCompare(b);
457
+ }
458
+ function serialError(port, message) {
459
+ lastErrorByPort.set(port, message);
460
+ debug('ESP32', message);
461
+ }
462
+ function isResponsive(conn, now = Date.now()) {
463
+ if (!conn.connected)
464
+ return false;
465
+ const hasRead = conn.lastReadAt > 0;
466
+ if (hasRead && (now - conn.lastReadAt) <= STALE_THRESHOLD_MS) {
467
+ return true;
468
+ }
469
+ // Fallback for native USB CDC ports: if we have written to it successfully recently,
470
+ // and it is open, consider it responsive. This prevents native CDC displays (which may
471
+ // not send read data due to DTR/RTS line states in raw fs mode) from being marked disconnected.
472
+ const isCDC = /usbmodem|ttyACM/.test(conn.port);
473
+ if (isCDC && conn.lastWriteAt > 0 && (now - conn.lastWriteAt) <= STALE_THRESHOLD_MS) {
474
+ return true;
475
+ }
476
+ // If no read has occurred yet, allow an initial grace period
477
+ if (conn.lastReadAt <= 0) {
478
+ return (now - conn.connectedAt) <= INITIAL_READ_TIMEOUT_MS;
479
+ }
480
+ return false;
481
+ }
482
+ /** @internal Exported for testing only */
483
+ export function isRetryableSerialIoError(err) {
484
+ const code = err?.code;
485
+ return code === 'EAGAIN' || code === 'EWOULDBLOCK';
486
+ }
487
+ function hasLiveDeviceInfo(conn) {
488
+ const isCDC = /usbmodem|ttyACM/.test(conn.port);
489
+ if (isCDC) {
490
+ // CDC ports may have no read traffic but can still run dashboard/sessions if cached board type exists
491
+ return Boolean(conn.deviceInfo?.board);
492
+ }
493
+ return Boolean(conn.deviceInfo?.board) && conn.lastReadAt > 0;
494
+ }
113
495
  /** @internal Exported for testing only */
114
496
  export function handleSerialLine(conn, line) {
115
497
  if (!line.startsWith('{'))
@@ -117,15 +499,46 @@ export function handleSerialLine(conn, line) {
117
499
  try {
118
500
  const msg = JSON.parse(line);
119
501
  if (msg.type) {
502
+ conn.lastReadAt = Date.now(); // Update last read timestamp
120
503
  debug('ESP32', `← ${conn.port}: ${msg.type}`);
121
- conn.deviceInfo = conn.deviceInfo || {};
122
504
  if (msg.type === 'device_info') {
123
505
  conn.deviceInfo = {
124
506
  board: msg.board,
125
507
  version: msg.version,
508
+ buildHash: msg.buildHash,
509
+ buildEpoch: msg.buildEpoch,
126
510
  wifiConfigured: msg.wifiConfigured,
127
511
  wifiConnected: msg.wifiConnected,
512
+ wifiRadioParked: msg.wifiRadioParked,
513
+ ip: msg.ip,
514
+ uptimeSec: msg.uptimeSec,
515
+ resetReason: msg.resetReason,
516
+ resetReasonCode: msg.resetReasonCode,
517
+ otaSupported: msg.otaSupported,
518
+ otaSlotCount: msg.otaSlotCount,
519
+ otaSlotSize: msg.otaSlotSize,
520
+ otaFreeSketchSpace: msg.otaFreeSketchSpace,
521
+ otaReason: msg.otaReason,
522
+ // Board-side reality counters (debug aid — surfaced on /devices so
523
+ // "device shows nothing" can be diagnosed without stealing the port)
524
+ timelineCount: msg.timelineCount,
525
+ sessionCount: msg.sessionCount,
526
+ usageFiveH: msg.usageFiveH,
527
+ processingCount: msg.processingCount,
128
528
  };
529
+ conn.deviceInfoFresh = true;
530
+ if (conn.deviceInfo.board) {
531
+ lastKnownDeviceInfoByPort.set(conn.port, conn.deviceInfo);
532
+ persistDeviceInfoCache();
533
+ }
534
+ }
535
+ else if (!conn.deviceInfoFresh &&
536
+ conn.deviceInfoRequestsSent < DEVICE_INFO_MAX_REQUESTS &&
537
+ Date.now() - conn.lastDeviceInfoRequestAt >= DEVICE_INFO_READ_RETRY_MS) {
538
+ // Retry until a device_info arrives on THIS connection — the cache
539
+ // seed keeps /devices populated meanwhile but must not silence the
540
+ // re-identify (a lost reply or reflashed board would stay stale).
541
+ sendDeviceInfoRequest(conn);
129
542
  }
130
543
  if (messageHandler) {
131
544
  messageHandler(conn.port, msg);
@@ -136,35 +549,16 @@ export function handleSerialLine(conn, line) {
136
549
  // Not valid JSON — ignore (ESP32 debug output)
137
550
  }
138
551
  }
139
- async function openPort(port) {
140
- try {
141
- // Open streams first, THEN configure baud rate.
142
- // On macOS, opening a serial device can reset stty settings to defaults (9600).
143
- // So stty must run AFTER the file descriptors are open.
144
- const stream = createWriteStream(port, { flags: 'w' });
145
- const conn = {
146
- port, stream, reader: null, readBuf: '',
147
- connected: true, deviceInfo: null, provisionSent: false,
148
- };
149
- stream.on('error', (err) => {
150
- debug('ESP32', `Serial write error on ${port}: ${err.message}`);
151
- conn.connected = false;
152
- try {
153
- conn.reader?.destroy();
154
- }
155
- catch { /* ignore */ }
156
- });
157
- stream.on('close', () => {
158
- debug('ESP32', `Serial write closed: ${port}`);
159
- conn.connected = false;
160
- });
161
- // Open read stream for incoming ESP32 messages
552
+ async function startReadLoop(conn) {
553
+ const buf = Buffer.alloc(1024);
554
+ while (conn.connected && conn.fd != null) {
162
555
  try {
163
- const reader = createReadStream(port, { flags: 'r', encoding: 'utf-8' });
164
- reader.pause(); // Don't process data until baud rate is configured
165
- conn.reader = reader;
166
- reader.on('data', (chunk) => {
167
- const str = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
556
+ const fd = conn.fd;
557
+ if (fd == null)
558
+ break;
559
+ const { bytesRead } = await readFromFd(fd, buf);
560
+ if (bytesRead > 0) {
561
+ const str = buf.toString('utf-8', 0, bytesRead);
168
562
  conn.readBuf += str;
169
563
  let newlineIdx;
170
564
  while ((newlineIdx = conn.readBuf.indexOf('\n')) !== -1) {
@@ -174,55 +568,67 @@ async function openPort(port) {
174
568
  handleSerialLine(conn, line);
175
569
  }
176
570
  }
177
- // Prevent buffer bloat from non-newline data
178
571
  if (conn.readBuf.length > 8192) {
179
572
  conn.readBuf = '';
180
573
  }
181
- });
182
- reader.on('error', (err) => {
183
- debug('ESP32', `Serial read error on ${port}: ${err.message}`);
184
- conn.connected = false;
185
- try {
186
- reader.destroy();
187
- }
188
- catch { /* ignore */ }
189
- try {
190
- conn.stream.end();
191
- }
192
- catch { /* ignore */ }
193
- });
194
- reader.on('close', () => {
195
- conn.reader = null;
196
- conn.connected = false;
197
- });
574
+ }
575
+ else {
576
+ // EOF
577
+ await new Promise(r => setTimeout(r, 20));
578
+ }
198
579
  }
199
580
  catch (err) {
200
- debug('ESP32', `Failed to open reader on ${port}: ${err.message}`);
201
- // Write-only still works for broadcast
202
- }
203
- // Configure baud rate AFTER file descriptors are open.
204
- // On macOS, open() on serial devices can reset stty to defaults (9600).
205
- // Skip stty for native USB CDC ports (cu.usbmodem / ttyACM) — they use
206
- // USB bus speed, not UART baud rate. stty can interfere with open fds.
207
- const isCDC = /usbmodem|ttyACM/.test(port);
208
- if (!isCDC) {
209
- const platform = process.platform;
210
- if (platform === 'darwin') {
211
- await execWithKill(`stty -f ${port} 115200 cs8 -cstopb -parenb -hupcl`);
581
+ if (err.code === 'EAGAIN' || err.code === 'EWOULDBLOCK') {
582
+ // No data right now, wait 20ms and try again
583
+ await new Promise(r => setTimeout(r, 20));
212
584
  }
213
- else if (platform === 'linux') {
214
- await execWithKill(`stty -F ${port} 115200 cs8 -cstopb -parenb -hupcl`);
585
+ else {
586
+ debug('ESP32', `Serial read error on ${conn.port}: ${err.message}`);
587
+ closeConnection(conn);
588
+ break;
215
589
  }
216
590
  }
217
- // Resume reader now that baud rate is configured (flush any garbled data from open→stty window)
218
- if (conn.reader) {
219
- conn.readBuf = '';
220
- conn.reader.resume();
591
+ }
592
+ }
593
+ async function openPort(port) {
594
+ let fd = null;
595
+ try {
596
+ // Use one read/write fd. Opening /dev/cu.* separately for read and write
597
+ // can make macOS serial look writable while ESP32 never receives host JSON.
598
+ fd = await openFdWithProbe(port);
599
+ const platform = process.platform;
600
+ const isCDC = /usbmodem|ttyACM/.test(port);
601
+ if (!isCDC && platform === 'darwin') {
602
+ await execWithKill(`stty -f ${port} 115200 cs8 -cstopb -parenb -hupcl raw -echo`);
603
+ }
604
+ else if (!isCDC && platform === 'linux') {
605
+ await execWithKill(`stty -F ${port} 115200 cs8 -cstopb -parenb -hupcl raw -echo`);
221
606
  }
607
+ const stream = {
608
+ destroyed: false,
609
+ writable: true,
610
+ write: () => true,
611
+ destroy: () => { },
612
+ on: () => { },
613
+ };
614
+ const conn = {
615
+ port, fd, stream, reader: null, readBuf: '',
616
+ connected: true, deviceInfo: lastKnownDeviceInfoByPort.get(port) ?? null,
617
+ deviceInfoFresh: false, provisionSent: false,
618
+ connectedAt: Date.now(),
619
+ lastReadAt: 0,
620
+ lastWriteAt: Date.now(),
621
+ lastDeviceInfoRequestAt: 0,
622
+ deviceInfoRequestsSent: 0,
623
+ writeQueue: [],
624
+ writeTimer: null,
625
+ };
222
626
  const portType = isCDC ? 'CDC' : 'UART';
223
627
  debug('ESP32', `Opened serial port (r/w): ${port} [${portType}]`);
628
+ // Start async read loop
629
+ void startReadLoop(conn);
224
630
  // Request device info on connect
225
- sendToConnection(conn, JSON.stringify({ type: 'device_info_request' }));
631
+ sendDeviceInfoRequest(conn);
226
632
  // Send full initial state (state + usage + sessions) so ESP32 doesn't
227
633
  // have to wait for next state change or heartbeat cycle
228
634
  if (initialStateProvider) {
@@ -233,34 +639,96 @@ async function openPort(port) {
233
639
  // Skip usage_update without API data — would reset ESP32 to "no data"
234
640
  if (event.type === 'usage_update' && event.fiveHourPercent == null)
235
641
  continue;
236
- sendToConnection(conn, JSON.stringify(prepareForSerial(event)));
642
+ if ((event.type === 'usage_update' || event.type === 'sessions_list') && !hasLiveDeviceInfo(conn))
643
+ continue;
644
+ sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
237
645
  }
238
646
  }
239
647
  else if (stateProvider) {
240
648
  const event = stateProvider();
241
649
  if (event)
242
- sendToConnection(conn, JSON.stringify(event));
650
+ sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
243
651
  }
244
652
  return conn;
245
653
  }
246
654
  catch (err) {
247
- debug('ESP32', `Failed to open ${port}: ${err.message}`);
655
+ if (fd != null) {
656
+ closeFd(fd);
657
+ }
658
+ serialError(port, `Failed to open ${port}: ${err.message}`);
248
659
  return null;
249
660
  }
250
661
  }
251
- function sendToConnection(conn, json) {
662
+ function closeConnection(conn) {
663
+ conn.connected = false;
664
+ if (conn.writeTimer) {
665
+ clearTimeout(conn.writeTimer);
666
+ conn.writeTimer = null;
667
+ }
668
+ conn.writeQueue = [];
669
+ if (conn.fd != null)
670
+ closeFd(conn.fd);
671
+ conn.fd = null;
672
+ }
673
+ function sendToConnection(conn, json, priority = false) {
252
674
  if (!conn.connected)
253
675
  return;
254
- if (conn.stream.destroyed || !conn.stream.writable) {
255
- conn.connected = false;
256
- return;
676
+ if (priority) {
677
+ conn.writeQueue.unshift(json);
257
678
  }
258
- try {
259
- conn.stream.write(json + '\n');
679
+ else {
680
+ conn.writeQueue.push(json);
260
681
  }
261
- catch {
262
- conn.connected = false;
682
+ while (conn.writeQueue.length > SERIAL_MAX_QUEUE) {
683
+ const dropIdx = conn.writeQueue.findIndex((queued) => queued.startsWith('{"type":"state_update"') || queued.startsWith('{"type":"sessions_list"'));
684
+ conn.writeQueue.splice(dropIdx >= 0 ? dropIdx : 0, 1);
263
685
  }
686
+ scheduleWriteFlush(conn, 0);
687
+ }
688
+ function scheduleWriteFlush(conn, delayMs) {
689
+ if (conn.writeTimer || !conn.connected)
690
+ return;
691
+ conn.writeTimer = setTimeout(() => flushNextWrite(conn), delayMs);
692
+ }
693
+ function flushNextWrite(conn) {
694
+ conn.writeTimer = null;
695
+ if (!conn.connected)
696
+ return;
697
+ const json = conn.writeQueue.shift();
698
+ if (!json)
699
+ return;
700
+ if (conn.fd == null) {
701
+ closeConnection(conn);
702
+ return;
703
+ }
704
+ writeToFd(conn.fd, json + '\n')
705
+ .then(() => {
706
+ conn.lastWriteAt = Date.now();
707
+ if (conn.writeQueue.length > 0) {
708
+ scheduleWriteFlush(conn, SERIAL_WRITE_INTERVAL_MS);
709
+ }
710
+ })
711
+ .catch((err) => {
712
+ if (isRetryableSerialIoError(err)) {
713
+ conn.writeQueue.unshift(json);
714
+ scheduleWriteFlush(conn, SERIAL_WRITE_INTERVAL_MS);
715
+ return;
716
+ }
717
+ debug('ESP32', `Serial write error on ${conn.port}: ${err.message}`);
718
+ closeConnection(conn);
719
+ });
720
+ }
721
+ function sendDeviceInfoRequest(conn) {
722
+ conn.lastDeviceInfoRequestAt = Date.now();
723
+ conn.deviceInfoRequestsSent++;
724
+ sendToConnection(conn, JSON.stringify({ type: 'device_info_request' }), true);
725
+ }
726
+ function sendSerialKeepalive(conn) {
727
+ // ESP32 marks USB as connected only after successfully parsing recent JSON.
728
+ // Keep this independent from larger state/session payloads so display
729
+ // reconnect overlays do not flap when a board is slow to parse UI updates.
730
+ conn.writeQueue = conn.writeQueue.filter((queued) => queued !== SERIAL_KEEPALIVE_JSON);
731
+ sendToConnection(conn, SERIAL_KEEPALIVE_JSON, true);
264
732
  }
265
733
  /**
266
734
  * Register a callback that returns the current state_update event.
@@ -277,6 +745,25 @@ export function setESP32StateProvider(provider) {
277
745
  export function setESP32UsageProvider(provider) {
278
746
  usageProvider = provider;
279
747
  }
748
+ /**
749
+ * Register a callback that returns the current display_state event.
750
+ * display_state is otherwise edge-triggered (on change + on connect); a board
751
+ * that misses the wake edge (half-open serial, daemon handoff) would stay
752
+ * dark until power-cycled. The heartbeat re-sync makes it self-heal.
753
+ */
754
+ export function setESP32DisplayStateProvider(provider) {
755
+ displayStateProvider = provider;
756
+ }
757
+ /**
758
+ * Register a callback that returns the current sessions_list event.
759
+ * Like display_state it is otherwise edge-triggered (on change + on connect);
760
+ * a board that (re)connects during a quiet window — daemon handoff, half-open
761
+ * serial — would sit on an empty roster ("no active sessions") until the next
762
+ * unrelated session change. The heartbeat re-sync makes it self-heal.
763
+ */
764
+ export function setESP32SessionsListProvider(provider) {
765
+ sessionsListProvider = provider;
766
+ }
280
767
  /**
281
768
  * Register a provider that returns all initial state events (state + usage + sessions).
282
769
  * Called when a new ESP32 device connects to send full state immediately.
@@ -296,28 +783,222 @@ function sendHeartbeat() {
296
783
  if (connections.length === 0)
297
784
  return;
298
785
  heartbeatCount++;
786
+ for (const conn of connections) {
787
+ if (conn.connected)
788
+ sendSerialKeepalive(conn);
789
+ // Refresh the identity snapshot about once a minute even after a fresh
790
+ // device_info landed — its debug counters (sessionCount/timelineCount)
791
+ // are point-in-time, and a connect-moment snapshot reading "0" on
792
+ // /devices masquerades as "board parses nothing".
793
+ if (conn.deviceInfoFresh && Date.now() - conn.lastDeviceInfoRequestAt > 60_000) {
794
+ sendDeviceInfoRequest(conn);
795
+ }
796
+ }
299
797
  // Send state_update (stripped for serial — smaller payload)
300
798
  if (stateProvider) {
301
799
  const event = stateProvider();
302
800
  if (event) {
303
- const json = JSON.stringify(prepareForSerial(event));
304
801
  for (const conn of connections) {
305
- sendToConnection(conn, json);
802
+ sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
803
+ }
804
+ }
805
+ }
806
+ // Re-sync display_state every cycle. It is edge-triggered elsewhere, and a
807
+ // board that misses a wake edge would otherwise stay blacked out until the
808
+ // next reconnect or a power cycle (payload is ~70 bytes; the firmware
809
+ // handler is idempotent).
810
+ if (displayStateProvider) {
811
+ const event = displayStateProvider();
812
+ if (event) {
813
+ for (const conn of connections) {
814
+ sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
815
+ }
816
+ }
817
+ }
818
+ // Re-sync sessions_list every cycle for the same reason: it is edge-triggered
819
+ // elsewhere, so a board that reconnects during a quiet window shows "no active
820
+ // sessions" until the next session change. Only send once a device has
821
+ // identified (matches the sessions_list gating in the broadcast path).
822
+ if (sessionsListProvider) {
823
+ const event = sessionsListProvider();
824
+ if (event) {
825
+ for (const conn of connections) {
826
+ if (!hasLiveDeviceInfo(conn))
827
+ continue;
828
+ sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
306
829
  }
307
830
  }
308
831
  }
309
832
  // Send usage_update (so ESP32 always has fresh usage/reset times)
310
- // Only send if API usage data is present (fiveHourPercent defined),
311
- // otherwise the ESP32 would reset its cached values to "no data" sentinel.
833
+ // Only send once SOME usage signal is present Claude 5h/7d, Codex limits,
834
+ // or Antigravity credits — otherwise the ESP32 would reset its cached values
835
+ // to the "no data" sentinel before any provider has populated them.
312
836
  if (usageProvider) {
313
837
  const event = usageProvider();
314
- if (event && event.fiveHourPercent != null) {
315
- const json = JSON.stringify(prepareForSerial(event));
838
+ const u = event;
839
+ const hasUsage = u && (u.fiveHourPercent != null || u.codexRateLimits != null || u.antigravityStatus != null);
840
+ if (hasUsage && event) {
316
841
  for (const conn of connections) {
317
- sendToConnection(conn, json);
842
+ if (!hasLiveDeviceInfo(conn))
843
+ continue;
844
+ sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
318
845
  }
319
846
  }
320
847
  }
848
+ // Retry device identification. Some boards answer heartbeats/provisioning
849
+ // but miss the first request after USB reset; without a retry they stay
850
+ // anonymous and surfaces render them as reconnecting.
851
+ //
852
+ // Gate on deviceInfoFresh, NOT conn.deviceInfo: a cache-seeded connection has
853
+ // a non-null (stale) deviceInfo but deviceInfoFresh=false. Keying off
854
+ // conn.deviceInfo froze such a connection at the cache seed forever — a board
855
+ // reflashed/OTA-updated on the same port (e.g. inkdeck round8→round9) would
856
+ // keep reporting its old buildHash/no-OTA because no path ever re-requested.
857
+ // deviceInfoFresh only flips true once a LIVE device_info lands on THIS
858
+ // connection, so a fresh board stops the retries (capped at MAX) as before.
859
+ const now = Date.now();
860
+ for (const conn of connections) {
861
+ if (!shouldRetryDeviceInfoIdentify(conn, now))
862
+ continue;
863
+ const seeded = conn.deviceInfo ? ' (cache-seeded — refreshing identity)' : '';
864
+ debug('ESP32', `${conn.port}: no live device_info yet${seeded} — retrying request`);
865
+ sendDeviceInfoRequest(conn);
866
+ }
867
+ // Check for stale connections and trigger recovery
868
+ checkStaleConnections();
869
+ }
870
+ /**
871
+ * A connection that opened cleanly but has never spoken the AgentDeck protocol:
872
+ * no device_info, no cached board for this port, and not a single valid JSON
873
+ * line read. Strong evidence it's a foreign device (e.g. a TRMNL panel) we
874
+ * should stop holding/resetting rather than an AgentDeck board that's just slow.
875
+ */
876
+ /** @internal Exported for testing only */
877
+ export function isUnidentifiedForeign(conn) {
878
+ return !conn.deviceInfo?.board && !lastKnownDeviceInfoByPort.has(conn.port) && conn.lastReadAt === 0;
879
+ }
880
+ /**
881
+ * A CDC port that IS an AgentDeck board (live device_info, or a cached board for
882
+ * this port) yet has produced no read since this (re)connection. A healthy board
883
+ * answers device_info/keepalive within seconds, so lastReadAt===0 well past a
884
+ * generous grace means a half-open USB-CDC pipe (board reset / re-enumerated)
885
+ * that heartbeat writes will never revive — the FD must be recycled so re-poll
886
+ * re-probes. Mutually exclusive with isUnidentifiedForeign (this REQUIRES an
887
+ * identified board; that one requires none).
888
+ */
889
+ /** @internal Exported for testing only */
890
+ export function isHalfOpenIdentifiedCdc(conn, now = Date.now()) {
891
+ const isCDC = /usbmodem|ttyACM/.test(conn.port);
892
+ if (!isCDC)
893
+ return false;
894
+ const isIdentified = Boolean(conn.deviceInfo?.board) || lastKnownDeviceInfoByPort.has(conn.port);
895
+ return isIdentified && conn.lastReadAt === 0 && (now - conn.connectedAt) > CDC_SILENT_READ_TIMEOUT_MS;
896
+ }
897
+ /**
898
+ * Whether the periodic heartbeat should (re)send a device_info_request to
899
+ * re-identify this connection. Keys off deviceInfoFresh (a LIVE reply on THIS
900
+ * connection), NOT conn.deviceInfo — a cache-seeded connection carries a stale
901
+ * deviceInfo but deviceInfoFresh=false and must keep re-requesting until the
902
+ * board answers, so a reflashed/OTA-updated board on the same port refreshes
903
+ * its buildHash/OTA fields instead of freezing at the cache seed. Capped by
904
+ * DEVICE_INFO_MAX_REQUESTS and paced by DEVICE_INFO_RETRY_MS.
905
+ */
906
+ /** @internal Exported for testing only */
907
+ export function shouldRetryDeviceInfoIdentify(conn, now = Date.now()) {
908
+ if (!conn.connected || conn.deviceInfoFresh)
909
+ return false;
910
+ if (conn.deviceInfoRequestsSent >= DEVICE_INFO_MAX_REQUESTS)
911
+ return false;
912
+ if (now - conn.lastDeviceInfoRequestAt < DEVICE_INFO_RETRY_MS)
913
+ return false;
914
+ return true;
915
+ }
916
+ function denylistForeignPort(port, reason) {
917
+ foreignDenylistUntil.set(port, Date.now() + FOREIGN_DENYLIST_COOLDOWN_MS);
918
+ foreignProbeFailures.delete(port);
919
+ debug('ESP32', `Denylisting non-AgentDeck port ${port} for ${Math.round(FOREIGN_DENYLIST_COOLDOWN_MS / 60000)}min (${reason})`);
920
+ }
921
+ /** @internal Exported for testing only. True if the port is in active cooldown. */
922
+ export function isForeignPortDenylisted(port, now = Date.now()) {
923
+ const until = foreignDenylistUntil.get(port);
924
+ return until != null && until > now;
925
+ }
926
+ /** @internal Exported for testing only. Clear all foreign-port tracking state. */
927
+ export function __resetForeignPortState() {
928
+ foreignProbeFailures.clear();
929
+ foreignDenylistUntil.clear();
930
+ }
931
+ /** @internal Exported for testing only.
932
+ * Count a failed identification cycle; denylist once it crosses the threshold. */
933
+ export function recordForeignProbeFailure(port) {
934
+ const next = (foreignProbeFailures.get(port) ?? 0) + 1;
935
+ foreignProbeFailures.set(port, next);
936
+ if (next >= FOREIGN_MAX_PROBE_FAILURES) {
937
+ denylistForeignPort(port, `${next} failed probes`);
938
+ }
939
+ }
940
+ /**
941
+ * Check for stale connections (no read for >60s) and close them.
942
+ * This allows re-poll to automatically reconnect devices that stopped responding.
943
+ */
944
+ function checkStaleConnections() {
945
+ const now = Date.now();
946
+ let staleCount = 0;
947
+ for (const conn of connections) {
948
+ if (!conn.connected)
949
+ continue;
950
+ const isCDC = /usbmodem|ttyACM/.test(conn.port);
951
+ const hasRead = conn.lastReadAt > 0;
952
+ const readAge = hasRead ? now - conn.lastReadAt : now - conn.connectedAt;
953
+ const writeAge = now - conn.lastWriteAt;
954
+ // For CDC ports, do not close due to lack of read traffic if write is active and healthy.
955
+ if (isCDC) {
956
+ // A held-open CDC port that never identifies is a foreign device; denylist
957
+ // it so we stop occupying it (CDC ports aren't otherwise reaped on silence).
958
+ if (isUnidentifiedForeign(conn) && (now - conn.connectedAt) > FOREIGN_CDC_GRACE_MS) {
959
+ denylistForeignPort(conn.port, 'CDC never identified');
960
+ closeConnection(conn);
961
+ staleCount++;
962
+ continue;
963
+ }
964
+ // A CDC port whose board is identified but has read nothing since connect is
965
+ // half-open (board reset, RX pipe dead). Recycle the FD so re-poll re-probes;
966
+ // the reopen's DTR/RTS toggle may itself revive the board. Feed the foreign
967
+ // probe-failure counter so a permanently-dead board denylists after a few
968
+ // strikes instead of getting DTR/RTS-reset every grace window.
969
+ if (isHalfOpenIdentifiedCdc(conn, now)) {
970
+ debug('ESP32', `Half-open CDC (identified, no read since connect): ${conn.port} — recycling`);
971
+ recordForeignProbeFailure(conn.port);
972
+ closeConnection(conn);
973
+ staleCount++;
974
+ continue;
975
+ }
976
+ if (writeAge > STALE_THRESHOLD_MS) {
977
+ debug('ESP32', `Stale CDC connection (no write for >60s): ${conn.port}`);
978
+ closeConnection(conn);
979
+ staleCount++;
980
+ }
981
+ continue;
982
+ }
983
+ if ((!hasRead && readAge > INITIAL_READ_TIMEOUT_MS) ||
984
+ (hasRead && readAge > STALE_THRESHOLD_MS && writeAge > STALE_THRESHOLD_MS)) {
985
+ debug('ESP32', `Stale connection detected: ${conn.port} (${Math.floor(readAge / 1000)}s ${hasRead ? 'since last read' : 'without initial read'}, ${Math.floor(writeAge / 1000)}s since write)`);
986
+ // A UART port closed before ever identifying counts as a foreign probe
987
+ // failure; enough failures denylist it so we stop reopening (and resetting) it.
988
+ if (isUnidentifiedForeign(conn))
989
+ recordForeignProbeFailure(conn.port);
990
+ closeConnection(conn);
991
+ staleCount++;
992
+ }
993
+ }
994
+ if (staleCount > 0) {
995
+ // Remove stale connections immediately, then trigger re-poll
996
+ connections = connections.filter(c => c.connected);
997
+ debug('ESP32', `Closed ${staleCount} stale connection(s), re-polling...`);
998
+ pollForDevices().catch(err => {
999
+ debug('ESP32', `Re-poll after stale cleanup failed: ${err.message}`);
1000
+ });
1001
+ }
321
1002
  }
322
1003
  /**
323
1004
  * Start ESP32 serial bridge.
@@ -328,6 +1009,7 @@ function sendHeartbeat() {
328
1009
  * USB port (stty stuck in kernel I/O) cannot block bridge startup.
329
1010
  */
330
1011
  export function startESP32Serial() {
1012
+ loadDeviceInfoCache();
331
1013
  // Fire-and-forget initial detection (non-blocking)
332
1014
  pollForDevices().catch(err => {
333
1015
  debug('ESP32', `Initial poll failed: ${err.message}`);
@@ -343,31 +1025,69 @@ export function startESP32Serial() {
343
1025
  debug('ESP32', 'Serial bridge started');
344
1026
  }
345
1027
  async function pollForDevices() {
346
- const ports = await detectESP32Ports();
347
- // Remove disconnected
348
- connections = connections.filter(c => {
349
- if (!c.connected) {
350
- try {
351
- c.stream.end();
1028
+ if (pollInProgress)
1029
+ return;
1030
+ pollInProgress = true;
1031
+ try {
1032
+ const ports = [...new Set(await detectESP32Ports())];
1033
+ const portSet = new Set(ports);
1034
+ // Forget foreign-port state for ports that are gone (a replug re-probes
1035
+ // fresh) or whose cooldown has elapsed. Then skip ports still in cooldown so
1036
+ // we don't reopen — and reset — a non-AgentDeck device every poll.
1037
+ const nowTs = Date.now();
1038
+ for (const [p, until] of [...foreignDenylistUntil]) {
1039
+ if (until <= nowTs || !portSet.has(p))
1040
+ foreignDenylistUntil.delete(p);
1041
+ }
1042
+ for (const p of [...foreignProbeFailures.keys()]) {
1043
+ if (!portSet.has(p))
1044
+ foreignProbeFailures.delete(p);
1045
+ }
1046
+ const allowedPorts = ports.filter((p) => {
1047
+ const until = foreignDenylistUntil.get(p);
1048
+ if (until && until > nowTs) {
1049
+ debug('ESP32', `Skipping denylisted non-AgentDeck port ${p} (${Math.ceil((until - nowTs) / 1000)}s left)`);
1050
+ return false;
1051
+ }
1052
+ return true;
1053
+ });
1054
+ // Remove disconnected or unplugged ports. Read silence alone is handled
1055
+ // separately so half-duplex-but-rendering boards are not reset every minute.
1056
+ connections = connections.filter(c => {
1057
+ if (!c.connected) {
1058
+ closeConnection(c);
1059
+ return false;
352
1060
  }
353
- catch { /* ignore */ }
1061
+ if (!portSet.has(c.port)) {
1062
+ debug('ESP32', `Serial port disappeared: ${c.port}`);
1063
+ closeConnection(c);
1064
+ return false;
1065
+ }
1066
+ return true;
1067
+ });
1068
+ // Add new ports (denylisted foreign ports are excluded via allowedPorts)
1069
+ for (const port of allowedPorts) {
1070
+ if (connections.some(c => c.port === port) || openingPorts.has(port))
1071
+ continue;
1072
+ openingPorts.add(port);
354
1073
  try {
355
- c.reader?.destroy();
1074
+ const conn = await openPort(port);
1075
+ if (conn && !connections.some(c => c.port === port)) {
1076
+ connections.push(conn);
1077
+ }
1078
+ else if (conn) {
1079
+ conn.connected = false;
1080
+ closeConnection(conn);
1081
+ }
356
1082
  }
357
- catch { /* ignore */ }
358
- return false;
359
- }
360
- return true;
361
- });
362
- // Add new ports
363
- for (const port of ports) {
364
- if (!connections.some(c => c.port === port)) {
365
- const conn = await openPort(port);
366
- if (conn) {
367
- connections.push(conn);
1083
+ finally {
1084
+ openingPorts.delete(port);
368
1085
  }
369
1086
  }
370
1087
  }
1088
+ finally {
1089
+ pollInProgress = false;
1090
+ }
371
1091
  }
372
1092
  /**
373
1093
  * Broadcast a BridgeEvent to all connected ESP32 devices via serial.
@@ -377,7 +1097,6 @@ export function broadcastESP32(event) {
377
1097
  return;
378
1098
  if (!FORWARDED_EVENTS.has(event.type))
379
1099
  return;
380
- const prepared = prepareForSerial(event);
381
1100
  // Debug: log sessions_list and state_update details for ESP32 troubleshooting
382
1101
  if (event.type === 'sessions_list') {
383
1102
  const e = event;
@@ -388,9 +1107,11 @@ export function broadcastESP32(event) {
388
1107
  const e = event;
389
1108
  debug('ESP32', `→ state_update: agent=${e.agentType} state=${e.state}`);
390
1109
  }
391
- const json = JSON.stringify(prepared);
392
1110
  for (const conn of connections) {
393
- sendToConnection(conn, json);
1111
+ if (event.type === 'sessions_list' && !hasLiveDeviceInfo(conn))
1112
+ continue;
1113
+ const prepared = prepareForSerial(event, conn);
1114
+ sendToConnection(conn, JSON.stringify(prepared));
394
1115
  }
395
1116
  }
396
1117
  /**
@@ -400,7 +1121,7 @@ export function sendWifiProvision(port, msg) {
400
1121
  const conn = connections.find(c => c.port === port && c.connected);
401
1122
  if (!conn)
402
1123
  return false;
403
- sendToConnection(conn, JSON.stringify(msg));
1124
+ sendToConnection(conn, JSON.stringify(msg), true);
404
1125
  conn.provisionSent = true;
405
1126
  debug('ESP32', `→ ${port}: wifi_provision (SSID: ${msg.ssid})`);
406
1127
  return true;
@@ -411,31 +1132,76 @@ export function sendWifiProvision(port, msg) {
411
1132
  export function sendWifiProvisionToAll(msg) {
412
1133
  let count = 0;
413
1134
  for (const conn of connections) {
414
- if (!conn.connected)
415
- continue;
416
- // Skip if already provisioned or WiFi already configured
417
- if (conn.provisionSent)
1135
+ if (!shouldSendWifiProvision(conn))
418
1136
  continue;
419
- if (conn.deviceInfo?.wifiConnected)
420
- continue;
421
- sendToConnection(conn, JSON.stringify(msg));
1137
+ sendToConnection(conn, JSON.stringify(msg), true);
422
1138
  conn.provisionSent = true;
423
1139
  count++;
424
1140
  debug('ESP32', `→ ${conn.port}: wifi_provision (SSID: ${msg.ssid})`);
425
1141
  }
426
1142
  return count;
427
1143
  }
1144
+ /** @internal Exported for testing only */
1145
+ export function shouldSendWifiProvision(conn) {
1146
+ if (!conn.connected)
1147
+ return false;
1148
+ if (!conn.deviceInfo?.board)
1149
+ return false;
1150
+ if (conn.provisionSent)
1151
+ return false;
1152
+ // IPS10 carries a daemon endpoint in NVS in addition to WiFi credentials.
1153
+ // Refresh it once while USB serial is active and the radio is online, even
1154
+ // if the board is already configured/connected; otherwise a stale endpoint
1155
+ // can survive and WiFi-only operation keeps dialing the old daemon host.
1156
+ if (conn.deviceInfo.board === 'ips_10' && conn.deviceInfo.wifiConnected && !conn.deviceInfo.wifiRadioParked) {
1157
+ return true;
1158
+ }
1159
+ if (conn.deviceInfo.wifiConnected)
1160
+ return false;
1161
+ // Auto-provision is for first setup. A configured board may intentionally have
1162
+ // WiFi off (IPS10 USB-primary radio parking) or may be temporarily away from
1163
+ // its AP; repeatedly injecting credentials would fight firmware policy.
1164
+ if (conn.deviceInfo.wifiConfigured)
1165
+ return false;
1166
+ return true;
1167
+ }
428
1168
  /**
429
1169
  * Get device info for all connected ESP32 devices.
430
1170
  */
431
1171
  export function getESP32DeviceInfo() {
1172
+ const now = Date.now();
432
1173
  return connections
433
- .filter(c => c.connected)
1174
+ .filter(c => isResponsive(c, now))
434
1175
  .map(c => ({
435
1176
  port: c.port,
436
1177
  ...c.deviceInfo,
437
1178
  }));
438
1179
  }
1180
+ /**
1181
+ * Board identities currently reachable over a LIVE, responsive USB serial
1182
+ * connection (has announced device_info and is actively read/written). The
1183
+ * daemon uses this to enforce single-path communication: when a physical board
1184
+ * is driven over serial, its WiFi WebSocket copy is redundant and is suppressed
1185
+ * (see daemon-server transport dedup). A power-only USB (no enumerated port) or
1186
+ * a silent/booting port is NOT responsive, so it never appears here and the
1187
+ * board keeps using WiFi — exactly the desired fallback.
1188
+ *
1189
+ * `ip` is the board's own WiFi IP (when connected), used to confirm the serial
1190
+ * connection and the WiFi socket are the same physical unit before deduping.
1191
+ */
1192
+ export function getSerialReachableBoards() {
1193
+ const now = Date.now();
1194
+ const out = [];
1195
+ for (const c of connections) {
1196
+ if (!isResponsive(c, now))
1197
+ continue;
1198
+ const board = c.deviceInfo?.board;
1199
+ if (!board)
1200
+ continue;
1201
+ out.push({ board, ip: c.deviceInfo?.wifiConnected ? c.deviceInfo?.ip : undefined });
1202
+ }
1203
+ return out;
1204
+ }
439
1205
  /**
440
1206
  * Wake recovery — close stale file descriptors, force immediate re-poll.
441
1207
  */
@@ -443,14 +1209,7 @@ export function handleESP32Wake() {
443
1209
  debug('ESP32', `Wake recovery — closing ${connections.length} stale connection(s)`);
444
1210
  for (const conn of connections) {
445
1211
  conn.connected = false;
446
- try {
447
- conn.stream.end();
448
- }
449
- catch { /* ignore */ }
450
- try {
451
- conn.reader?.destroy();
452
- }
453
- catch { /* ignore */ }
1212
+ closeConnection(conn);
454
1213
  }
455
1214
  connections = [];
456
1215
  // Delay re-poll 2s to let USB bus stabilize after wake
@@ -474,14 +1233,7 @@ export function stopESP32Serial() {
474
1233
  }
475
1234
  for (const conn of connections) {
476
1235
  conn.connected = false;
477
- try {
478
- conn.stream.end();
479
- }
480
- catch { /* ignore */ }
481
- try {
482
- conn.reader?.destroy();
483
- }
484
- catch { /* ignore */ }
1236
+ closeConnection(conn);
485
1237
  }
486
1238
  connections = [];
487
1239
  messageHandler = null;
@@ -491,12 +1243,57 @@ export function stopESP32Serial() {
491
1243
  * Get number of connected ESP32 devices.
492
1244
  */
493
1245
  export function esp32ConnectionCount() {
494
- return connections.filter(c => c.connected).length;
1246
+ const now = Date.now();
1247
+ return connections.filter(c => isResponsive(c, now)).length;
495
1248
  }
496
1249
  /**
497
1250
  * Get list of connected ESP32 serial port paths.
498
1251
  */
499
1252
  export function getESP32Ports() {
500
- return connections.filter(c => c.connected).map(c => c.port);
1253
+ const now = Date.now();
1254
+ return connections.filter(c => isResponsive(c, now)).map(c => c.port);
1255
+ }
1256
+ /**
1257
+ * Get detailed connection status for all ESP32 devices.
1258
+ * Used by /api/status endpoint to show connection health.
1259
+ */
1260
+ export function getSerialConnectionStatus() {
1261
+ const now = Date.now();
1262
+ return connections.map(c => ({
1263
+ port: c.port,
1264
+ connected: isResponsive(c, now),
1265
+ transportOpen: c.connected,
1266
+ board: c.deviceInfo?.board,
1267
+ version: c.deviceInfo?.version,
1268
+ buildHash: c.deviceInfo?.buildHash,
1269
+ buildEpoch: c.deviceInfo?.buildEpoch,
1270
+ wifiConfigured: c.deviceInfo?.wifiConfigured,
1271
+ wifiConnected: c.deviceInfo?.wifiConnected,
1272
+ wifiRadioParked: c.deviceInfo?.wifiRadioParked,
1273
+ uptimeSec: c.deviceInfo?.uptimeSec,
1274
+ resetReason: c.deviceInfo?.resetReason,
1275
+ resetReasonCode: c.deviceInfo?.resetReasonCode,
1276
+ otaSupported: c.deviceInfo?.otaSupported,
1277
+ otaSlotCount: c.deviceInfo?.otaSlotCount,
1278
+ otaSlotSize: c.deviceInfo?.otaSlotSize,
1279
+ otaFreeSketchSpace: c.deviceInfo?.otaFreeSketchSpace,
1280
+ otaReason: c.deviceInfo?.otaReason,
1281
+ timelineCount: c.deviceInfo?.timelineCount,
1282
+ sessionCount: c.deviceInfo?.sessionCount,
1283
+ usageFiveH: c.deviceInfo?.usageFiveH,
1284
+ processingCount: c.deviceInfo?.processingCount,
1285
+ lastReadAt: c.lastReadAt,
1286
+ lastWriteAt: c.lastWriteAt,
1287
+ lastReadSecondsAgo: c.lastReadAt > 0 ? Math.floor((now - c.lastReadAt) / 1000) : null,
1288
+ lastWriteSecondsAgo: Math.floor((now - c.lastWriteAt) / 1000),
1289
+ stale: c.connected && !isResponsive(c, now),
1290
+ }));
1291
+ }
1292
+ export function getSerialLastError() {
1293
+ if (lastErrorByPort.size === 0)
1294
+ return null;
1295
+ return Array.from(lastErrorByPort.entries())
1296
+ .map(([port, error]) => `${port}: ${error}`)
1297
+ .join('; ');
501
1298
  }
502
1299
  //# sourceMappingURL=esp32-serial.js.map